Compare commits
197
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95ade02bf2 | ||
|
|
275ef2009f | ||
|
|
435f442761 | ||
|
|
db58bfa982 | ||
|
|
94d13b82d7 | ||
|
|
f80b718bd4 | ||
|
|
808274b4fe | ||
|
|
54d18594a3 | ||
|
|
2766424bb2 | ||
|
|
fe651a8c37 | ||
|
|
d761020869 | ||
|
|
b2b772941b | ||
|
|
342c8af747 | ||
|
|
b6786881c3 | ||
|
|
c51134e0e5 | ||
|
|
bcaedbab5c | ||
|
|
e506c4ce98 | ||
|
|
b0ca8daa2e | ||
|
|
d7ee6f1339 | ||
|
|
3e3505ba33 | ||
|
|
a20858a6e1 | ||
|
|
388dd5b0e7 | ||
|
|
7b708a711c | ||
|
|
36395eb7f0 | ||
|
|
714680a94e | ||
|
|
c97b62004e | ||
|
|
98b428e8af | ||
|
|
a09d6b17f4 | ||
|
|
41c48b10e8 | ||
|
|
b0d60d0a5b | ||
|
|
71e759e477 | ||
|
|
24dd05db5d | ||
|
|
8d31d98eaa | ||
|
|
4d6cf9b961 | ||
|
|
dfe9880e47 | ||
|
|
822eb499e4 | ||
|
|
cc064d0ccf | ||
|
|
86cc8dd449 | ||
|
|
b204f968c6 | ||
|
|
d9a59b393d | ||
|
|
b1c720c74f | ||
|
|
301e8017d9 | ||
|
|
aac6f6efc2 | ||
|
|
4c6b1a8458 | ||
|
|
e88b9dedf0 | ||
|
|
15ddc68cf0 | ||
|
|
a45fc1b89c | ||
|
|
96b00108bb | ||
|
|
f4c01c2656 | ||
|
|
900e900e53 | ||
|
|
3c721ef6a5 | ||
|
|
f87a218eea | ||
|
|
472e7bec7e | ||
|
|
17a282054a | ||
|
|
0df1ff5705 | ||
|
|
870c7129b9 | ||
|
|
eef1adaa73 | ||
|
|
058f410a58 | ||
|
|
0383da87f5 | ||
|
|
cf292e3bc7 | ||
|
|
41aa4e676d | ||
|
|
8283d10b41 | ||
|
|
74592c25a5 | ||
|
|
2d5aaad41a | ||
|
|
46e686ce22 | ||
|
|
dc8265a850 | ||
|
|
3c42d1ecd5 | ||
|
|
a804f9a8db | ||
|
|
a31fdcdabd | ||
|
|
efd17f4263 | ||
|
|
f292294ad7 | ||
|
|
176b1b8169 | ||
|
|
0617664d97 | ||
|
|
ce76f4fa8d | ||
|
|
5fc741ab02 | ||
|
|
dc72665610 | ||
|
|
57edf974fd | ||
|
|
1d345e072e | ||
|
|
d606e11de4 | ||
|
|
95c9127e8e | ||
|
|
36cbdcf200 | ||
|
|
67e8f7ed5d | ||
|
|
661a15162c | ||
|
|
2d975baea2 | ||
|
|
424d797ecf | ||
|
|
387736fb56 | ||
|
|
1e46aad9ea | ||
|
|
7fee97497d | ||
|
|
026e74641e | ||
|
|
239730fd3b | ||
|
|
047ac32b19 | ||
|
|
60291c0e21 | ||
|
|
af51b37ae4 | ||
|
|
1c3df5c70f | ||
|
|
eed07bb1ee | ||
|
|
04b16b6432 | ||
|
|
9436ac8d4d | ||
|
|
27df8031ca | ||
|
|
6a4edd442f | ||
|
|
c59ff51361 | ||
|
|
207f8dcc2a | ||
|
|
84ba0000b8 | ||
|
|
ca47dc46e4 | ||
|
|
b6944b4ee1 | ||
|
|
8acf9418dc | ||
|
|
34a619b235 | ||
|
|
2e99893c8b | ||
|
|
07c16ddc25 | ||
|
|
1c089c4a10 | ||
|
|
e7bd24bbaa | ||
|
|
747002924d | ||
|
|
ae00f61711 | ||
|
|
c82bd7cae9 | ||
|
|
05e6b19bfa | ||
|
|
40a74e6cbe | ||
|
|
0534bf7c4b | ||
|
|
e984f11a94 | ||
|
|
7198efaccd | ||
|
|
d598501393 | ||
|
|
2b79160f95 | ||
|
|
732906bc7a | ||
|
|
3166a7b1ab | ||
|
|
1981e66b8a | ||
|
|
2c8afdef79 | ||
|
|
93ae8f8840 | ||
|
|
92cf86bf5c | ||
|
|
2de5e343b2 | ||
|
|
c49693bac6 | ||
|
|
bbbb8ef5d5 | ||
|
|
3f57e4bde1 | ||
|
|
2635e4615d | ||
|
|
eb64e4964d | ||
|
|
4468c2e025 | ||
|
|
b3494b9720 | ||
|
|
aa2133ed9a | ||
|
|
9231315186 | ||
|
|
62ff4d815d | ||
|
|
66a376d22c | ||
|
|
675df5710b | ||
|
|
7f38ac5366 | ||
|
|
fa4b562fcc | ||
|
|
05e2a2ec7b | ||
|
|
bf22aba9dd | ||
|
|
06300578be | ||
|
|
668c7241a1 | ||
|
|
a3010299d0 | ||
|
|
89fd595809 | ||
|
|
924a024b0b | ||
|
|
5c5a7b5155 | ||
|
|
b171f35be6 | ||
|
|
d813884074 | ||
|
|
d25eae5920 | ||
|
|
79dcd5cdf3 | ||
|
|
2bde697955 | ||
|
|
1f08443c38 | ||
|
|
0f324bbf60 | ||
|
|
f232dc32d3 | ||
|
|
2b79026157 | ||
|
|
f3216b2f3b | ||
|
|
84a0ea10e9 | ||
|
|
fb7aabff61 | ||
|
|
833342fced | ||
|
|
dae19176cd | ||
|
|
cedd6845ce | ||
|
|
004209ed4f | ||
|
|
4173d2c058 | ||
|
|
b8ab84a009 | ||
|
|
8d38584d37 | ||
|
|
b305ccac51 | ||
|
|
7ac843fea8 | ||
|
|
5f639eec3c | ||
|
|
2d8d4f4d82 | ||
|
|
5c224521d3 | ||
|
|
a9c277b497 | ||
|
|
1564941ea5 | ||
|
|
c1f4a220f9 | ||
|
|
a0f1efc4ef | ||
|
|
70d69e7441 | ||
|
|
561b6181a0 | ||
|
|
c8399ccaa7 | ||
|
|
fa1452d23d | ||
|
|
e26b38a119 | ||
|
|
4625848048 | ||
|
|
df8c6ce586 | ||
|
|
1f8c9493b7 | ||
|
|
b0d3aec811 | ||
|
|
fc0ae05415 | ||
|
|
bfd37967c3 | ||
|
|
522fdba4cd | ||
|
|
d0070aa0bb | ||
|
|
5157e42bc4 | ||
|
|
f0df84bc5e | ||
|
|
7823bbcb03 | ||
|
|
a7a5b155e3 | ||
|
|
cdf862427e | ||
|
|
318006dba3 | ||
|
|
de1430928a |
No files matched your search
@@ -19,3 +19,8 @@ AUDIOBOOKS_LOGIN_LINK=
|
||||
# or per-person signed links (optional): shared secret the audiobooks site uses to verify signed links
|
||||
AUDIOBOOKS_SSO_SECRET=
|
||||
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
|
||||
|
||||
# Mail (Outlook.com personal accounts, through Microsoft Graph). Register an app at entra.microsoft.com (see README -> Mail),
|
||||
# then put its Application (client) ID here. The secret is only needed if you registered it as a "Web" app.
|
||||
MS_CLIENT_ID=
|
||||
MS_CLIENT_SECRET=
|
||||
@@ -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
|
||||
@@ -17,9 +17,29 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
- run: node tools/frontend-test.js # every page script loads (no syntax errors, no names declared twice)
|
||||
- run: node tools/smoke-test.js
|
||||
- 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/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||
- run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy
|
||||
|
||||
- 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-buildx-action@v3
|
||||
|
||||
|
||||
+2
-1
@@ -2,11 +2,12 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js ./
|
||||
COPY *.js ./
|
||||
COPY public ./public
|
||||
ENV PORT=8080 DATA_DIR=/data
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
USER node
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s CMD wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,7 +1,7 @@
|
||||
# NE Hub
|
||||
|
||||
One installable web app (PWA) for Seafile, OnlyOffice and your four webmail accounts.
|
||||
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide).
|
||||
One installable web app (PWA) for Seafile, OnlyOffice, Plex, Signal, Outlook.com mail and more.
|
||||
Mobile-first; bottom tabs on phones, side rail on iPad and desktop (tuned for 12.9" at 1024 and 1366 wide). On a tablet or computer the menu collapses to icons (button at the bottom of the rail) or hides completely (the menu button at the top, or Ctrl/Cmd+B); the choice is remembered on that device.
|
||||
|
||||
## Configure
|
||||
|
||||
@@ -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,34 +26,91 @@ 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).
|
||||
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audio, Podcast, Podcast Admin, Mail,
|
||||
Calendar, Tasks, Search; 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** 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.
|
||||
You can change a person's tabs, sign them out everywhere, turn them off, or delete them and their data. An existing single-user install becomes the admin
|
||||
automatically on first start (the original files are left in place as a backup).
|
||||
- **Files**: also rename, move, share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
|
||||
- **Files**: a Finder/Explorer-style browser. Tap **Select** (or long-press on a phone, Cmd/Ctrl-click or Shift-click on a computer) to pick several items and
|
||||
Move, Copy (to any folder in any library, with a New folder button in the picker) or Delete them together. Rename checks for clashes, sort by name/date/size/kind
|
||||
(folders stay on top), filter a folder, right-click for the item menu, and keys: Ctrl/Cmd-A, Delete, F2, Enter, Esc. **Drag files or whole folders from your computer
|
||||
onto the page** (or onto a folder in the list) to upload; uploads show progress, retry on a bad connection, and can be cancelled. **Drag items onto a folder or a
|
||||
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 queue (play next / add to queue) and position are remembered.
|
||||
- **Audio**: 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
|
||||
the person may use the Audio tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
|
||||
- **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. **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.
|
||||
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
|
||||
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
||||
- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose).
|
||||
Providers forbid framing, and a real unified inbox would need Google/Azure app registrations.
|
||||
- **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
|
||||
(if you switch it on in Settings -> Signal alerts) a desktop notification when the app is in the background; sound and notifications are saved per device. It talks to your account through the community `signal-cli-rest-api` bridge, which is one more *linked device* of your account
|
||||
(like Signal Desktop; not an official Signal product). The `signal` container in `docker-compose.yml` runs it; remove that service and the hub's `SIGNAL_API_URL` line to skip Signal.
|
||||
First time: open the Signal tab, then on your phone Signal -> Settings -> Linked devices -> Link new device and scan the code. You can send and receive text, photos,
|
||||
files, voice messages (played, not recorded), replies, reactions, groups and read receipts; there are no calls. In a chat, the ... button edits the contact (the name you give is shown in the app and sent to Signal),
|
||||
clears the messages, or removes the chat from the app (it stays on your phone, and a new message starts it again); right-click a chat in the list for the same menu. A diagnostic list of what arrived lately (kinds only, no message text) is at `/api/signal/debug`. Only messages from after linking appear (Signal does
|
||||
not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume (a Docker *volume*, not a host folder: the bridge runs as user 1000 and a root-owned host folder gives "permission denied"; if you do want a folder, `chown -R 1000:1000` it first): whoever gets into your server can read your chats,
|
||||
so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is
|
||||
not connected for about a month; the hub stays connected, so this only matters if the stack is switched off.
|
||||
- **Links**: your own bookmarks page. Add a name, address and optional group; filter as you type; opens in a new tab. Saved with your settings on the server, private to you, same on every device. Only http(s) addresses are accepted.
|
||||
- **Security and backups**: every response carries `nosniff`, a `frame-ancestors 'self'` policy and a same-origin referrer policy; writes that come from another website are refused; JSON files are written atomically (temp file, then rename). Admins get **Settings -> Storage -> Download a backup** (a .tar.gz of `/data`; keep it private). `/healthz` answers without signing in and the container has a health check. In-app OnlyOffice editing now requires `ONLYOFFICE_JWT_SECRET` (or `ALLOW_UNSIGNED_OO=1` for testing). `npm test` runs every test script; CI also runs `npm audit`.
|
||||
- **Planner and grouped menu**: Calendar and Tasks are one menu entry, **Planner**, with a Calendar | Tasks switch at the top (each still needs its own permission in Settings -> People, and `#/calendar` and `#/tasks` still work as addresses). Tasks with a due date appear on the calendar (month chips, week/day all-day strip, agenda) with a tick to complete them; tapping one opens the task. The menu, the phone "More" sheet and the Home tiles are grouped by kind: Work (Planner, Files, Office, Links), Communicate (Mail, Signal), Media (Music, Audiobooks, Podcast, Podcast Admin), with Home/Search at the top and Settings at the bottom.
|
||||
- **Files, sturdier**: the Seafile pass-through gives Seafile 30 seconds to answer (2 minutes to start a file transfer), retries a failed read twice, says plainly when Seafile is slow or down, and stops reading from Seafile as soon as you close a download. In the app, an expired Seafile sign-in asks for your password again in a sheet and then carries on where you were. Each folder you open is saved (names, sizes, dates) so it shows instantly while the real list loads, and stays visible with a note when you are offline or Seafile cannot be reached; very large folders show 300 rows at a time with Show more, and the scroll position comes back when you return. Uploads wait for the connection instead of failing, retry with growing pauses (5 tries), and when a name is already there you choose Replace (the old version stays in the file's history), Keep both, Skip or Cancel. Deleting offers **Undo** (from Seafile's recycle bin), and **Recycle bin** in the folder menu (the three-dots button in the toolbar) restores anything deleted from the library. The same menu has **Search this library** and **Download as zip** (also in Select mode and on a folder's menu). **Share links** now offer a password and an expiry, list the links that already exist and can delete them. Text files (txt, md, json, csv, logs, config files, code) open in a viewer, and small ones can be edited and saved there. Not included: resumable chunked uploads (a dropped connection restarts that file; I could not confirm Seafile's chunk protocol without a server to test), editing files over 256 KB in the viewer. The recycle bin, Undo, zip, share options and text saving use Seafile's documented API but have only been tried against a fake Seafile, not a real one.
|
||||
- **Command palette and quick add**: press **Ctrl/Cmd-K** anywhere (or the Command palette button on Home) to jump to any page, create things, open a saved link or find a task/event. Typing a sentence offers **Add task**, and understands a date at the end ("Call mom tomorrow", "Pay rent in 3 days", "Dentist fri", "2026-12-25"). **Quick add** on Home does the same plus new event and new link. Search also finds your links.
|
||||
- **Tasks**: repeating tasks (daily, weekdays, weekly, monthly, yearly; finishing one moves it to its next date) and one level of subtasks, in NE Hub lists (iCloud reminder lists stay as Apple stores them).
|
||||
- **Calendar**: Month, Week, Day and Agenda. Week and Day show a time grid with overlapping events side by side, an all-day strip and a "now" line; tap an empty slot to add an event.
|
||||
- **Music playlists and sleep timer**: playlists can be created, added to ("Add to playlist" on any track, or "Save as playlist" from the queue), renamed, have tracks removed and be deleted. The hub only lets playlist writes through to Plex; everything else on the Plex server stays read-only. The Sleep button (Now Playing card, or the queue) fades the music out after 15-90 minutes or when the song ends.
|
||||
- **Offline and accessibility**: the app page and its scripts are cached so it opens without a connection, with a notice while offline. Icon buttons have names, there is a skip link, sheets close with Escape and keep focus inside, and the command palette is a proper combobox.
|
||||
- **Reminders (Web Push)**: Settings -> Reminders turns on notifications on that device for tasks due today (one morning summary, at the time you choose) and events about to start. Works with the app closed; on iPhone/iPad only from the installed home-screen app. Keys are made once in `/data/vapid.json`; set `VAPID_SUBJECT` to a `mailto:` address if you want one in the push messages. Needs HTTPS.
|
||||
- **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** 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)
|
||||
|
||||
@@ -63,13 +122,95 @@ iOS keeps a separate sign-in for the home-screen app, so sign in once inside it
|
||||
## Build and deploy with Gitea
|
||||
|
||||
`.gitea/workflows/build.yml` builds the container on every push to `main` (and `v*` tags) and pushes it to
|
||||
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag). amd64 (x86) only.
|
||||
`git.necloud.us/nate/hub` (tags: `latest`, short commit sha, git tag) and the lyrics container from `lyrica/` to `git.necloud.us/nate/hub-lyrica`. The hub image is amd64 and arm64; the lyrics image is amd64 only.
|
||||
|
||||
One-time setup in Gitea: create an access token with the **write:package** scope, then add it to the repo under
|
||||
Settings -> Actions -> Secrets as `REGISTRY_TOKEN` (optionally `REGISTRY_USER`). The runner needs Docker.
|
||||
|
||||
On the server:
|
||||
|
||||
cp .env.example .env # fill in secrets, PUBLIC_URL, etc.
|
||||
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`), `hub.notes.list() / add({ title, body })` (new notes are private), `hub.recipes.search(text) / get(slug)` and `hub.music.nowPlaying()` (what is playing, or `null`). A refused request rejects with an error. Each permission can be switched off again from the extension's **Permissions** button, and when an admin changes an extension (its version or file) the people who have it see "Updated" the next time they open it. 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.
|
||||
|
||||
@@ -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', 'audiobook'), 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
@@ -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 };
|
||||
@@ -2,10 +2,11 @@
|
||||
// sign them out everywhere, turn them off, or delete them and their data. Admin only (enforced by the caller).
|
||||
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 {
|
||||
@@ -15,18 +16,22 @@ async function handle(req, res, me) {
|
||||
if (!id && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req) || '{}');
|
||||
const u = users.create({ name: b.name, role: 'user', tabs: b.tabs });
|
||||
audit.log('person added', { who: me.name, detail: u.name }, req);
|
||||
return json(res, 200, { user: row(u), code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES });
|
||||
}
|
||||
const u = users.get(id); if (!u) return json(res, 404, { error: 'Person not found' });
|
||||
if (action === 'invite' && req.method === 'POST') audit.log('invite created', { who: me.name, detail: u.name }, req);
|
||||
if (action === 'invite' && req.method === 'POST') return json(res, 200, { code: auth.newInvite(u.id), minutes: auth.INVITE_MINUTES }); // for a new device or to recover access
|
||||
if (!action && req.method === 'PUT') {
|
||||
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' });
|
||||
users.update(id, { name: b.name, tabs: b.tabs, role: b.role, disabled: b.disabled, signOutEverywhere: b.signOutEverywhere });
|
||||
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, kid: b.kid, board: b.board, simple: b.simple });
|
||||
return json(res, 200, { user: row(users.get(id)) });
|
||||
}
|
||||
if (!action && req.method === 'DELETE') {
|
||||
if (u.id === me.id) return json(res, 400, { error: 'You cannot delete your own account' });
|
||||
audit.log('person removed', { who: me.name, detail: u.name }, req);
|
||||
users.remove(id); auth.removeCredsOf(id);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Write a file so a crash or power cut never leaves half of it: write to a temp file, then rename over the original.
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
module.exports = function writeFileAtomic(file, data, opts = { mode: 0o600 }) {
|
||||
const tmp = file + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
|
||||
try { fs.writeFileSync(tmp, data, opts); fs.renameSync(tmp, file); }
|
||||
catch (e) { try { fs.unlinkSync(tmp); } catch {} throw e; }
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// Audit log: who signed in, who was added or changed, what was downloaded. One JSON line per event in /data/audit.log
|
||||
// (rotated at about 1 MB, keeping one older file). Contains no passwords, codes or message text.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const FILE = path.join(DATA_DIR, 'audit.log'), MAX = 1024 * 1024;
|
||||
const ipOf = req => String((req && (req.headers['x-forwarded-for'] || (req.socket && req.socket.remoteAddress))) || '').split(',')[0].trim();
|
||||
|
||||
/** log('sign-in', { who: 'Nate', ip, detail: 'iPhone' }) */
|
||||
function log(event, o = {}, req) {
|
||||
try {
|
||||
const line = JSON.stringify({ t: Date.now(), event, who: o.who || '', detail: o.detail || '', ip: o.ip || ipOf(req) }) + '\n';
|
||||
try { if (fs.statSync(FILE).size > MAX) fs.renameSync(FILE, FILE + '.1'); } catch {}
|
||||
fs.appendFileSync(FILE, line, { mode: 0o600 });
|
||||
} catch (e) { console.error('[audit] could not write:', e.message); }
|
||||
}
|
||||
/** Newest first. */
|
||||
function recent(n = 200) {
|
||||
const rows = [];
|
||||
for (const f of [FILE, FILE + '.1']) {
|
||||
let text = ''; try { text = fs.readFileSync(f, 'utf8'); } catch { continue; }
|
||||
for (const l of text.split('\n').reverse()) { if (!l) continue; try { rows.push(JSON.parse(l)); } catch {} if (rows.length >= n) return rows; }
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
module.exports = { log, recent };
|
||||
@@ -3,17 +3,19 @@
|
||||
// Everyone else enrols with a one-time invite code the admin creates; a signed-in person can also invite one of their own devices.
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const atomicWrite = require('./atomic');
|
||||
const path = require('path');
|
||||
const {
|
||||
generateRegistrationOptions, verifyRegistrationResponse,
|
||||
generateAuthenticationOptions, verifyAuthenticationResponse,
|
||||
} = require('@simplewebauthn/server');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const readJson = (f, d) => { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch { return d; } };
|
||||
const writeJson = (f, v) => fs.writeFileSync(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
const writeJson = (f, v) => atomicWrite(path.join(DATA_DIR, f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
|
||||
// Signing secret for cookies: env wins, otherwise generated once and kept so sessions survive restarts.
|
||||
let secret = process.env.COOKIE_SECRET;
|
||||
@@ -70,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));
|
||||
@@ -105,6 +117,22 @@ function newInvite(uid) {
|
||||
const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; };
|
||||
const inviteFor = c => { const k = normInvite(c), v = k && invites.get(k); return v && v.exp > Date.now() && users.get(v.uid) && !users.get(v.uid).disabled ? { code: k, uid: v.uid } : null; };
|
||||
|
||||
// Recovery codes: 8 one-time codes per person, made in Settings. Each can enrol a new passkey (like an invite) if every device is lost.
|
||||
// Only keyed hashes are stored, so the file does not reveal them.
|
||||
const recovery = readJson('recovery.json', {}); // uid -> [hash]
|
||||
const saveRecovery = () => writeJson('recovery.json', recovery);
|
||||
const recHash = code => mac('r' + normInvite(code));
|
||||
function newRecoveryCodes(uid) {
|
||||
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789', codes = [];
|
||||
for (let i = 0; i < 8; i++) { let c = ''; for (const b of crypto.randomBytes(8)) c += a[b % a.length]; codes.push(c.slice(0, 4) + '-' + c.slice(4)); }
|
||||
recovery[uid] = codes.map(recHash); saveRecovery();
|
||||
return codes;
|
||||
}
|
||||
const recoveryFor = c => { const k = normInvite(c); if (!k) return null; const h = recHash(k); const uid = Object.keys(recovery).find(u => recovery[u].includes(h)); const u = uid && users.get(uid); return u && !u.disabled ? { code: k, uid, recovery: true } : null; };
|
||||
const useRecovery = c => { const h = recHash(c), uid = Object.keys(recovery).find(u => recovery[u].includes(h)); if (uid) { recovery[uid] = recovery[uid].filter(x => x !== h); saveRecovery(); } };
|
||||
/** An invite or a recovery code, whichever this is. */
|
||||
const claimFor = c => inviteFor(c) || recoveryFor(c);
|
||||
|
||||
const RATE_LIMIT = Math.max(1, parseInt(process.env.AUTH_RATE_LIMIT, 10) || 10); // attempts per minute per address; raised only by the tests
|
||||
const hits = new Map(); // crude per-IP throttle on anything that checks a secret
|
||||
const throttled = req => {
|
||||
@@ -126,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);
|
||||
|
||||
@@ -136,6 +164,7 @@ async function handle(req, res) {
|
||||
const me = currentUser(req);
|
||||
if (p === '/api/auth/state') return send(res, 200, { registered: store.creds.length > 0, authed: !!me });
|
||||
if (p === '/api/auth/logout' && req.method === 'POST') {
|
||||
if (me) audit.log('sign-out', { who: me.name }, req);
|
||||
addCookie(res, 'hub_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
@@ -147,7 +176,7 @@ async function handle(req, res) {
|
||||
if (me) { kind = 'reg-authed'; uid = me.id; name = me.name; handle = me.handle; }
|
||||
else {
|
||||
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
const inv = body.inviteCode && inviteFor(body.inviteCode);
|
||||
const inv = body.inviteCode && claimFor(body.inviteCode);
|
||||
if (!users.all().length && equalStr(body.setupCode || '', SETUP_CODE)) { kind = 'reg-setup'; name = 'Admin'; handle = crypto.randomBytes(32).toString('base64url'); }
|
||||
else if (inv) { kind = 'reg-invite'; uid = inv.uid; const u = users.get(uid); name = u.name; handle = u.handle; }
|
||||
else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' });
|
||||
@@ -158,7 +187,7 @@ async function handle(req, res) {
|
||||
excludeCredentials: (uid ? credsOf(uid) : []).map(c => ({ id: c.id, transports: c.transports })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
||||
});
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && inviteFor(body.inviteCode) ? inviteFor(body.inviteCode).code : undefined }), options });
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, kind, { uid, handle, code: body.inviteCode && claimFor(body.inviteCode) ? claimFor(body.inviteCode).code : undefined }), options });
|
||||
}
|
||||
if (p === '/api/auth/register/verify' && req.method === 'POST') {
|
||||
if (!me && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
@@ -167,7 +196,7 @@ async function handle(req, res) {
|
||||
const ex = rec.extra || {};
|
||||
const okKind = rec.kind === 'reg-authed' ? !!me && me.id === ex.uid
|
||||
: rec.kind === 'reg-setup' ? !users.all().length
|
||||
: rec.kind === 'reg-invite' ? !!inviteFor(ex.code) && !!users.get(ex.uid) : false;
|
||||
: rec.kind === 'reg-invite' ? !!claimFor(ex.code) && !!users.get(ex.uid) : false;
|
||||
if (!okKind) return send(res, 403, { error: 'Not allowed' });
|
||||
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID });
|
||||
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
|
||||
@@ -177,41 +206,51 @@ async function handle(req, res) {
|
||||
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
|
||||
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now(), uid: user.id });
|
||||
save();
|
||||
if (rec.kind === 'reg-invite') invites.delete(ex.code); // single use
|
||||
if (rec.kind === 'reg-invite') { const wasRecovery = !inviteFor(ex.code); invites.delete(ex.code); useRecovery(ex.code); audit.log(wasRecovery ? 'recovery code used' : 'passkey added with invite', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req); } // single use
|
||||
else if (rec.kind === 'reg-setup') audit.log('first admin created', { who: user.name }, req);
|
||||
else audit.log('passkey added', { who: user.name, detail: String(body.name || '').slice(0, 40) }, req);
|
||||
if (!me) startSession(req, res, user);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
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.' });
|
||||
const rec = takeRecord(body.cid);
|
||||
const cred = store.creds.find(c => c.id === body.response?.id);
|
||||
const user = cred && users.get(cred.uid);
|
||||
if (!rec || rec.kind !== 'login' || !cred || !user) return send(res, 400, { error: 'Passkey not recognised' });
|
||||
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 },
|
||||
});
|
||||
if (!v.verified) return send(res, 401, { error: 'Sign-in failed' });
|
||||
if (!v.verified) { audit.log('sign-in failed', { who: user.name, detail: 'verification failed' }, req); return send(res, 401, { error: 'Sign-in failed' }); }
|
||||
cred.counter = v.authenticationInfo.newCounter; cred.lastUsed = Date.now(); save();
|
||||
startSession(req, res, user);
|
||||
audit.log('sign-in', { who: user.name, detail: cred.name }, req);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
// Everything below needs a signed-in session.
|
||||
if (!me) return send(res, 401, { error: 'Not signed in' });
|
||||
if (p === '/api/auth/invite' && req.method === 'POST') { // an invite for one of my own devices
|
||||
audit.log('invite created for a device', { who: me.name }, req);
|
||||
return send(res, 200, { code: newInvite(me.id), minutes: INVITE_MINUTES });
|
||||
}
|
||||
if (p === '/api/auth/recovery' && req.method === 'GET') return send(res, 200, { remaining: (recovery[me.id] || []).length });
|
||||
if (p === '/api/auth/recovery' && req.method === 'POST') { audit.log('recovery codes created', { who: me.name }, req); return send(res, 200, { codes: newRecoveryCodes(me.id) }); }
|
||||
if (p === '/api/auth/passkeys' && req.method === 'GET') {
|
||||
return send(res, 200, credsOf(me.id).map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
|
||||
}
|
||||
if (p === '/api/auth/passkeys' && req.method === 'DELETE') {
|
||||
const mine = credsOf(me.id); if (!mine.some(c => c.id === body.id)) return send(res, 404, { error: 'Passkey not found' });
|
||||
audit.log('passkey removed', { who: me.name, detail: (mine.find(c => c.id === body.id) || {}).name }, req);
|
||||
store.creds = store.creds.filter(c => c.id !== body.id); save();
|
||||
return send(res, 200, { ok: true, remaining: credsOf(me.id).length });
|
||||
}
|
||||
@@ -220,6 +259,6 @@ async function handle(req, res) {
|
||||
}
|
||||
|
||||
const passkeyCount = uid => credsOf(uid).length;
|
||||
function removeCredsOf(uid) { store.creds = store.creds.filter(c => c.uid !== uid); save(); }
|
||||
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 };
|
||||
@@ -0,0 +1,89 @@
|
||||
// Automations: "when this happens, do that" rules, one set per person. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to the Shopping
|
||||
// list, or notify me. Rules only ever act on the person's own things, a rule runs at most 20 times a day, and what a rule does never starts another rule.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const WHEN = ['time', 'task.done', 'mail.new', 'pantry.low', 'podcast.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20;
|
||||
const file = () => path.join(users.dir(), 'automations.json');
|
||||
const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { 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-\u001f]/g, ' ').trim().slice(0, n);
|
||||
const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||
|
||||
/** A rule from what the browser sent, or an error that says what is missing. */
|
||||
function clean(b, old) {
|
||||
const wt = String(b.when && b.when.type), tt = String(b.then && b.then.type);
|
||||
if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do');
|
||||
const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80);
|
||||
if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)');
|
||||
if (wt === 'podcast.new' && !when.match) throw fail(400, 'Say which show (part of its name)');
|
||||
const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task');
|
||||
return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : wt === 'pantry.low' ? 'When the pantry runs low' : wt === 'podcast.new' ? 'When a new episode comes out' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 };
|
||||
}
|
||||
const fill = (text, ctx) => text.replace(/\{(title|from|subject|show)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120));
|
||||
/** Do a rule's action for the person it belongs to (run inside their context). */
|
||||
async function act(rule, ctx) {
|
||||
const me = users.current(), text = fill(rule.then.text, ctx);
|
||||
if (rule.then.type === 'task' || rule.then.type === 'shopping') { require('./tasks').addLocalTask(rule.then.type === 'shopping' ? 'Shopping' : '', text); return; }
|
||||
require('./inbox').add(me.id, { kind: 'info', title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id });
|
||||
try { await require('./push').notify(me.id, { title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); } catch { /* the Notifications list has it */ }
|
||||
}
|
||||
async function run(rule, ctx) {
|
||||
const l = load(), r = l.find(x => x.id === rule.id); if (!r) return false;
|
||||
if (r.day !== today()) { r.day = today(); r.today = 0; } if (r.today >= DAILY_CAP) return false;
|
||||
r.today++; r.runs = (r.runs || 0) + 1; r.last = Date.now(); save(l);
|
||||
try { await act(r, ctx); return true; } catch (e) { console.warn('[automations]', r.name, e.message); return false; }
|
||||
}
|
||||
/** Something happened for a person: run their matching rules. type: 'task.done' ({ title }) or 'mail.new' ({ from, subject }). */
|
||||
async function fire(uid, type, ctx) {
|
||||
const u = users.get(uid); if (!u || u.disabled) return;
|
||||
await users.runAs(u, async () => {
|
||||
for (const r of load()) {
|
||||
if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : type === 'podcast.new' ? (ctx.show || '') + ' ' + (ctx.title || '') : ctx.title || '').toLowerCase();
|
||||
if (m && !hay.includes(m)) continue; await run(r, ctx);
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Something happened that concerns everyone (the pantry went low, a new episode): run every person's matching rules. */
|
||||
async function fireAll(type, ctx) { for (const u of users.all()) await fire(u.id, type, ctx).catch(() => {}); }
|
||||
/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */
|
||||
async function tickUser(u, lt) {
|
||||
await users.runAs(u, async () => {
|
||||
for (const r of load()) if (r.on && r.when.type === 'time' && r.when.at <= lt.hm && r.doneDay !== lt.day) {
|
||||
const l = load(), x = l.find(y => y.id === r.id); if (!x) continue; x.doneDay = lt.day; save(l); // (marked first, so a slow action is never run twice)
|
||||
if (lt.hm >= r.when.at && minutes(lt.hm) - minutes(r.when.at) <= 180) await run(x, { title: x.name }); // (not for a time that passed hours ago, such as a rule made in the evening)
|
||||
}
|
||||
});
|
||||
}
|
||||
/** New podcast episodes since the last look (not the first look, which only notes what is already there): run the "new episode" rules. Checked every half hour by push.js. */
|
||||
const SEEN = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'podcast-seen.json');
|
||||
async function checkEpisodes(getRecent) {
|
||||
let seen = null; try { seen = JSON.parse(fs.readFileSync(SEEN(), 'utf8')); } catch { /* the first look */ }
|
||||
const eps = await getRecent(5); if (!Array.isArray(eps)) return 0;
|
||||
const ids = new Set(Array.isArray(seen) ? seen : []), fresh = Array.isArray(seen) && seen.length ? eps.filter(e => e.id && !ids.has(e.id)) : [];
|
||||
atomicWrite(SEEN(), JSON.stringify([...new Set([...eps.map(e => e.id), ...ids])].slice(-600)), { mode: 0o600 });
|
||||
for (const e of fresh.slice(0, 10)) await fireAll('podcast.new', { title: e.title, show: e.show });
|
||||
return fresh.length;
|
||||
}
|
||||
const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3);
|
||||
|
||||
async function handle(req, res) {
|
||||
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/auto'.length) || '/';
|
||||
try {
|
||||
if (!me) throw fail(401, 'Sign in');
|
||||
if (p === '/' && req.method === 'GET') return json(res, 200, { rules: load().map(({ day, doneDay, ...r }) => r) });
|
||||
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||
const b = JSON.parse(await readBody(req, 2e4) || '{}'), l = load();
|
||||
if (p === '/') { const i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (b.id && i < 0) throw fail(404, 'That rule is gone'); if (i < 0 && l.length >= MAX_RULES) throw fail(400, 'That is the most rules (' + MAX_RULES + ')'); const r = clean(b, i >= 0 ? l[i] : null); if (i >= 0) l[i] = r; else l.push(r); save(l); return json(res, 200, { rules: l }); }
|
||||
const i = l.findIndex(x => x.id === b.id); if (i < 0) throw fail(404, 'That rule is gone');
|
||||
if (p === '/toggle') { l[i].on = !!b.on; save(l); return json(res, 200, { rules: l }); }
|
||||
if (p === '/delete') { l.splice(i, 1); save(l); return json(res, 200, { rules: l }); }
|
||||
if (p === '/test') { const ok = await run(l[i], { title: 'a test', from: 'Test', subject: 'a test' }); return json(res, 200, { ok }); }
|
||||
throw fail(404, 'Not found');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle, fire, fireAll, tickUser, checkEpisodes };
|
||||
@@ -0,0 +1,66 @@
|
||||
// Admin-only backup: streams the whole data folder (passkeys, accounts, tasks, events, settings, Signal history) as a .tar.gz.
|
||||
// Treat the file like a password: it contains the session secret and encrypted sign-in tokens.
|
||||
// Restore (admin): upload that .tar.gz; it is checked, unpacked beside the data, and each top-level item is swapped in with the old one kept in .pre-restore-<date>; then the hub restarts.
|
||||
// Export (anyone): your own data as a .tar.gz, without sign-in tokens.
|
||||
const { spawn, execFile } = require('child_process');
|
||||
const fs = require('fs'), path = require('path'), { pipeline } = require('stream/promises');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data'));
|
||||
const MAX_RESTORE = 4 * 1024 ** 3;
|
||||
const send = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const run = (cmd, args) => new Promise((ok, no) => execFile(cmd, args, { maxBuffer: 64 * 1024 * 1024 }, (e, out, err) => e ? no(new Error((String(err) || e.message).trim().slice(0, 300))) : ok(String(out))));
|
||||
const rm = p => fs.rmSync(p, { recursive: true, force: true });
|
||||
/** Refuse an archive that could write outside the data folder or plant links: every name must stay inside, and only plain files and folders are allowed. */
|
||||
async function checkArchive(file) {
|
||||
const names = (await run('tar', ['-tzf', file])).split('\n').filter(Boolean), long = (await run('tar', ['-tvzf', file])).split('\n').filter(Boolean);
|
||||
if (!names.length) throw new Error('That backup is empty');
|
||||
for (const n of names) if (path.isAbsolute(n) || n.split('/').includes('..')) throw new Error('That is not a backup this hub made (unsafe file names)');
|
||||
for (const l of long) if (!/^[-d]/.test(l)) throw new Error('That is not a backup this hub made (it contains links or special files)');
|
||||
return names;
|
||||
}
|
||||
async function restore(req, res, who) {
|
||||
if (req.method !== 'POST') return send(res, 405, { error: 'Use POST' });
|
||||
const stage = path.join(DATA_DIR, '.restore'), up = stage + '.tar.gz', out = path.join(stage, 'files'); rm(stage); rm(up);
|
||||
try {
|
||||
if ((+req.headers['content-length'] || 0) > MAX_RESTORE) return send(res, 413, { error: 'That file is too large' });
|
||||
let n = 0; const cap = new (require('stream').Transform)({ transform(c, _e, cb) { n += c.length; cb(n > MAX_RESTORE ? Object.assign(new Error('That file is too large'), { status: 413 }) : null, c); } });
|
||||
await pipeline(req, cap, fs.createWriteStream(up, { mode: 0o600 }));
|
||||
await checkArchive(up); fs.mkdirSync(out, { recursive: true, mode: 0o700 }); await run('tar', ['-xzf', up, '--no-same-owner', '--no-same-permissions', '-C', out]);
|
||||
const items = fs.readdirSync(out); if (!items.includes('secret.json') && !items.includes('users')) throw new Error('That does not look like an NE Hub backup (no accounts or secret in it)');
|
||||
const keep = path.join(DATA_DIR, '.pre-restore-' + new Date().toISOString().replace(/[:.]/g, '-')); fs.mkdirSync(keep, { recursive: true, mode: 0o700 });
|
||||
for (const it of items) { const dst = path.join(DATA_DIR, it); if (fs.existsSync(dst)) fs.renameSync(dst, path.join(keep, it)); fs.renameSync(path.join(out, it), dst); }
|
||||
audit.log('backup restored', { who: who || '', items: items.length, kept: path.basename(keep) }, req);
|
||||
send(res, 200, { ok: true, items: items.length, kept: path.basename(keep), restarting: true });
|
||||
if (!process.env.NO_RESTART_AFTER_RESTORE) setTimeout(() => process.exit(0), 800); // the container restarts (restart: unless-stopped) and loads the restored data
|
||||
} catch (e) { if (!res.headersSent) send(res, e.status || 400, { error: e.message }); }
|
||||
finally { rm(stage); rm(up); }
|
||||
}
|
||||
/** Your own data as a .tar.gz: tasks, contacts, settings, Bible notes, your notes and so on. Sign-in tokens for mail, iCloud and Plex are left out. */
|
||||
async function exportMine(req, res) {
|
||||
const me = users.current(); if (!me) return send(res, 401, { error: 'Sign in' });
|
||||
const tmp = fs.mkdtempSync(path.join(DATA_DIR, '.export-')), SECRET = new Set(['mail-accounts.json', 'mail.json', 'icloud.json', 'plex.json', 'push.json']);
|
||||
try {
|
||||
fs.cpSync(users.dirOf(me.id), path.join(tmp, 'me'), { recursive: true, filter: s => !SECRET.has(path.basename(s)) });
|
||||
try { const notes = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'notes.json'), 'utf8')), l = Array.isArray(notes) ? notes : notes.notes || []; fs.writeFileSync(path.join(tmp, 'me', 'my-notes.json'), JSON.stringify(l.filter(n => n.owner === me.id), null, 1)); } catch { /* no notes yet */ }
|
||||
const name = 'nehub-' + String(me.name || 'me').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + '-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||
const tar = spawn('tar', ['-czf', '-', '--exclude=*.tmp', '-C', tmp, 'me'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||
tar.stdout.pipe(res); res.on('close', () => { tar.kill(); rm(tmp); }); tar.on('error', () => res.destroy());
|
||||
audit.log('personal export', { who: me.name }, req);
|
||||
} catch (e) { rm(tmp); if (!res.headersSent) send(res, 500, { error: 'Could not make the export: ' + e.message }); }
|
||||
}
|
||||
function handle(req, res, who) {
|
||||
if (new URL(req.url, 'http://x').pathname === '/api/backup/restore') return restore(req, res, who);
|
||||
if (new URL(req.url, 'http://x').pathname === '/api/export') return exportMine(req, res);
|
||||
if (req.method !== 'GET') { res.writeHead(405); return res.end(); }
|
||||
const name = 'nehub-backup-' + new Date().toISOString().slice(0, 10) + '.tar.gz';
|
||||
const tar = spawn('tar', ['-czf', '-', '--exclude=./lyrics', '--exclude=./.restore*', '--exclude=./.export-*', '--exclude=./.pre-restore-*', '--exclude=*.tmp', '-C', DATA_DIR, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
tar.on('error', e => { if (!res.headersSent) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'Could not make the backup: ' + e.message })); } else res.destroy(); });
|
||||
tar.stderr.on('data', d => console.warn('[backup]', String(d).trim()));
|
||||
res.writeHead(200, { 'content-type': 'application/gzip', 'content-disposition': `attachment; filename="${name}"`, 'cache-control': 'no-store' });
|
||||
tar.stdout.pipe(res);
|
||||
res.on('close', () => tar.kill());
|
||||
audit.log('backup downloaded', { who: who || '' }, req);
|
||||
}
|
||||
module.exports = { handle, checkArchive };
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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 : [], plan: o.plan || null }; } catch { return { pos: null, marks: [], plan: null }; } };
|
||||
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; } };
|
||||
|
||||
|
||||
// ---- Devotion: a one-year reading plan with a check-in per day (kept per person), and a family prayer list everyone can add to and mark answered.
|
||||
const crypto = require('crypto');
|
||||
const PRAYERS = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'prayers.json');
|
||||
const loadPrayers = () => { try { const a = JSON.parse(fs.readFileSync(PRAYERS(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const savePrayers = a => { const f = PRAYERS(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(a), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||
const prayerView = (x, me) => ({ id: x.id, text: x.text, by: x.by, byName: (users.get(x.by) || {}).name || 'Someone', at: x.at, answered: !!x.answered, answeredAt: x.answeredAt || 0, mine: x.by === me.id });
|
||||
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
async function devotion(req, res, p) {
|
||||
const me = users.current(); if (!me) return json(res, 401, { error: 'Sign in' });
|
||||
if (p === '/prayers' && req.method === 'GET') return json(res, 200, loadPrayers().map(x => prayerView(x, me)));
|
||||
if (req.method !== 'POST') return json(res, 404, { error: 'Not found' });
|
||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' });
|
||||
if (p === '/plan') { const s = load(); if (b.stop) s.plan = null; else if (DAY.test(b.start || '')) s.plan = { start: b.start, done: [] }; else return json(res, 400, { error: 'Pick a start date' }); save(s); return json(res, 200, { plan: s.plan }); }
|
||||
if (p === '/plan/check') { const s = load(), d = Math.round(+b.day); if (!s.plan || !(d >= 0 && d < 366)) return json(res, 400, { error: 'No plan' }); const set = new Set(s.plan.done); if (b.on) set.add(d); else set.delete(d); s.plan.done = [...set].sort((x, y) => x - y); save(s); return json(res, 200, { plan: s.plan }); }
|
||||
const l = loadPrayers();
|
||||
if (p === '/prayers') { const text = clip(b.text, 500); if (!text) return json(res, 400, { error: 'Write the prayer request' }); if (l.length >= 500) return json(res, 400, { error: 'The prayer list is full' }); l.unshift({ id: crypto.randomBytes(6).toString('hex'), text, by: me.id, at: Date.now() }); savePrayers(l); return json(res, 200, l.map(x => prayerView(x, me))); }
|
||||
const x = l.find(y => y.id === b.id); if (!x) return json(res, 404, { error: 'That request is gone' });
|
||||
if (p === '/prayers/answer') { x.answered = !!b.on; x.answeredAt = b.on ? Date.now() : 0; savePrayers(l); return json(res, 200, l.map(y => prayerView(y, me))); }
|
||||
if (p === '/prayers/delete') { if (x.by !== me.id && me.role !== 'admin') return json(res, 403, { error: 'Only the person who wrote it can remove it' }); savePrayers(l.filter(y => y !== x)); return json(res, 200, l.filter(y => y !== x).map(y => prayerView(y, me))); }
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
||||
if (p === '/plan' || p.startsWith('/plan/') || p.startsWith('/prayers')) return devotion(req, res, p);
|
||||
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del), plan: s.plan }); }
|
||||
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 };
|
||||
@@ -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, goal: p.goal })) }))(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 };
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,151 @@
|
||||
// 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); if (st[id] && st[id].pct >= 0.98) { try { require('./progress').markFinished('ebook:' + id, 'ebook'); } catch { /* the goal count is a nicety */ } }
|
||||
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 };
|
||||
@@ -1,6 +1,7 @@
|
||||
// Calendar agenda from private ICS feeds (Google "secret address", Outlook/iCloud published calendars).
|
||||
// Feeds are fetched server-side, cached briefly, parsed, and recurring events are expanded for the requested window.
|
||||
const fs = require('fs');
|
||||
const atomicWrite = require('./atomic');
|
||||
const path = require('path');
|
||||
const dns = require('dns').promises;
|
||||
const net = require('net');
|
||||
@@ -12,7 +13,7 @@ const users = require('./users');
|
||||
const here = f => path.join(users.dir(), f); // each person's own files
|
||||
const FILE_NAME = 'calendars.json';
|
||||
const load = () => { try { return JSON.parse(fs.readFileSync(here(FILE_NAME), 'utf8')); } catch { return []; } };
|
||||
const save = v => fs.writeFileSync(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
const save = v => atomicWrite(here(FILE_NAME), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
|
||||
// ---- Fetching, with SSRF protection: https only, and never to loopback/private/link-local addresses.
|
||||
const isPrivate = ip => {
|
||||
@@ -221,60 +222,191 @@ 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) => fs.writeFileSync(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 writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
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;
|
||||
/** The colour of whoever added an event (the family calendar shows each person's own colour). */
|
||||
const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : '';
|
||||
|
||||
/** 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: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || 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, family: sh.owner === FAM });
|
||||
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
|
||||
return { events: all, errors };
|
||||
}
|
||||
|
||||
/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */
|
||||
function invite(ids, uid, fields, ok) {
|
||||
if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0;
|
||||
for (const id of new Set(ids.map(String))) {
|
||||
const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue;
|
||||
const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue;
|
||||
all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++;
|
||||
try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
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/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) })));
|
||||
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');
|
||||
@@ -297,10 +429,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'));
|
||||
@@ -312,13 +445,16 @@ 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);
|
||||
return json(res, 200, { ok: true, uid });
|
||||
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);
|
||||
const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM));
|
||||
return json(res, 200, { ok: true, uid, invited });
|
||||
}
|
||||
if (caldav.isCal(b.cal)) {
|
||||
ics.validate(b.fields);
|
||||
@@ -335,7 +471,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');
|
||||
}
|
||||
@@ -352,4 +493,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: byCol(evs.find(x => x.uid === e.uid)) || 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 };
|
||||
@@ -2,6 +2,7 @@
|
||||
// Create the password at appleid.apple.com -> Sign-In and Security -> App-Specific Passwords.
|
||||
// Credentials are encrypted at rest with a key derived from the server's secret. Only *.icloud.com is ever contacted.
|
||||
const fs = require('fs');
|
||||
const atomicWrite = require('./atomic');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const auth = require('./auth');
|
||||
@@ -18,7 +19,7 @@ const seal = obj => { const iv = crypto.randomBytes(12), c = crypto.createCipher
|
||||
const unseal = b64 => { const b = Buffer.from(b64, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12));
|
||||
d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); };
|
||||
function load() { try { return unseal(JSON.parse(fs.readFileSync(file(), 'utf8')).data); } catch { return null; } }
|
||||
function save(o) { fs.writeFileSync(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
|
||||
function save(o) { atomicWrite(file(), JSON.stringify({ data: seal(o) }), { mode: 0o600 }); }
|
||||
function wipe() { try { fs.unlinkSync(file()); } catch {} }
|
||||
|
||||
// ---- HTTP + XML
|
||||
@@ -86,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 });
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// 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');
|
||||
// ---- share cards: a song, film, book... from elsewhere in the hub, with the picture and a link back to it
|
||||
const SHARE = { song: ['🎵', 'a song'], album: ['💿', 'an album'], artist: ['🎤', 'an artist'], playlist: ['📃', 'a playlist'], movie: ['🎬', 'a movie'], show: ['📺', 'a show'], episode: ['📺', 'an episode'], book: ['📖', 'a book'], audiobook: ['🎧', 'an audiobook'], podcast: ['🎙️', 'a podcast'], podcastep: ['🎙️', 'a podcast episode'], radio: ['📻', 'a radio station'] };
|
||||
const ART_OK = /^\/(plex|api)\/[^\s"'<>\\]*$/, LINK_OK = /^#\/[\w-]+(\/[\w.~%:@+,=&?-]*)*$/;
|
||||
/** What a client sent as a share, made safe: whitelisted kind, short words, a picture from this hub only and a link that is a hash route (never another site). */
|
||||
function cleanShare(s) {
|
||||
if (!s || typeof s !== 'object' || !SHARE[s.kind]) throw fail(400, 'That cannot be shared');
|
||||
const title = str(s.title, 120).trim(), link = String(s.link || ''), art = String(s.art || '');
|
||||
if (!title) throw fail(400, 'Nothing to share');
|
||||
if (link.length > 300 || !LINK_OK.test(link)) throw fail(400, 'That link cannot be shared');
|
||||
if (art && (art.length > 600 || !ART_OK.test(art) || art.includes('..'))) throw fail(400, 'That picture cannot be shared');
|
||||
return { kind: s.kind, title, sub: str(s.sub, 120).trim(), art, link };
|
||||
}
|
||||
const shareLine = s => SHARE[s.kind][0] + ' Shared ' + SHARE[s.kind][1] + ': ' + s.title;
|
||||
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 textOf = m => [m.text, m.share && m.share.title, m.share && m.share.sub].filter(Boolean).join(' · ');
|
||||
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.share ? SHARE[m.share.kind][0] + ' ' + m.share.title : 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, share: m.deleted ? undefined : m.share, ...(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 } : {}) }); }
|
||||
const share = b.share ? cleanShare(b.share) : undefined;
|
||||
if (!text && !att.length && !share) 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.share ? q.share.title : q.att && q.att[0] ? q.att[0].name : ''), 200) }; }
|
||||
return addMsg(me, c, { text, att, quote, share });
|
||||
}
|
||||
/** This person has read a chat up to now (they opened it, or cleared its notification): their unread count, the senders' ticks and the Notifications list all follow. */
|
||||
function markRead(uid, c) {
|
||||
if (typeof c === 'string') c = convs().find(x => x.id === c && x.members.includes(uid)); if (!c) return;
|
||||
const st = stateOf(uid), 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(uid, st);
|
||||
inbox.markTagRead(uid, 'chat:' + c.id); emit([uid], { t: 'read', conv: c.id, unread: totalUnread(uid) }); emit(c.members.filter(x => x !== uid), { t: 'receipt', conv: c.id });
|
||||
}
|
||||
/** 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, share }) {
|
||||
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, share, 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 : share ? shareLine(share) : 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.share = 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); markRead(me.id, c); 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', ref: { announce: a.id } }); 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, share: x.share, 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 && textOf(x).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 && textOf(x).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 = { cleanShare, markRead, announcement: () => read(annFile(), null), handle, totalUnread, purge, parsePreview, askParents, searchFor };
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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 : [], goals: j.goals && typeof j.goals === 'object' && !Array.isArray(j.goals) ? j.goals : {}, reminded: j.reminded && typeof j.reminded === 'object' && !Array.isArray(j.reminded) ? j.reminded : {} }; } catch { return { chores: [], log: [], claims: [], rewards: [], given: [], goals: {}, reminded: {} }; } })());
|
||||
const 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), goal: (g => tiers.some(t => t.id === g && forMe(t, e.id)) ? g : null)((d.goals[e.id] || {})[from]) })).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, kid: isKid(me) }, 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); for (const k of Object.keys(d.goals)) for (const w of Object.keys(d.goals[k])) if (d.goals[k][w] === b.id) delete d.goals[k][w]; save(); return json(res, 200, { ok: true }); }
|
||||
if (!old && d.rewards.length >= 12) throw fail(400, 'That is plenty of rewards'); const r = cleanReward(b, old); if (old) d.rewards[d.rewards.indexOf(old)] = r; else d.rewards.push(r); save(); return json(res, 200, r);
|
||||
}
|
||||
if (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 === '/goal' && req.method === 'POST') { // a child chooses the reward they are aiming for (an admin can choose for a child); no reward clears it
|
||||
const b = JSON.parse(await readBody(req, 2e3) || '{}'), d = db(), uid = admin && b.user ? String(b.user) : me.id;
|
||||
if (!liveKid(uid)) throw fail(400, 'Goals are for child accounts');
|
||||
const near = ymdOk(b.date) && Math.abs(Date.parse(b.date + 'T12:00:00Z') - Date.now()) < 3 * 864e5, wk = monday(near ? b.date : ymd(new Date())); // (this week, by the child's own calendar; a goal is for one week and starts empty each Monday)
|
||||
if (!b.reward) { if (d.goals[uid]) delete d.goals[uid][wk]; save(); return json(res, 200, { ok: true, goal: null }); }
|
||||
const t = d.rewards.find(x => x.id === String(b.reward)); if (!t || !forMe(t, uid)) throw fail(400, 'That reward is not available'); const mine = d.goals[uid] = d.goals[uid] || {}; mine[wk] = t.id; for (const w of Object.keys(mine).sort().slice(0, -12)) delete mine[w]; save(); try { require('./inbox').markTagRead(uid, 'goal:' + wk); } catch {} return json(res, 200, { ok: true, goal: t.id });
|
||||
}
|
||||
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 }); }
|
||||
}
|
||||
|
||||
/** The Monday of this week, when a child with rewards to choose from still has no goal for it and has not been reminded yet (otherwise null). */
|
||||
function goalNeeded(uid, day) { const d = db(), wk = monday(day); if (!liveKid(uid) || d.reminded[uid] === wk || (d.goals[uid] || {})[wk]) return null; return d.rewards.some(t => forMe(t, uid)) ? wk : null; }
|
||||
function goalReminded(uid, wk) { db().reminded[uid] = wk; save(); }
|
||||
|
||||
/** 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, goalNeeded, goalReminded };
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
// 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), anniversary: cleanBirthday(b.anniversary), gifts: str(b.gifts, 2000), 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 === 'ANNIVERSARY' || name === 'X-ANNIVERSARY') cur.anniversary = 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.anniversary && 'X-ANNIVERSARY:' + c.anniversary.replace(/-/g, '').replace(/^/, c.anniversary.startsWith('--') ? '--' : ''),
|
||||
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
|
||||
}
|
||||
|
||||
/** Birthdays and anniversaries as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Each 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)) {
|
||||
for (const [field, label, color] of [['birthday', "'s birthday", '#d9578a'], ['anniversary', "'s anniversary", '#c9a227']]) {
|
||||
const v = c[field]; if (!v) continue;
|
||||
const [mo, d] = v.startsWith('--') ? [v.slice(2, 4), v.slice(5, 7)] : [v.slice(5, 7), v.slice(8, 10)], born = v.startsWith('--') ? 0 : +v.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}${label}${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: field + ':' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color, 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
@@ -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 };
|
||||
@@ -19,7 +19,19 @@ services:
|
||||
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
|
||||
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
|
||||
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
|
||||
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
|
||||
TRANSCRIBE_MODEL: ${TRANSCRIBE_MODEL:-Systran/faster-whisper-base} # smaller and about 3x faster than -small on a CPU-only host; set Systran/faster-whisper-small for better accuracy
|
||||
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
|
||||
@@ -27,6 +39,48 @@ services:
|
||||
COOKIE_SECRET: ${COOKIE_SECRET:-} # optional; auto-generated and kept in /data if empty
|
||||
volumes:
|
||||
- hub-data:/data # passkeys, calendar links and the session secret live here
|
||||
|
||||
# Signal messages (the Signal tab). This links to your Signal account as one more linked device (like Signal Desktop) through the community
|
||||
# signal-cli-rest-api bridge. Not an official Signal product; its link to your account (keys) is kept in the signal-data volume. See README -> Signal.
|
||||
signal:
|
||||
image: bbernhard/signal-cli-rest-api:0.101
|
||||
restart: unless-stopped
|
||||
network_mode: "service:hub" # reached by the hub at 127.0.0.1:8085 and by nothing else
|
||||
depends_on:
|
||||
- hub
|
||||
environment:
|
||||
MODE: json-rpc
|
||||
PORT: "8085"
|
||||
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:
|
||||
image: git.necloud.us/nate/hub-lyrica:latest # built by the Gitea workflow from ./lyrica (Lyrica pinned to one commit)
|
||||
build: ./lyrica
|
||||
restart: unless-stopped
|
||||
network_mode: "service:hub" # reached by the hub at 127.0.0.1:9999 and by nothing else
|
||||
depends_on:
|
||||
- hub
|
||||
environment:
|
||||
PORT: "9999"
|
||||
|
||||
volumes:
|
||||
hub-data:
|
||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||
signal-data:
|
||||
name: nehub-signal
|
||||
whisper-models:
|
||||
name: nehub-whisper
|
||||
@@ -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 2 cups flour 1 1/2 tsp salt 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>
|
||||
@@ -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>
|
||||
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
@@ -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{'&':'&','<':'<','>':'>','"':'"'}[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+'¤t=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)+'°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])+'°</div><div class="m">'+Math.round(d.temperature_2m_min[i])+'°</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>
|
||||
@@ -0,0 +1,72 @@
|
||||
# Setting up Outlook.com mail (Microsoft app registration)
|
||||
|
||||
NE Hub reads and sends your Outlook.com (personal) mail through Microsoft Graph. Microsoft requires every app that does this to be registered once. It is free and takes about five minutes. The steps follow the Entra portal; Microsoft changes the layout now and then, so a label may differ slightly.
|
||||
|
||||
## 1. Open the registration page
|
||||
|
||||
1. Go to **entra.microsoft.com** and sign in with any Microsoft account. It does not have to be the mailbox you will read.
|
||||
2. If it says you need a directory or Azure subscription, accept the free "Default Directory" it offers. The free tier is enough and costs nothing.
|
||||
3. In the left menu open **Entra ID -> App registrations -> New registration**.
|
||||
|
||||
## 2. Fill in the registration
|
||||
|
||||
- **Name:** `NE Hub`.
|
||||
- **Supported account types:** choose **Personal Microsoft accounts only**. This matches the hub, which signs in through Microsoft's "consumers" endpoint.
|
||||
- **Redirect URI:** set the platform to **Web** and enter `https://hub.necloud.us/api/mail/callback`. Use your real hub address. It must match `PUBLIC_URL` in your `.env` exactly: same `https`, same host, no trailing slash.
|
||||
- Press **Register**.
|
||||
|
||||
## 3. Copy the client ID
|
||||
|
||||
On the app's **Overview** page, copy the **Application (client) ID** (a long GUID). It goes in `MS_CLIENT_ID`.
|
||||
|
||||
## 4. Add the mail permissions
|
||||
|
||||
1. Open **API permissions -> Add a permission -> Microsoft Graph -> Delegated permissions**.
|
||||
2. Tick these four and press **Add permissions**:
|
||||
- `Mail.ReadWrite`
|
||||
- `Mail.Send`
|
||||
- `User.Read`
|
||||
- `offline_access`
|
||||
3. You do not need "Grant admin consent". Personal accounts approve these themselves the first time they connect.
|
||||
|
||||
## 5. Create a client secret
|
||||
|
||||
1. Open **Certificates & secrets -> New client secret**.
|
||||
2. Give it any name and choose the longest expiry (24 months).
|
||||
3. Copy the **Value** column right away. Microsoft shows it only once. It goes in `MS_CLIENT_SECRET` (not the "Secret ID").
|
||||
4. Put a reminder in your calendar for about 23 months from now. When the secret expires, Connect fails until you make a new one and update `MS_CLIENT_SECRET`.
|
||||
|
||||
(If you would rather not have a secret, register the redirect under the platform **Mobile and desktop applications** instead of **Web** and leave `MS_CLIENT_SECRET` empty. The hub signs in with PKCE either way.)
|
||||
|
||||
## 6. Give the hub the values
|
||||
|
||||
**In the app (easiest):** sign in as an admin, open **Settings -> Microsoft app (admin)**. It shows the exact redirect address to register (with a Copy button), and has boxes for the Application (client) ID, the client secret Value and the date the secret expires. Press **Save**; Mail switches on straight away, no restart needed. The secret is stored encrypted on the server and is never shown again. NE Hub warns you in Settings, and with a push notification if you turned reminders on, in the month before the secret expires.
|
||||
|
||||
**Or in the environment** (these win over anything saved in the app, and then the Settings box is read-only):
|
||||
|
||||
```
|
||||
MS_CLIENT_ID=<the Application (client) ID>
|
||||
MS_CLIENT_SECRET=<the secret Value>
|
||||
PUBLIC_URL=https://hub.necloud.us
|
||||
```
|
||||
|
||||
then redeploy with `docker compose up -d`. Leave both empty (and nothing saved in the app) to switch Mail off.
|
||||
|
||||
## 7. Connect
|
||||
|
||||
Microsoft cannot check an app registration without a real sign-in, so the first Connect is the test: it checks the ID, the secret and the redirect address, and if something is wrong it says what. After one good connection, Settings -> Microsoft app shows the date of the last successful connection.
|
||||
|
||||
1. Open the hub, go to **Mail**, and press **Connect Outlook.com**.
|
||||
2. Sign in with your Outlook.com account on Microsoft's page and approve the permissions.
|
||||
3. You land back on the Mail tab with your inbox showing.
|
||||
|
||||
Each person who has the Mail tab connects their own mailbox. The hub keeps only the sign-in token, encrypted, in that person's own folder.
|
||||
|
||||
## If something goes wrong
|
||||
|
||||
- **"redirect URI doesn't match" (AADSTS50011):** the address in step 2 differs from what the hub sends. Compare `PUBLIC_URL` and the registered URI character by character.
|
||||
- **"invalid client secret" or `invalid_client`:** you pasted the Secret ID instead of the Value, or the secret has expired. Make a new one.
|
||||
- **"app not found in directory/consumers":** the account type was not set to personal accounts only. Check it under **Authentication -> Supported account types**.
|
||||
- **The Mail tab says "Mail is not set up on this server":** `MS_CLIENT_ID` is empty or the container was not restarted after you added it.
|
||||
- **Connect works, then later says Microsoft signed you out:** the sign-in token was revoked, for example after a password change. Press Connect again.
|
||||
- **Installed iPhone/iPad app:** Microsoft's page may open in Safari. Sign in there, then reopen the app.
|
||||
@@ -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.**
|
||||
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
|
||||
- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.**
|
||||
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
|
||||
|
||||
## 3. Kids and safety
|
||||
- [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.**
|
||||
- [x] **Approvals (S):** **Done: requests.js ("Can I have...").** 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**
|
||||
- [x] **Simple mode (S):** **Done: simple-mode.js.** 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.**
|
||||
- [x] **Continue row (S):** **Done: Home, today.js.** 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.**
|
||||
- [x] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, Today and Upcoming views, and subtasks on iCloud lists too. Lists serve as projects.**
|
||||
- [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.**
|
||||
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
|
||||
|
||||
## 5. Media
|
||||
- [x] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, mixes (random, family night, short, classics) and Your stats (counts, hours, favourite genres from Plex's last-watched dates). The Plex-based ones are tested only against the counting code, not a real Plex.**
|
||||
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
|
||||
- [x] **Reading goals (S):** **Done: a yearly goal for books finished (audiobooks and e-books, an e-book counts at 98% read), a listening streak, a family challenge with one shared goal, and downloading 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.**
|
||||
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
|
||||
|
||||
## 6. Reliability (the admin's side)
|
||||
- [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.**
|
||||
- [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.**
|
||||
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
|
||||
- [x] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played; books can be saved on the device; adding a task, ticking one, adding to the shopping list and writing a note are kept and sent when the connection returns. Other changes still need a connection.**
|
||||
- [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.**
|
||||
- [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed; dialogs now keep focus inside and give it back; Bible verses can be picked with the keyboard. The main screens were scanned for things only a mouse can click and that was the one gap found.**
|
||||
|
||||
## 7. Platform (later)
|
||||
- [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.**
|
||||
- [x] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished, new mail arrives, a pantry item runs low or a new podcast episode comes out, then add a task, add to Shopping or notify.**
|
||||
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
|
||||
|
||||
## 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.
|
||||
@@ -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 };
|
||||
@@ -172,6 +172,7 @@ function todoLines(f, { completed = true } = {}) {
|
||||
const L = ['SUMMARY:' + esc(String(f.title).trim())];
|
||||
if (f.notes) L.push('DESCRIPTION:' + esc(f.notes));
|
||||
if (f.due) L.push('DUE;VALUE=DATE:' + f.due.replace(/-/g, ''));
|
||||
if (f.parent) L.push('RELATED-TO;RELTYPE=PARENT:' + esc(String(f.parent))); // a subtask points at its main task
|
||||
if (f.done) { L.push('STATUS:COMPLETED', 'PERCENT-COMPLETE:100'); if (completed) L.push('COMPLETED:' + nowStamp()); }
|
||||
else L.push('STATUS:NEEDS-ACTION');
|
||||
return L;
|
||||
@@ -199,6 +200,7 @@ function patchTodo(text, f) {
|
||||
const name = l.split(/[;:]/)[0].toUpperCase();
|
||||
if (name === 'COMPLETED') { if (f.done) { hadCompleted = true; out.push(l); } return; } // keep the original completion time
|
||||
if (['SUMMARY', 'DESCRIPTION', 'DUE', 'STATUS', 'PERCENT-COMPLETE'].includes(name)) return;
|
||||
if (name === 'RELATED-TO' && f.parent !== undefined && !/RELTYPE=(CHILD|SIBLING)/i.test(l)) return; // (replaced below when a parent is given; kept when none is mentioned)
|
||||
if (name === 'SEQUENCE') { seq = parseInt(l.split(':')[1], 10) || 0; return; }
|
||||
if (name === 'DTSTAMP') { out.push('DTSTAMP:' + nowStamp()); sawStamp = true; return; }
|
||||
out.push(l);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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. */
|
||||
const mergeRef = (a, b) => !a || !b ? b || a : { ...a, ...b, ...(a.mail && b.mail ? { mail: [...a.mail, ...b.mail].slice(-50) } : {}) };
|
||||
function add(uid, { kind = 'info', title, body = '', url = '', tag = '', ref }) {
|
||||
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; if (ref) old.ref = mergeRef(old.ref, ref); 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), ref: ref || undefined, at: now, read: false, count: 1 });
|
||||
save(uid, items.slice(0, MAX));
|
||||
}
|
||||
const unread = uid => load(uid).filter(i => !i.read).length;
|
||||
/** The thing itself was dealt with somewhere else (a chat opened, say): its notification is done too. */
|
||||
function markTagRead(uid, tag) { const items = load(uid); let ch = false; for (const i of items) if (i.tag === tag && !i.read) { i.read = true; ch = true; } if (ch) save(uid, items); }
|
||||
/** Reading or clearing a notification also settles what it was about: a chat is marked read, new mail is marked read. (Reminders, requests and the like stay as they are: they still need doing or answering.) */
|
||||
async function settle(uid, items) {
|
||||
const jobs = [];
|
||||
for (const i of items) {
|
||||
const m = /^chat:(.+)$/.exec(i.tag || ''); if (m) { try { require('./chat').markRead(uid, m[1]); } catch (e) { console.warn('[inbox]', e.message); } }
|
||||
if (i.ref && Array.isArray(i.ref.mail) && i.ref.mail.length) jobs.push(require('./mail').markRead(i.ref.mail).catch(() => {}));
|
||||
}
|
||||
await Promise.all(jobs);
|
||||
}
|
||||
|
||||
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), hit = items.filter(i => !i.read && (b.all || i.id === b.id)); for (const i of items) if (b.all || i.id === b.id) i.read = true; save(me.id, items); await settle(me.id, hit); return json(res, 200, { unread: unread(me.id) }); }
|
||||
if (p === '/clear' && req.method === 'POST') { const hit = load(me.id).filter(i => !i.read); save(me.id, []); await settle(me.id, hit); 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, markTagRead };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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', 'audiobook'); // 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(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/�?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 };
|
||||
@@ -0,0 +1,14 @@
|
||||
# Lyrica (https://github.com/Wilooper/Lyrica, MIT): a lyrics service that tries several sources and returns plain, line-synced and word-synced lyrics.
|
||||
# Pinned to one commit so what runs is what was tested; change LYRICA_REF to update.
|
||||
FROM python:3.11-slim
|
||||
ARG LYRICA_REF=2c77f81752f3e1ae9a1adbd7b68f633d60b2de23
|
||||
ENV PYTHONUNBUFFERED=1 PORT=9999
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential gcc git libxml2-dev libxslt1-dev libffi-dev libssl-dev curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL "https://github.com/Wilooper/Lyrica/archive/${LYRICA_REF}.tar.gz" | tar xz --strip-components=1 -C /app \
|
||||
&& pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt \
|
||||
&& useradd -m lyrica && chown -R lyrica /app
|
||||
USER lyrica
|
||||
EXPOSE 9999
|
||||
CMD ["gunicorn", "-c", "gunicorn.config.py", "run:app"]
|
||||
@@ -0,0 +1,113 @@
|
||||
// Song lyrics for the music player, from Lyrica (a lyrics service that tries several sources: LRCLIB, Musixmatch through Lrcmux, NetEase, Megalobiz).
|
||||
// The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording,
|
||||
// and keeps what it finds on disk so a song is looked up once.
|
||||
const fs = require('fs');
|
||||
const atomicWrite = require('./atomic');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
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');
|
||||
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 || 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)); };
|
||||
|
||||
// "[01:23.45]text" lines (and enhanced "<01:23.45>word" tags) into timed lines; text without times stays plain.
|
||||
function parseLrc(text) {
|
||||
const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
|
||||
const lines = [];
|
||||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||
const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
|
||||
if (!tags.length) continue;
|
||||
let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
|
||||
if (/<\d+:\d{1,2}/.test(body)) { // word timing inside the line
|
||||
const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
|
||||
words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
|
||||
words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
|
||||
body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
|
||||
}
|
||||
lines.sort((a, b) => a.t - b.t);
|
||||
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;
|
||||
}
|
||||
|
||||
// What Lyrica answered, in the shape the player uses: { lines: [{ t, e, text, words? }], sync: 'word' | 'line' | 'none', ... }
|
||||
function shape(d, want) {
|
||||
if (!d || d.status === 'error' || !d.data) return { found: false, why: 'not found' };
|
||||
const x = d.data, src = String(x.source || '');
|
||||
if (/youtube/i.test(src)) return { found: false, why: 'only captions found' }; // auto-captions are not lyrics
|
||||
if (x.instrumental) return { found: true, instrumental: true, source: src, sync: 'none', lines: [] };
|
||||
if (want && x.duration && Math.abs(+x.duration - want) > 10) return { found: false, why: 'different version' };
|
||||
let lines = [], sync = 'none';
|
||||
if (Array.isArray(x.timed_lyrics) && x.timed_lyrics.length) {
|
||||
lines = x.timed_lyrics.filter(l => String(l.text || '').trim() || l.words).map(l => {
|
||||
const o = { t: Math.round(l.start_time), e: Math.round(l.end_time || 0), text: String(l.text || '').trim() };
|
||||
const words = Array.isArray(l.words) ? l.words.filter(w => String(w.text || '').trim()).map(w => ({ t: Math.round(w.start), e: Math.round(w.end), text: w.text })) : [];
|
||||
if (words.length) o.words = words; return o;
|
||||
});
|
||||
sync = lines.some(l => l.words) ? 'word' : 'line';
|
||||
} else if (/\[\d+:\d{2}/.test(x.lyrics || '')) { lines = parseLrc(x.lyrics); sync = lines.some(l => l.words) ? 'word' : 'line'; }
|
||||
else lines = String(x.lyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() }));
|
||||
if (!lines.some(l => l.text)) return { found: false, why: 'empty' };
|
||||
lines.forEach((l, i) => { if (sync !== 'none' && (!l.e || l.e <= l.t)) l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; });
|
||||
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?
|
||||
const c = JSON.parse(fs.readFileSync(f, 'utf8')), age = Date.now() - c.at;
|
||||
if (c.result && (c.result.found ? age < HIT_DAYS * 864e5 : age < MISS_HOURS * 36e5)) return c.result;
|
||||
} catch { /* not cached */ }
|
||||
if (inflight.has(k)) return inflight.get(k);
|
||||
const p = (async () => {
|
||||
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;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
if (u.pathname !== '/api/lyrics') return json(res, 404, { error: 'Not found' });
|
||||
const artist = String(u.searchParams.get('artist') || '').trim().slice(0, 200), title = String(u.searchParams.get('title') || '').trim().slice(0, 200), seconds = +u.searchParams.get('duration') || 0;
|
||||
if (!title) return json(res, 400, { error: 'Which song?' });
|
||||
try { return json(res, 200, await lookup(artist, title, seconds)); }
|
||||
catch (e) { return json(res, e.status || 502, { error: e.status ? e.message : 'The lyrics service is not answering' }); }
|
||||
}
|
||||
|
||||
module.exports = { init, handle, enabled, parseLrc, shape };
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// Gmail and iCloud mail through IMAP (reading, folders, flags, moving) and SMTP (sending), with an app password.
|
||||
// Same interface as the Outlook provider in mail.js: every function takes the account and the provider's own ids.
|
||||
// Message ids are base64url JSON [folder path, uid]; folders are the same aliases as Outlook (inbox, drafts, sentitems, archive, junkemail,
|
||||
// deleteditems) or "p-<base64url path>" for the rest.
|
||||
const { ImapFlow } = require('imapflow');
|
||||
const nodemailer = require('nodemailer');
|
||||
const MailComposer = require('nodemailer/lib/mail-composer');
|
||||
const { simpleParser } = require('mailparser');
|
||||
|
||||
const PRESETS = {
|
||||
gmail: { name: 'Gmail', host: 'imap.gmail.com', port: 993, secure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true, appendSent: false },
|
||||
icloud: { name: 'iCloud Mail', host: 'imap.mail.me.com', port: 993, secure: true, smtpHost: 'smtp.mail.me.com', smtpPort: 587, smtpSecure: false, appendSent: true },
|
||||
};
|
||||
// Only the tests set these (a fake server on localhost, no TLS).
|
||||
const TEST = process.env.MAIL_TEST_SERVERS ? JSON.parse(process.env.MAIL_TEST_SERVERS) : null;
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
||||
const MAX_SOURCE = 25 * 1024 * 1024;
|
||||
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
|
||||
const enc = o => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const dec = s => { try { const v = JSON.parse(Buffer.from(String(s), 'base64url').toString()); if (Array.isArray(v) && typeof v[0] === 'string' && Number.isInteger(v[1])) return v; } catch {} throw fail(400, 'Bad id'); };
|
||||
|
||||
const cfgOf = a => {
|
||||
const p = PRESETS[a.type]; if (!p) throw fail(400, 'Unknown mail provider');
|
||||
return TEST ? { ...p, ...TEST.imap, smtpHost: TEST.smtp.host, smtpPort: TEST.smtp.port, smtpSecure: false, smtpIgnoreTLS: true, test: true } : p;
|
||||
};
|
||||
/** What went wrong, in words a person can act on. */
|
||||
function friendly(a, e) {
|
||||
const t = (e && (e.responseText || e.message) || '') + ' ' + (e && e.code || '');
|
||||
const p = PRESETS[a.type] || {};
|
||||
if (e && (e.authenticationFailed || /AUTHENTICATIONFAILED|Invalid credentials|auth.*fail|535|534|Username and Password not accepted/i.test(t)))
|
||||
return a.type === 'gmail' ? 'Google rejected the password. Use an app password (Google account -> Security -> 2-Step Verification -> App passwords), not your normal password.'
|
||||
: 'Apple rejected the password. Use an app-specific password (appleid.apple.com -> Sign-In and Security -> App-Specific Passwords), not your Apple ID password.';
|
||||
if (/ENOTFOUND|EAI_AGAIN/.test(t)) return `Could not find ${p.name || 'the mail server'} (no internet from the server, or DNS is down).`;
|
||||
if (/ECONNREFUSED|ETIMEDOUT|ECONNRESET|timed out|Timeout/i.test(t)) return `Could not reach ${p.name || 'the mail server'}. Try again in a moment.`;
|
||||
if (/IMAP.*disabled|not enabled/i.test(t)) return 'IMAP is turned off for this account. Turn it on in the account\'s mail settings.';
|
||||
return (e && e.responseText) || (e && e.message) || 'The mail server refused the request';
|
||||
}
|
||||
|
||||
// ---- 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 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, 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(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(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, slot) {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
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 || ''))) { drop(slot ? a.id + ':' + slot : a.id); continue; }
|
||||
throw fail(502, friendly(a, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- folders
|
||||
const boxCache = new Map(); // account id -> { t, v }
|
||||
const SPECIAL = { inbox: ['\\Inbox'], sentitems: ['\\Sent'], drafts: ['\\Drafts'], deleteditems: ['\\Trash'], junkemail: ['\\Junk'], archive: ['\\Archive', '\\All'] };
|
||||
const GUESS = { sentitems: /^(\[.*\]\/)?sent( (mail|items|messages))?$/i, drafts: /^(\[.*\]\/)?drafts?$/i, deleteditems: /^(\[.*\]\/)?(trash|deleted( (items|messages))?|bin)$/i, junkemail: /^(\[.*\]\/)?(junk( e-?mail)?|spam)$/i, archive: /^(\[.*\]\/)?(archive|all mail)$/i };
|
||||
const NAMES = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
|
||||
async function boxes(a, client) {
|
||||
const c = boxCache.get(a.id); if (c && Date.now() - c.t < 60e3) return c.v;
|
||||
const list = (await client.list()).filter(b => !b.flags.has('\\Noselect') && !b.flags.has('\\NonExistent'));
|
||||
const alias = {}; // alias -> path
|
||||
const take = (k, b) => { if (b && !alias[k]) alias[k] = b.path; };
|
||||
take('inbox', list.find(b => b.path.toUpperCase() === 'INBOX'));
|
||||
for (const k of Object.keys(SPECIAL)) for (const f of SPECIAL[k]) take(k, list.find(b => b.specialUse === f));
|
||||
for (const k of Object.keys(GUESS)) take(k, list.find(b => GUESS[k].test(b.path) || GUESS[k].test(b.name)));
|
||||
const v = { list, alias };
|
||||
boxCache.set(a.id, { t: Date.now(), v }); return v;
|
||||
}
|
||||
async function pathOf(a, client, folder) {
|
||||
const { list, alias } = await boxes(a, client);
|
||||
if (alias[folder]) return alias[folder];
|
||||
if (/^p-/.test(folder)) { try { const p = Buffer.from(folder.slice(2), 'base64url').toString(); if (list.some(b => b.path === p)) return p; } catch {} }
|
||||
throw fail(404, folder === 'archive' ? 'This account has no Archive folder' : 'That folder does not exist');
|
||||
}
|
||||
|
||||
const addrOf = x => x ? { name: x.name || '', address: x.address || '' } : { name: '', address: '' };
|
||||
const hasAttachment = n => !!n && (((n.disposition || '').toLowerCase() === 'attachment') || (n.childNodes || []).some(hasAttachment));
|
||||
|
||||
const folderCache = new Map(); // account id -> { t, v }
|
||||
async function folders(a) {
|
||||
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 30e3) return c.v;
|
||||
const v = await withClient(a, async client => {
|
||||
const { list, alias } = await boxes(a, client), byPath = {}; for (const [k, p] of Object.entries(alias)) byPath[p] = k;
|
||||
const skip = b => b.flags.has('\\Noselect') || ['\\Flagged', '\\Important'].includes(b.specialUse);
|
||||
const keep = list.filter(b => !skip(b)), wellFirst = [...keep.filter(b => byPath[b.path]), ...keep.filter(b => !byPath[b.path])];
|
||||
const counts = new Map(); // one STATUS per folder is a round trip each: the usual six first, then at most ten more (the rest show no number)
|
||||
for (const b of wellFirst.slice(0, Object.keys(byPath).length + 10)) { try { counts.set(b.path, await client.status(b.path, { messages: true, unseen: true })); } catch {} }
|
||||
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;
|
||||
}
|
||||
|
||||
async function list(a, { folder, skip, q }) {
|
||||
return withClient(a, async client => {
|
||||
const path = await pathOf(a, client, folder || 'inbox');
|
||||
const lock = await client.getMailboxLock(path);
|
||||
try {
|
||||
const total = client.mailbox.exists, query = { uid: true, flags: true, envelope: true, bodyStructure: true, internalDate: true };
|
||||
let range, opts = {}, more = false;
|
||||
if (q) {
|
||||
const uids = (await client.search({ or: [{ subject: q }, { from: q }, { to: q }] }, { uid: true })) || [];
|
||||
if (!uids.length) return { items: [], more: false, next: 0 };
|
||||
range = uids.slice(-50).join(','); opts = { uid: true };
|
||||
} else {
|
||||
const hi = total - skip; if (hi < 1) return { items: [], more: false, next: skip };
|
||||
range = `${Math.max(1, hi - 29)}:${hi}`; more = hi - 30 >= 1;
|
||||
}
|
||||
const items = [];
|
||||
for await (const m of client.fetch(range, query, opts)) {
|
||||
const env = m.envelope || {};
|
||||
items.push({ id: enc([path, m.uid]), subject: env.subject || '(No subject)', from: addrOf((env.from || [])[0]), date: (env.date || m.internalDate || new Date()).toISOString(), read: m.flags.has('\\Seen'), attach: hasAttachment(m.bodyStructure), preview: '', flagged: m.flags.has('\\Flagged') });
|
||||
}
|
||||
items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
|
||||
return { items, more, next: skip + items.length };
|
||||
} finally { lock.release(); }
|
||||
});
|
||||
}
|
||||
|
||||
/** Download and parse one message (refusing anything huge). */
|
||||
async function source(a, mid) {
|
||||
const [path, uid] = dec(mid);
|
||||
return withClient(a, async client => {
|
||||
const lock = await client.getMailboxLock(path);
|
||||
try {
|
||||
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(); }
|
||||
});
|
||||
}
|
||||
const pick = (list, f) => (list || []).map(x => f(x));
|
||||
const addrs = v => (v && v.value ? v.value : []).map(x => ({ name: x.name || '', address: x.address || '' }));
|
||||
|
||||
async function message(a, mid) {
|
||||
const { parsed: p, flags } = await source(a, mid);
|
||||
let html = p.html || (p.textAsHtml ? p.textAsHtml : textToHtml(p.text || ''));
|
||||
const atts = p.attachments || [];
|
||||
for (const x of atts) if (x.cid && /^image\//i.test(x.contentType || '') && x.size < 1.5 * 1024 * 1024) html = html.split('cid:' + x.cid).join(`data:${x.contentType};base64,${x.content.toString('base64')}`);
|
||||
return { id: mid, subject: p.subject || '(No subject)', from: addrs(p.from)[0] || { name: '', address: '' }, to: addrs(p.to), cc: addrs(p.cc), date: (p.date || new Date()).toISOString(), read: flags.has('\\Seen'), flagged: flags.has('\\Flagged'),
|
||||
html, attachments: atts.map((x, i) => ({ x, i })).filter(({ x }) => !x.related).map(({ x, i }) => ({ id: String(i), name: x.filename || 'attachment', type: x.contentType, size: x.size })) };
|
||||
}
|
||||
async function attachment(a, mid, aid) {
|
||||
const { parsed: p } = await source(a, mid), x = (p.attachments || [])[parseInt(aid, 10)];
|
||||
if (!x) throw fail(404, 'Attachment not found');
|
||||
return { name: x.filename || 'attachment', type: x.contentType, buffer: x.content };
|
||||
}
|
||||
|
||||
async function update(a, mid, { read, flagged }) {
|
||||
const [path, uid] = dec(mid);
|
||||
if (typeof read !== 'boolean' && typeof flagged !== 'boolean') throw fail(400, 'Nothing to change');
|
||||
return withClient(a, async client => {
|
||||
const lock = await client.getMailboxLock(path);
|
||||
try {
|
||||
if (typeof read === 'boolean') await (read ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Seen'], { uid: true });
|
||||
if (typeof flagged === 'boolean') await (flagged ? client.messageFlagsAdd : client.messageFlagsRemove).call(client, String(uid), ['\\Flagged'], { uid: true });
|
||||
} finally { lock.release(); }
|
||||
});
|
||||
}
|
||||
async function move(a, mid, to) {
|
||||
const [path, uid] = dec(mid);
|
||||
return withClient(a, async client => {
|
||||
const dest = await pathOf(a, client, to);
|
||||
const lock = await client.getMailboxLock(path);
|
||||
try { await client.messageMove(String(uid), dest, { uid: true }); } finally { lock.release(); }
|
||||
});
|
||||
}
|
||||
async function del(a, mid, permanent) {
|
||||
if (!permanent) return move(a, mid, 'deleteditems');
|
||||
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(); } });
|
||||
}
|
||||
/** 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) {
|
||||
const c = cfgOf(a);
|
||||
return nodemailer.createTransport({ host: c.smtpHost, port: c.smtpPort, secure: c.smtpSecure, ...(c.smtpIgnoreTLS ? { ignoreTLS: true } : { requireTLS: !c.smtpSecure }), auth: { user: a.address, pass: a.pass }, tls: c.test ? { rejectUnauthorized: false } : undefined, connectionTimeout: 20e3, greetingTimeout: 20e3, socketTimeout: 60e3 });
|
||||
}
|
||||
const fromOf = a => ({ name: a.name || '', address: a.address });
|
||||
async function deliver(a, mail) {
|
||||
const opts = { from: fromOf(a), ...mail };
|
||||
const raw = await new MailComposer(opts).compile().build();
|
||||
const all = [...(opts.to || []), ...(opts.cc || []), ...(opts.bcc || [])].map(x => x.address || x);
|
||||
try { await transport(a).sendMail({ envelope: { from: a.address, to: all }, raw }); }
|
||||
catch (err) { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); }
|
||||
if (cfgOf(a).appendSent) { // iCloud does not keep a copy of what you send through SMTP, so put it in Sent ourselves
|
||||
try { await withClient(a, async client => { const { alias } = await boxes(a, client); if (alias.sentitems) await client.append(alias.sentitems, raw, ['\\Seen']); }); } catch { /* the mail was sent; only the copy is missing */ }
|
||||
}
|
||||
}
|
||||
async function send(a, { to, cc, bcc, subject, body, attachments }) {
|
||||
await deliver(a, { to, cc, bcc, subject, text: body, html: textToHtml(body), attachments: attachments.map(x => ({ filename: x.name, contentType: x.type, content: Buffer.from(x.data, 'base64') })) });
|
||||
}
|
||||
const quote = t => String(t || '').split(/\r?\n/).map(l => '> ' + l).join('\n');
|
||||
/** Reply, reply all or forward: built here (IMAP has no such call) so the thread headers and the quoted original are right. */
|
||||
async function reply(a, { id: mid, mode, comment, to }) {
|
||||
const { parsed: p } = await source(a, mid), me = a.address.toLowerCase();
|
||||
const orig = { from: addrs(p.from), replyTo: addrs(p.replyTo), to: addrs(p.to), cc: addrs(p.cc) };
|
||||
const when = (p.date || new Date()).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }), who = orig.from[0] ? (orig.from[0].name || orig.from[0].address) : 'someone';
|
||||
const subj = String(p.subject || '').replace(/^((re|fwd?):\s*)+/i, '');
|
||||
const origHtml = (p.html || textToHtml(p.text || '')).replace(/<script[\s\S]*?<\/script>/gi, '');
|
||||
const headers = {};
|
||||
let mail;
|
||||
if (mode === 'forward') {
|
||||
const note = `---------- Forwarded message ----------\nFrom: ${who}\nDate: ${when}\nSubject: ${p.subject || ''}\n\n`;
|
||||
const files = (p.attachments || []).filter(x => !x.related && x.size < 3 * 1024 * 1024).slice(0, 10).map(x => ({ filename: x.filename || 'attachment', contentType: x.contentType, content: x.content }));
|
||||
mail = { to, subject: 'Fwd: ' + subj, text: (comment ? comment + '\n\n' : '') + note + (p.text || ''), html: (comment ? textToHtml(comment) : '') + `<p>${escHtml(note).replace(/\n/g, '<br>')}</p>` + origHtml, attachments: files };
|
||||
} else {
|
||||
const first = (orig.replyTo[0] || orig.from[0]); if (!first) throw fail(400, 'That message has no sender to reply to');
|
||||
let rcpt = [first];
|
||||
if (mode === 'replyAll') for (const x of [...orig.to, ...orig.cc]) if (x.address && x.address.toLowerCase() !== me && !rcpt.some(r => r.address.toLowerCase() === x.address.toLowerCase())) rcpt.push(x);
|
||||
if (p.messageId) { headers.inReplyTo = p.messageId; headers.references = [].concat(p.references || [], p.messageId).join(' '); }
|
||||
mail = { to: rcpt.filter(x => x.address.toLowerCase() !== me || rcpt.length === 1), subject: 'Re: ' + subj, text: (comment ? comment + '\n\n' : '') + `On ${when}, ${who} wrote:\n` + quote(p.text || ''), html: textToHtml(comment || '') + `<p>On ${escHtml(when)}, ${escHtml(who)} wrote:</p><blockquote style="margin:0 0 0 8px;padding-left:10px;border-left:3px solid #ccc">${origHtml}</blockquote>`, ...headers };
|
||||
}
|
||||
await deliver(a, mail);
|
||||
}
|
||||
|
||||
// ---- adding an account: check the password against the real server first
|
||||
async function connectNew({ type, address, password }) {
|
||||
const preset = PRESETS[type]; if (!preset) throw fail(400, 'Choose Gmail or iCloud Mail');
|
||||
const addr = String(address || '').trim(), pass = String(password || '').replace(/\s+/g, '');
|
||||
if (!EMAIL.test(addr)) throw fail(400, 'Enter the full email address');
|
||||
if (pass.length < 8) throw fail(400, 'Enter the app password (not your normal password)');
|
||||
const a = { id: 'new-' + Math.random().toString(36).slice(2, 8), type, address: addr, pass, name: '' };
|
||||
try {
|
||||
const client = await open(a);
|
||||
await client.list();
|
||||
await transport(a).verify().catch(err => { throw fail(err.responseCode === 535 || err.code === 'EAUTH' ? 401 : 502, friendly(a, err)); });
|
||||
} finally { forget(a.id); }
|
||||
return { type, address: addr, pass, name: '' };
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -0,0 +1,381 @@
|
||||
// Mail: several accounts per person. Outlook.com (personal) goes through Microsoft Graph (sign in on Microsoft's page, needs an app registration,
|
||||
// see README -> Mail); Gmail and iCloud go through IMAP/SMTP with an app password (mail-imap.js). Every account's credentials are kept encrypted in
|
||||
// that person's own folder, and every request runs as that person. Message and folder ids carry the account: "<account>~<id>".
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const audit = require('./audit');
|
||||
const vault = require('./vault')('nehub-mail');
|
||||
const imap = require('./mail-imap');
|
||||
|
||||
const ENV_ID = process.env.MS_CLIENT_ID || '', ENV_SECRET = process.env.MS_CLIENT_SECRET || '';
|
||||
const TENANT = process.env.MS_TENANT || 'consumers'; // personal Microsoft accounts only
|
||||
const LOGIN = (process.env.MS_AUTH_URL || 'https://login.microsoftonline.com').replace(/\/$/, '');
|
||||
const GRAPH = (process.env.MS_GRAPH_URL || 'https://graph.microsoft.com/v1.0').replace(/\/$/, '');
|
||||
const PUBLIC_URL = (process.env.PUBLIC_URL || '').replace(/\/$/, '');
|
||||
const SCOPES = 'offline_access Mail.ReadWrite Mail.Send User.Read';
|
||||
|
||||
// ---- Microsoft app credentials: from the environment if set there, otherwise saved by an admin in Settings (encrypted, in the data folder).
|
||||
const CONFIG_FILE = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mail-config.json');
|
||||
let cfgCache;
|
||||
const loadCfg = () => { if (cfgCache !== undefined) return cfgCache; try { cfgCache = vault.unseal(JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')).data) || {}; } catch { cfgCache = {}; } return cfgCache; };
|
||||
const saveCfg = o => { atomicWrite(CONFIG_FILE, JSON.stringify({ data: vault.seal(o) }), { mode: 0o600 }); cfgCache = o; };
|
||||
const clientId = () => ENV_ID || loadCfg().clientId || '';
|
||||
const clientSecret = () => ENV_ID ? ENV_SECRET : loadCfg().clientSecret || '';
|
||||
const outlookOn = () => !!clientId();
|
||||
const enabled = () => true; // Gmail and iCloud need no server setup; only the Outlook.com button needs the Microsoft app
|
||||
/** How long the saved client secret lasts: { expires, days } or null. */
|
||||
function secretStatus() {
|
||||
const e = loadCfg().secretExpires; if (!e || ENV_ID) return null;
|
||||
const days = Math.ceil((Date.parse(e + 'T23:59:59') - Date.now()) / 864e5);
|
||||
return { expires: e, days };
|
||||
}
|
||||
|
||||
|
||||
const MAX_BODY = 6 * 1024 * 1024, MAX_ATTACH = 3 * 1024 * 1024;
|
||||
const readBody = req => new Promise((ok, no) => { const c = []; let n = 0; req.on('data', d => { n += d.length; if (n > MAX_BODY) { no(new Error('That is too large to send (attachments can total about 3 MB)')); req.destroy(); } else c.push(d); }); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||
const json = (res, code, obj) => {
|
||||
const body = Buffer.from(JSON.stringify(obj)), h = { 'content-type': 'application/json', 'cache-control': 'no-store' };
|
||||
if (body.length > 2048 && /\bgzip\b/.test((res.req && res.req.headers['accept-encoding']) || '')) { // message bodies are mostly HTML: gzip makes them several times smaller
|
||||
const z = require('zlib').gzipSync(body); res.writeHead(code, { ...h, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }); return res.end(z);
|
||||
}
|
||||
res.writeHead(code, h); res.end(body);
|
||||
};
|
||||
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||
|
||||
// ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
|
||||
const accFile = () => path.join(users.dir(), 'mail-accounts.json');
|
||||
const legacyFile = () => path.join(users.dir(), 'mail.json'); // one Outlook.com account, from before there were several
|
||||
const accounts = () => {
|
||||
try { return (vault.unseal(JSON.parse(fs.readFileSync(accFile(), 'utf8')).data) || {}).accounts || []; } catch {}
|
||||
try { // migrate the old single-account file
|
||||
const old = vault.unseal(JSON.parse(fs.readFileSync(legacyFile(), 'utf8')).data);
|
||||
if (old) { const list = [{ id: crypto.randomBytes(3).toString('hex'), type: 'outlook', ...old }]; saveAccounts(list); fs.rmSync(legacyFile(), { force: true }); return list; }
|
||||
} catch {}
|
||||
return [];
|
||||
};
|
||||
const saveAccounts = list => atomicWrite(accFile(), JSON.stringify({ data: vault.seal({ accounts: list }) }), { mode: 0o600 });
|
||||
const putAccount = a => { const list = accounts(), i = list.findIndex(x => x.id === a.id); if (i >= 0) list[i] = a; else list.push(a); saveAccounts(list); return a; };
|
||||
const dropAccount = id => { saveAccounts(accounts().filter(x => x.id !== id)); imap.forget(id); folderCache.clear(); };
|
||||
const findAccount = id => { const list = accounts(); const a = id ? list.find(x => x.id === id) : list[0]; if (!a) throw fail(id ? 404 : 409, id ? 'That account is not connected' : 'Mail is not connected'); return a; };
|
||||
const publicAcct = a => ({ id: a.id, type: a.type, address: a.address || '', name: a.name || '' });
|
||||
/** "<account>~<native id>" -> [account, native id]. An id with no account (older links) belongs to the first account. */
|
||||
const unpack = v => { const s = String(v || ''), i = s.indexOf('~'); return i > 0 ? [findAccount(s.slice(0, i)), s.slice(i + 1)] : [findAccount(), s]; };
|
||||
const pack = (a, nid) => a.id + '~' + nid;
|
||||
|
||||
// ---- sign-in with Microsoft (authorization code + PKCE)
|
||||
const states = new Map(); // state -> { uid, verifier, redirect, exp }
|
||||
const redirectUri = req => (PUBLIC_URL || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`) + '/api/mail/callback';
|
||||
function startUrl(req) {
|
||||
for (const [k, v] of states) if (v.exp < Date.now()) states.delete(k);
|
||||
const state = crypto.randomBytes(24).toString('base64url'), verifier = crypto.randomBytes(48).toString('base64url');
|
||||
const redirect = redirectUri(req);
|
||||
states.set(state, { uid: users.current().id, verifier, redirect, exp: Date.now() + 10 * 60e3 });
|
||||
const q = new URLSearchParams({ client_id: clientId(), response_type: 'code', redirect_uri: redirect, response_mode: 'query', scope: SCOPES, state,
|
||||
code_challenge: crypto.createHash('sha256').update(verifier).digest('base64url'), code_challenge_method: 'S256', prompt: 'select_account' });
|
||||
return `${LOGIN}/${TENANT}/oauth2/v2.0/authorize?${q}`;
|
||||
}
|
||||
async function tokenCall(params) {
|
||||
const body = new URLSearchParams({ client_id: clientId(), scope: SCOPES, ...params }); if (clientSecret()) body.set('client_secret', clientSecret());
|
||||
const r = await fetch(`${LOGIN}/${TENANT}/oauth2/v2.0/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, signal: AbortSignal.timeout(15000) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(j.error_description || j.error || 'Microsoft refused the sign-in'), { status: r.status, code: j.error });
|
||||
return j;
|
||||
}
|
||||
const page = (title, msg) => `<!doctype html><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${title}</title><body style="font:16px system-ui;padding:28px;max-width:520px;margin:auto"><h2>${title}</h2><p>${msg}</p><p><a href="/#/mail">Back to NE Hub</a></p>`;
|
||||
const escHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
/** Microsoft sends the person back here. Runs without the hub cookie (an installed app may sign in in another browser), so the one-time state says who it is. */
|
||||
async function callback(req, res) {
|
||||
const q = new URL(req.url, 'http://x').searchParams, rec = states.get(q.get('state') || '');
|
||||
const send = (code, html) => { res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(html); };
|
||||
if (!rec || rec.exp < Date.now()) return send(400, page('Sign-in expired', 'That sign-in link is no longer valid. Go back and press Connect again.'));
|
||||
states.delete(q.get('state'));
|
||||
if (q.get('error')) return send(400, page('Not connected', escHtml(q.get('error_description') || q.get('error'))));
|
||||
const user = users.get(rec.uid); if (!user || user.disabled) return send(403, page('Not allowed', 'That account is not available.'));
|
||||
try {
|
||||
const t = await tokenCall({ grant_type: 'authorization_code', code: q.get('code') || '', redirect_uri: rec.redirect, code_verifier: rec.verifier });
|
||||
if (!t.refresh_token) throw new Error('Microsoft did not allow ongoing access');
|
||||
await users.runAs(user, async () => {
|
||||
const me = await (await fetch(GRAPH + '/me?$select=displayName,mail,userPrincipalName', { headers: { authorization: 'Bearer ' + t.access_token } })).json().catch(() => ({}));
|
||||
const address = me.mail || me.userPrincipalName || '', same = accounts().find(x => x.type === 'outlook' && x.address && x.address.toLowerCase() === address.toLowerCase());
|
||||
putAccount({ id: same ? same.id : crypto.randomBytes(3).toString('hex'), type: 'outlook', access: t.access_token, refresh: t.refresh_token, exp: Date.now() + (t.expires_in || 3600) * 1000, address, name: me.displayName || '' });
|
||||
saveCfg({ ...loadCfg(), verifiedAt: Date.now() }); // proof that the app registration works
|
||||
audit.log('mail connected', { who: user.name, detail: me.mail || me.userPrincipalName || '' }, req);
|
||||
});
|
||||
res.writeHead(302, { location: '/#/mail', 'cache-control': 'no-store' }); res.end();
|
||||
} catch (e) { console.error('[mail] sign-in failed:', e.message); send(400, page('Could not connect', escHtml(friendly(e)))); }
|
||||
}
|
||||
|
||||
const refreshing = new Map(); // account id -> promise: parallel requests share one refresh (refresh tokens can rotate)
|
||||
/** A working access token for an Outlook account, refreshed when needed. `stale` is the token that just failed. */
|
||||
async function token(acct, force, stale) {
|
||||
const st = accounts().find(x => x.id === acct.id); if (!st) throw fail(409, 'Mail is not connected');
|
||||
if (!force && st.exp - 60e3 > Date.now()) return st.access;
|
||||
if (force && st.access !== stale) return st.access; // another request already refreshed it
|
||||
if (refreshing.has(st.id)) return refreshing.get(st.id);
|
||||
const job = (async () => {
|
||||
try {
|
||||
const t = await tokenCall({ grant_type: 'refresh_token', refresh_token: st.refresh });
|
||||
putAccount({ ...st, access: t.access_token, refresh: t.refresh_token || st.refresh, exp: Date.now() + (t.expires_in || 3600) * 1000 });
|
||||
return t.access_token;
|
||||
} catch (e) {
|
||||
if (e.code === 'invalid_grant' || e.status === 400 || e.status === 401) { dropAccount(st.id); throw fail(409, 'Microsoft signed you out of mail. Connect again.'); }
|
||||
throw fail(502, 'Could not reach Microsoft: ' + e.message);
|
||||
}
|
||||
})().finally(() => refreshing.delete(st.id));
|
||||
refreshing.set(st.id, job);
|
||||
return job;
|
||||
}
|
||||
async function graph(acct, p, opts = {}, stale) {
|
||||
const used = await token(acct, stale !== undefined, stale);
|
||||
const r = await fetch(GRAPH + p, { ...opts, headers: { authorization: 'Bearer ' + used, ...(opts.body && typeof opts.body === 'string' ? { 'content-type': 'application/json' } : {}), ...(opts.headers || {}) }, signal: AbortSignal.timeout(30000) });
|
||||
if (r.status === 401 && stale === undefined) return graph(acct, p, opts, used);
|
||||
if (r.status === 204 || r.status === 202) return null;
|
||||
if (opts.raw && r.ok) return r;
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw fail(r.status === 404 ? 404 : r.status === 429 ? 429 : 502, (j.error && j.error.message) || 'Microsoft Graph error ' + r.status);
|
||||
return j;
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
const ID = /^[A-Za-z0-9_=\-]{1,400}$/;
|
||||
const WELL = { inbox: 'Inbox', drafts: 'Drafts', sentitems: 'Sent', archive: 'Archive', junkemail: 'Junk', deleteditems: 'Deleted' };
|
||||
const id = v => { if (!ID.test(String(v || ''))) throw fail(400, 'Bad id'); return encodeURIComponent(v); };
|
||||
const addr = a => a && a.emailAddress ? { name: a.emailAddress.name || '', address: a.emailAddress.address || '' } : { name: '', address: '' };
|
||||
const row = m => ({ id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), date: m.receivedDateTime, read: !!m.isRead, attach: !!m.hasAttachments, preview: m.bodyPreview || '', flagged: !!(m.flag && m.flag.flagStatus === 'flagged') });
|
||||
const EMAIL = /^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/;
|
||||
/** "a@b.co, Ann <ann@x.org>" -> [{ name, address }], refusing anything that is not an address. */
|
||||
const recipients = (list, label) => { const out = []; for (const x of Array.isArray(list) ? list : String(list || '').split(/[,;]/)) { const a = String(x).trim(); if (!a) continue; const m = /^(.*?)\s*<([^>]+)>$/.exec(a), e = (m ? m[2] : a).trim(); if (!EMAIL.test(e)) throw fail(400, `“${a}” is not an email address (${label})`); out.push({ name: m ? m[1].replace(/^"|"$/g, '') : '', address: e }); } return out; };
|
||||
const toGraph = list => list.map(p => ({ emailAddress: { address: p.address } }));
|
||||
const textToHtml = t => '<div style="white-space:pre-wrap;font-family:sans-serif">' + escHtml(t).replace(/\r?\n/g, '<br>') + '</div>';
|
||||
const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').slice(0, 150);
|
||||
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(); } imap.dropFolders(id); };
|
||||
|
||||
// ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
|
||||
const outlook = {
|
||||
async folders(a) {
|
||||
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 15e3) return c.v;
|
||||
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount', keys = Object.keys(WELL);
|
||||
// the usual six and the full list in ONE round trip (Graph's $batch), instead of seven
|
||||
const b = await graph(a, '/$batch', { method: 'POST', body: JSON.stringify({ requests: [...keys.map((k, i) => ({ id: String(i), method: 'GET', url: `/me/mailFolders/${k}${sel}` })), { id: 'all', method: 'GET', url: `/me/mailFolders${sel}&$top=100` }] }) });
|
||||
const by = Object.fromEntries((b.responses || []).map(r => [r.id, r]));
|
||||
const wk = keys.map((k, i) => { const r = by[String(i)]; return r && r.status === 200 ? { id: k, realId: r.body.id, name: WELL[k], unread: r.body.unreadItemCount || 0, total: r.body.totalItemCount || 0, well: true } : null; });
|
||||
const known = new Set(wk.filter(Boolean).map(f => f.realId));
|
||||
const rest = by.all && by.all.status === 200 ? by.all.body.value || [] : [];
|
||||
const v = [...wk.filter(Boolean).map(({ realId, ...f }) => f), ...rest.filter(f => !known.has(f.id) && !['Outbox', 'Conversation History', 'Sync Issues', 'RSS Feeds'].includes(f.displayName)).map(f => ({ id: f.id, name: f.displayName, unread: f.unreadItemCount || 0, total: f.totalItemCount || 0 }))];
|
||||
folderCache.set(a.id, { t: Date.now(), v }); return v;
|
||||
},
|
||||
async list(a, { folder, skip, q }) {
|
||||
const fid = id(folder || 'inbox'), sel = '$select=id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,flag';
|
||||
const r = await graph(a, q ? `/me/mailFolders/${fid}/messages?${sel}&$top=50&$search="${encodeURIComponent(q)}"` : `/me/mailFolders/${fid}/messages?${sel}&$top=30&$skip=${skip}&$orderby=receivedDateTime desc`.replace(/ /g, '%20'));
|
||||
const items = (r.value || []).map(row); if (q) items.sort((x, y) => String(y.date).localeCompare(String(x.date)));
|
||||
return { items, more: !q && !!r['@odata.nextLink'], next: skip + items.length };
|
||||
},
|
||||
async message(a, mid) {
|
||||
const m = await graph(a, `/me/messages/${id(mid)}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,body,hasAttachments,flag,parentFolderId&$expand=attachments($select=id,name,contentType,size,isInline)`);
|
||||
let html = m.body && m.body.contentType === 'html' ? m.body.content || '' : textToHtml((m.body && m.body.content) || '');
|
||||
html = await withInline(a, html, m.attachments, mid);
|
||||
return { id: m.id, subject: m.subject || '(No subject)', from: addr(m.from), to: (m.toRecipients || []).map(addr), cc: (m.ccRecipients || []).map(addr), date: m.receivedDateTime, read: !!m.isRead, flagged: !!(m.flag && m.flag.flagStatus === 'flagged'),
|
||||
html, attachments: (m.attachments || []).filter(x => !x.isInline).map(x => ({ id: x.id, name: x.name, type: x.contentType, size: x.size })) };
|
||||
},
|
||||
async attachment(a, mid, aid) {
|
||||
const meta = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}?$select=name,contentType,size`);
|
||||
const r = await graph(a, `/me/messages/${id(mid)}/attachments/${id(aid)}/$value`, { raw: true });
|
||||
return { name: meta.name, type: meta.contentType, buffer: Buffer.from(await r.arrayBuffer()) };
|
||||
},
|
||||
async update(a, mid, { read, flagged }) {
|
||||
const patch = {}; if (typeof read === 'boolean') patch.isRead = read; if (typeof flagged === 'boolean') patch.flag = { flagStatus: flagged ? 'flagged' : 'notFlagged' };
|
||||
if (!Object.keys(patch).length) throw fail(400, 'Nothing to change');
|
||||
await graph(a, `/me/messages/${id(mid)}`, { method: 'PATCH', body: JSON.stringify(patch) });
|
||||
},
|
||||
async move(a, mid, to) { await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: decodeURIComponent(id(to)) }) }); },
|
||||
async del(a, mid, permanent) {
|
||||
if (permanent) await graph(a, `/me/messages/${id(mid)}`, { method: 'DELETE' });
|
||||
else await graph(a, `/me/messages/${id(mid)}/move`, { method: 'POST', body: JSON.stringify({ destinationId: 'deleteditems' }) });
|
||||
},
|
||||
async send(a, { to, cc, bcc, subject, body, attachments }) {
|
||||
await graph(a, '/me/sendMail', { method: 'POST', body: JSON.stringify({ message: { subject, body: { contentType: 'HTML', content: textToHtml(body) }, toRecipients: toGraph(to), ccRecipients: toGraph(cc), bccRecipients: toGraph(bcc),
|
||||
attachments: attachments.map(x => ({ '@odata.type': '#microsoft.graph.fileAttachment', name: x.name, contentType: x.type, contentBytes: x.data })) }, saveToSentItems: true }) });
|
||||
},
|
||||
async reply(a, { id: mid, mode, comment, to }) {
|
||||
const body = { comment }; if (mode === 'forward') body.toRecipients = toGraph(to);
|
||||
await graph(a, `/me/messages/${id(mid)}/${mode}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
},
|
||||
async unread(a) { return (await graph(a, '/me/mailFolders/inbox?$select=unreadItemCount')).unreadItemCount || 0; },
|
||||
};
|
||||
const providerOf = a => a.type === 'outlook' ? outlook : imap;
|
||||
|
||||
async function withInline(a, html, attachments, mid) {
|
||||
// (contentId only exists on file attachments, so it cannot be selected in the list: fetch each small inline picture whole)
|
||||
const inl = (attachments || []).filter(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||
const fulls = await Promise.all(inl.map(x => graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`).catch(() => null))); // all at once
|
||||
inl.forEach((x, i) => { const full = fulls[i]; if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || x.contentType};base64,${full.contentBytes}`); });
|
||||
return html;
|
||||
}
|
||||
|
||||
/** Plain-language reason for a Microsoft sign-in error. */
|
||||
function friendly(e) {
|
||||
const t = (e.message || '') + ' ' + (e.code || '');
|
||||
if (/AADSTS700016/.test(t)) return 'Microsoft does not know that Application (client) ID. Check you copied the whole ID, and that the app is set to "Personal Microsoft accounts only".';
|
||||
if (/AADSTS7000215/.test(t)) return 'The client secret is wrong. Use the secret Value (shown once when you made it), not the Secret ID.';
|
||||
if (/AADSTS7000222/.test(t)) return 'The client secret has expired. Make a new one in Microsoft Entra (Certificates & secrets) and paste its Value here.';
|
||||
if (/AADSTS7000218|AADSTS70002/.test(t)) return 'Microsoft wants a client secret for this app. Add one here, or register the redirect under "Mobile and desktop applications" instead of "Web".';
|
||||
if (/AADSTS700025/.test(t)) return 'This app is registered as a public client, so remove the client secret here (or register the redirect under "Web").';
|
||||
if (/AADSTS50194|AADSTS90002|AADSTS700038/.test(t)) return 'The app registration is not set to personal Microsoft accounts only.';
|
||||
return e.message || 'Microsoft refused the request';
|
||||
}
|
||||
async function adminHandle(req, res, p) {
|
||||
const me = users.current();
|
||||
if (me.role !== 'admin') return json(res, 403, { error: 'Only an admin can do this' });
|
||||
const c = loadCfg();
|
||||
const view = () => ({ source: ENV_ID ? 'env' : c.clientId ? 'app' : 'none', clientId: clientId(), hasSecret: !!clientSecret(), secretExpires: ENV_ID ? '' : c.secretExpires || '', days: (secretStatus() || {}).days ?? null, redirect: redirectUri(req), tenant: TENANT, verifiedAt: c.verifiedAt || 0 });
|
||||
if (p === '/api/mail/admin' && req.method === 'GET') return json(res, 200, view());
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
if (ENV_ID) return json(res, 400, { error: 'The Microsoft app is set in the server environment (MS_CLIENT_ID). Change it there.' });
|
||||
if (p === '/api/mail/admin') {
|
||||
const b = JSON.parse((await readBody(req)) || '{}'), next = { ...c };
|
||||
if (typeof b.clientId === 'string') { const id = b.clientId.trim(); if (id && !/^[0-9a-fA-F-]{36}$/.test(id)) throw fail(400, 'The Application (client) ID is a 36-character code like 1a2b3c4d-1234-5678-9abc-def012345678'); next.clientId = id; }
|
||||
if (b.clearSecret) delete next.clientSecret; else if (typeof b.clientSecret === 'string' && b.clientSecret.trim()) next.clientSecret = b.clientSecret.trim();
|
||||
if (typeof b.secretExpires === 'string') { if (b.secretExpires && !/^\d{4}-\d{2}-\d{2}$/.test(b.secretExpires)) throw fail(400, 'Enter the expiry as a date'); next.secretExpires = b.secretExpires; }
|
||||
if (next.clientId !== c.clientId || next.clientSecret !== c.clientSecret) delete next.verifiedAt; // changed, so it is unproven again
|
||||
if (!next.clientId) { fs.rmSync(CONFIG_FILE, { force: true }); cfgCache = {}; } else saveCfg(next);
|
||||
audit.log('mail setup changed', { who: me.name, detail: next.clientId ? 'saved' : 'cleared' }, req);
|
||||
return json(res, 200, { ok: true, ...(() => { const cc = loadCfg(); return { source: cc.clientId ? 'app' : 'none', clientId: cc.clientId || '', hasSecret: !!cc.clientSecret, secretExpires: cc.secretExpires || '', days: (secretStatus() || {}).days ?? null, redirect: redirectUri(req), verifiedAt: cc.verifiedAt || 0 }; })() });
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
const unreadCache = new Map(); // account id -> { t, n }: counts are shown at once from here and refreshed when older than 30 s
|
||||
const withTimeout = (p, ms) => Promise.race([p, new Promise((_, no) => setTimeout(() => no(Object.assign(new Error('That account did not answer in time'), { status: 504 })), ms))]);
|
||||
/** The accounts, with unread counts. `light` makes no network calls at all (what the Mail tab needs to draw itself); otherwise counts older than
|
||||
* 30 seconds are refreshed, each account on its own 6-second limit so one slow or unreachable account cannot hold up the others. */
|
||||
async function overview(light) {
|
||||
const list = accounts();
|
||||
const rows = await Promise.all(list.map(async a => {
|
||||
const c = unreadCache.get(a.id);
|
||||
if (light || (c && Date.now() - c.t < 30e3)) return { ...publicAcct(a), unread: c ? c.n : null };
|
||||
try { const n = await withTimeout(providerOf(a).unread(a), 6000); unreadCache.set(a.id, { t: Date.now(), n }); return { ...publicAcct(a), unread: n }; }
|
||||
catch (e) { return { ...publicAcct(a), unread: c ? c.n : null, error: e.message, expired: e.status === 409 }; }
|
||||
}));
|
||||
const live = accounts(); // (an account whose sign-in Microsoft revoked was dropped meanwhile)
|
||||
const shown = rows.filter(r => live.some(x => x.id === r.id) || !r.expired);
|
||||
const expired = rows.some(r => r.expired);
|
||||
return { connected: shown.length > 0, accounts: shown, expired: expired && !shown.length, address: (shown[0] || {}).address, name: (shown[0] || {}).name, unread: shown.reduce((n, r) => n + (r.unread || 0), 0) };
|
||||
}
|
||||
|
||||
// ---- Mail 2.0: snooze, templates and a unified inbox. Snoozed messages and templates are kept by the hub for each person (the mail providers know nothing of them).
|
||||
// Snooze hides a message from the inbox lists until a time, then it is back and the person is told.
|
||||
const snoozeFile = () => path.join(users.dir(), 'mail-snoozed.json'), tplFile = () => path.join(users.dir(), 'mail-templates.json');
|
||||
const readArr = f => { try { const a = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const snoozed = () => readArr(snoozeFile()).filter(x => x && typeof x.id === 'string' && x.until > Date.now());
|
||||
const isInbox = f => /(^|~)inbox$/i.test(String(f || 'inbox'));
|
||||
const hideSnoozed = (items, folder) => { if (!isInbox(folder)) return items; const hide = new Set(snoozed().map(x => x.id)); return hide.size ? items.filter(m => !hide.has(m.id)) : items; };
|
||||
/** Snoozed messages whose time has come, for one person (and forgets them), so the caller can tell the person. */
|
||||
function snoozeDue(uid) {
|
||||
return users.runAs(users.get(uid), () => {
|
||||
const all = readArr(snoozeFile()), now = Date.now(), due = all.filter(x => x.until <= now);
|
||||
if (due.length) atomicWrite(snoozeFile(), JSON.stringify(all.filter(x => x.until > now)), { mode: 0o600 });
|
||||
return due;
|
||||
});
|
||||
}
|
||||
const tplClean = b => { const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the template a name'); return { name, subject: String(b.subject || '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200), body: String(b.body || '').slice(0, 20000) }; };
|
||||
/** The newest inbox messages of every connected account, together: one list, newest first (the first page of each account). */
|
||||
async function unifiedInbox(search) {
|
||||
const accs = accounts(), parts = await Promise.all(accs.map(async a => { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: search }), 12000); return r.items.map(m => ({ ...m, id: pack(a, m.id), account: a.name || a.address, accountId: a.id })); } catch { return []; } }));
|
||||
return parts.flat().sort((x, y) => String(y.date || '').localeCompare(String(x.date || ''))).slice(0, 40);
|
||||
}
|
||||
/** An attachment's name and bytes (for saving into Files). `id` is the packed message id. */
|
||||
async function attachmentOf(id, aid) { const [a, nid] = unpack(id), f = await providerOf(a).attachment(a, nid, aid); return { name: safeName(f.name), buffer: f.buffer }; }
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||
try {
|
||||
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
|
||||
if (p === '/api/mail/status') return json(res, 200, { ...(await overview(q.get('light') === '1')), outlook: outlookOn(), providers: imap.PRESETS });
|
||||
if (p === '/api/mail/connect' && req.method === 'GET') { if (!outlookOn()) throw fail(403, 'Outlook.com is not set up on this server (an admin adds the Microsoft app in Settings)'); return json(res, 200, { url: startUrl(req), redirect: redirectUri(req) }); }
|
||||
if (p === '/api/mail/folders') {
|
||||
const a = findAccount(q.get('acct') || undefined);
|
||||
return json(res, 200, (await providerOf(a).folders(a)).map(f => ({ ...f, id: pack(a, f.id) })));
|
||||
}
|
||||
if (p === '/api/mail/messages' && q.get('folder') === 'unified') { const items = hideSnoozed(await unifiedInbox((q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100)), 'inbox'); return json(res, 200, { items, more: false, next: items.length }); }
|
||||
if (p === '/api/mail/snoozed' && req.method === 'GET') return json(res, 200, snoozed());
|
||||
if (p === '/api/mail/templates' && req.method === 'GET') return json(res, 200, readArr(tplFile()));
|
||||
if (p === '/api/mail/messages') {
|
||||
const [a, fid] = unpack(q.get('folder') || 'inbox'), search = (q.get('q') || '').replace(/["\\]/g, ' ').trim().slice(0, 100), skip = Math.max(0, parseInt(q.get('skip'), 10) || 0);
|
||||
const r = await providerOf(a).list(a, { folder: fid || 'inbox', skip, q: search });
|
||||
return json(res, 200, { ...r, items: hideSnoozed(r.items.map(m => ({ ...m, id: pack(a, m.id) })), fid || 'inbox') });
|
||||
}
|
||||
if (p === '/api/mail/message') {
|
||||
const [a, nid] = unpack(q.get('id')), m = await providerOf(a).message(a, nid);
|
||||
return json(res, 200, { ...m, id: pack(a, m.id), account: publicAcct(a) });
|
||||
}
|
||||
if (p === '/api/mail/attachment') {
|
||||
const [a, nid] = unpack(q.get('id')), f = await providerOf(a).attachment(a, nid, q.get('aid'));
|
||||
res.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename="${safeName(f.name)}"; filename*=UTF-8''${encodeURIComponent(safeName(f.name))}`, 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' });
|
||||
return res.end(f.buffer);
|
||||
}
|
||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||
const b = JSON.parse((await readBody(req)) || '{}'), me = users.current();
|
||||
if (p === '/api/mail/snooze') { // hide a message from the inbox until a time (a message that cannot be found is refused)
|
||||
const until = Math.round(+b.until); if (!(until > Date.now() && until < Date.now() + 366 * 864e5)) throw fail(400, 'Pick a time in the future (within a year)');
|
||||
const [a, nid] = unpack(b.id), id = pack(a, nid), l = snoozed().filter(x => x.id !== id); l.push({ id, until, subject: String(b.subject || '').slice(0, 150), from: String(b.from || '').slice(0, 100) });
|
||||
atomicWrite(snoozeFile(), JSON.stringify(l.slice(-500)), { mode: 0o600 }); return json(res, 200, { ok: true, until });
|
||||
}
|
||||
if (p === '/api/mail/unsnooze') { atomicWrite(snoozeFile(), JSON.stringify(snoozed().filter(x => x.id !== String(b.id))), { mode: 0o600 }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/templates') { const t = tplClean(b), l = readArr(tplFile()), i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (i >= 0) l[i] = { ...l[i], ...t }; else { if (l.length >= 50) throw fail(400, 'That is the most templates (50)'); l.push({ id: crypto.randomBytes(4).toString('hex'), ...t }); } atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
|
||||
if (p === '/api/mail/templates/delete') { const l = readArr(tplFile()).filter(x => x.id !== b.id); atomicWrite(tplFile(), JSON.stringify(l), { mode: 0o600 }); return json(res, 200, l); }
|
||||
if (p === '/api/mail/accounts') { // add a Gmail or iCloud account (an app password; checked against the server before it is saved)
|
||||
const a = await imap.connectNew({ type: b.type, address: b.address, password: b.password });
|
||||
const same = accounts().find(x => x.type === a.type && x.address.toLowerCase() === a.address.toLowerCase());
|
||||
const saved = putAccount({ ...a, id: same ? same.id : crypto.randomBytes(3).toString('hex') }); dropFolderCache();
|
||||
audit.log('mail connected', { who: me.name, detail: a.address }, req);
|
||||
return json(res, 200, { ok: true, account: publicAcct(saved) });
|
||||
}
|
||||
if (p === '/api/mail/disconnect') {
|
||||
const list = accounts(), gone = b.acct ? list.filter(x => x.id === b.acct) : list;
|
||||
if (b.acct && !gone.length) throw fail(404, 'That account is not connected');
|
||||
for (const x of gone) dropAccount(x.id);
|
||||
audit.log('mail disconnected', { who: me.name, detail: gone.map(x => x.address).join(', ') }, req); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/update') { const [a, nid] = unpack(b.id); await providerOf(a).update(a, nid, { read: b.read, flagged: b.flagged }); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/move') { const [a, nid] = unpack(b.id), dest = String(b.to || ''); await providerOf(a).move(a, nid, dest.includes('~') ? dest.slice(dest.indexOf('~') + 1) : dest); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/delete') { const [a, nid] = unpack(b.id); await providerOf(a).del(a, nid, !!b.permanent); dropFolderCache(a.id); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/mail/send') {
|
||||
const a = findAccount(b.acct || undefined), to = recipients(b.to, 'To'), cc = recipients(b.cc, 'Cc'), bcc = recipients(b.bcc, 'Bcc');
|
||||
if (!to.length && !cc.length && !bcc.length) throw fail(400, 'Add at least one recipient');
|
||||
const subject = String(b.subject || '').slice(0, 255), body = String(b.body || ''); if (body.length > 200000) throw fail(400, 'That message is too long');
|
||||
await providerOf(a).send(a, { to, cc, bcc, subject, body, attachments: cleanAttachments(b.attachments) });
|
||||
audit.log('mail sent', { who: me.name, detail: `${to.length + cc.length + bcc.length} recipient(s)` }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
|
||||
}
|
||||
if (p === '/api/mail/reply') {
|
||||
const mode = { reply: 'reply', replyAll: 'replyAll', forward: 'forward' }[b.mode]; if (!mode) throw fail(400, 'Unknown action');
|
||||
const [a, nid] = unpack(b.id), to = mode === 'forward' ? recipients(b.to, 'To') : [];
|
||||
if (mode === 'forward' && !to.length) throw fail(400, 'Add at least one recipient');
|
||||
await providerOf(a).reply(a, { id: nid, mode, comment: String(b.comment || '').slice(0, 200000), to });
|
||||
audit.log('mail sent', { who: me.name, detail: mode }, req); dropFolderCache(a.id); return json(res, 200, { ok: true });
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
||||
}
|
||||
|
||||
/** 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({ acct: a.id, mid: m.id, 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;
|
||||
}
|
||||
/** Mark messages read: what a cleared "new mail" notification stands for. refs: [{ a: account id, id: message id }]. */
|
||||
async function markRead(refs) {
|
||||
for (const r of (Array.isArray(refs) ? refs : []).slice(0, 50)) { try { const a = findAccount(r.a); await providerOf(a).update(a, r.id, { read: true }); dropFolderCache(a.id); } catch { /* gone or unreachable: nothing to mark */ } }
|
||||
}
|
||||
/** 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 = { snoozeDue, attachmentOf, markRead, health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
// Movies & TV for the family: Favorites (each person's own hearted titles) and Movie night (one shared list everyone adds to and votes on, so the family picks together).
|
||||
// Titles are kept as a small card (Plex id, title, year, poster path), so the lists draw without asking Plex. Favorites: users/<id>/video-favs.json; Movie night: DATA_DIR/movienight.json.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const atomicWrite = require('./atomic');
|
||||
const users = require('./users');
|
||||
const { readBody } = require('./body');
|
||||
|
||||
const NIGHT = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'movienight.json');
|
||||
const FAVS = () => path.join(users.dir(), 'video-favs.json');
|
||||
const rd = (f, d) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(v) ? v : d; } catch { return d; } };
|
||||
const wr = (f, v) => 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-\u001f]/g, '').trim().slice(0, n);
|
||||
/** A title card from what the browser sent: only the fields a card needs, and the poster path must be a Plex path of ours. */
|
||||
function card(b) {
|
||||
const id = str(b.id, 20); if (!/^\d{1,12}$/.test(id)) throw fail(400, 'Pick a movie or show');
|
||||
const title = str(b.title, 160); if (!title) throw fail(400, 'That title has no name');
|
||||
return { id, title, year: Math.round(+b.year) || 0, type: b.type === 'show' ? 'show' : 'movie', thumb: /^\/library\/metadata\/\d+\/thumb(\/\d+)?$/.test(str(b.thumb, 120)) ? str(b.thumb, 120) : '' };
|
||||
}
|
||||
const nameOf = id => (users.get(id) || {}).name || 'Someone';
|
||||
const nightView = (l, me) => l.map(x => ({ ...x, byName: nameOf(x.by), votes: x.votes.length, voted: x.votes.includes(me.id), voters: x.votes.map(nameOf) })).sort((a, b) => b.votes - a.votes || a.added - b.added);
|
||||
|
||||
async function handle(req, res) {
|
||||
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/moviefam'.length) || '/';
|
||||
try {
|
||||
if (!me) throw fail(401, 'Sign in');
|
||||
if (p === '/night' && req.method === 'GET') return json(res, 200, { items: nightView(rd(NIGHT(), []), me) });
|
||||
if (p === '/favs' && req.method === 'GET') return json(res, 200, { items: rd(FAVS(), []) });
|
||||
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||
const b = JSON.parse(await readBody(req, 1e4) || '{}');
|
||||
if (p === '/fav') { const c = card(b), l = rd(FAVS(), []).filter(x => x.id !== c.id); if (b.on) l.unshift({ ...c, added: Date.now() }); wr(FAVS(), l.slice(0, 500)); return json(res, 200, { on: !!b.on }); }
|
||||
const l = rd(NIGHT(), []);
|
||||
if (p === '/night/add') { const c = card(b); if (l.some(x => x.id === c.id)) throw fail(400, c.title + ' is already on the list'); if (l.length >= 60) throw fail(400, 'The list is full; remove something first'); l.push({ ...c, by: me.id, added: Date.now(), votes: [me.id] }); wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||
if (p === '/night/clear') { if (me.role !== 'admin') throw fail(403, 'Only an admin can clear the list'); wr(NIGHT(), []); return json(res, 200, { items: [] }); }
|
||||
const x = l.find(y => y.id === str(b.id, 20)); if (!x) throw fail(404, 'That title is not on the list');
|
||||
if (p === '/night/vote') { x.votes = x.votes.includes(me.id) ? x.votes.filter(v => v !== me.id) : [...x.votes, me.id]; wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||
if (p === '/night/remove') { if (x.by !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who added it can remove it'); wr(NIGHT(), l.filter(y => y !== x)); return json(res, 200, { items: nightView(l.filter(y => y !== x), me) }); }
|
||||
throw fail(404, 'Not found');
|
||||
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||
}
|
||||
module.exports = { handle };
|
||||
+107
@@ -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 };
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// 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', 'notes.read', 'notes.write', 'recipes.read', 'music.read']; // what an extension may ask the person for
|
||||
const BOOK_EXT = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'docx'];
|
||||
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 : {}, seen: j.seen && typeof j.seen === 'object' ? j.seen : {} }; } catch { return { installed: [], data: {}, grants: {}, seen: {} }; } };
|
||||
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])}},
|
||||
notes:{list:function(){return call('notes.list')},add:function(o){return call('notes.add',[o])}},
|
||||
recipes:{search:function(q){return call('recipes.search',[{q:q}])},get:function(slug){return call('recipes.get',[{slug:slug}])}},
|
||||
music:{nowPlaying:function(){return call('music.nowPlaying')}}}})()</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, seen: stateOf().seen, 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); s.seen[it.id] = it.updated || 0; } if (m[2] === 'uninstall') { delete s.data[it.id]; delete s.grants[it.id]; delete s.seen[it.id]; } saveState(s); return json(res, 200, { installed: s.installed });
|
||||
}
|
||||
if ((m = /^\/ext\/([0-9a-f]{12})\/seen$/.exec(p)) && req.method === 'POST') { // the person has looked at this version of the extension (so it stops saying it was updated)
|
||||
const it = loadItems().find(x => x.id === m[1]); if (!it || it.kind !== 'extension') throw fail(404, 'Not found'); const s = stateOf(); if (s.installed.includes(it.id)) { s.seen[it.id] = it.updated || 0; saveState(s); } return json(res, 200, { seen: s.seen });
|
||||
}
|
||||
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 };
|
||||
@@ -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 };
|
||||
Generated
+796
-3
@@ -1,15 +1,23 @@
|
||||
{
|
||||
"name": "project",
|
||||
"name": "ne-hub",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project",
|
||||
"name": "ne-hub",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.3"
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"imapflow": "^2.9.0",
|
||||
"mailparser": "^3.12.2",
|
||||
"nodemailer": "^10.2.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hoodiecrow-imap": "^3.3.1",
|
||||
"smtp-server": "^3.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hexagon/base64": {
|
||||
@@ -257,6 +265,28 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "~2.3.0",
|
||||
"domhandler": "~5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"selderee": "~0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@simplewebauthn/server": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-14.0.3.tgz",
|
||||
@@ -278,6 +308,41 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@zone-eu/mailsplit": {
|
||||
"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.6.3",
|
||||
"libqp": "3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.1.0.tgz",
|
||||
@@ -292,6 +357,563 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.2.tgz",
|
||||
"integrity": "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.2",
|
||||
"entities": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.4.0.tgz",
|
||||
"integrity": "sha512-PE0KjlsDy9JSsoU4C2Q8NXb1kC4c+bZRac9ZvHXUE4q1Ht63gScKMdEy9jst9+Fi3M4eDkqzkEhBZvix4blP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hoodiecrow-imap": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/hoodiecrow-imap/-/hoodiecrow-imap-3.3.1.tgz",
|
||||
"integrity": "sha512-0o+wjiIAkRZDJDHkays3mZZ92Qd1dmikPfOO8qbZ7QczIOCPBxvPJlkNjem3DzZkAB5O34uHS3u25yNS4YzV/w==",
|
||||
"deprecated": "Renamed to imapkit, see https://imapkit.com",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"imap-handler": "1.3.1",
|
||||
"smtp-server": "3.19.17"
|
||||
},
|
||||
"bin": {
|
||||
"hoodiecrow": "bin/hoodiecrow.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoodiecrow-imap/node_modules/nodemailer": {
|
||||
"version": "10.0.14",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz",
|
||||
"integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==",
|
||||
"dev": true,
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoodiecrow-imap/node_modules/smtp-server": {
|
||||
"version": "3.19.17",
|
||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.17.tgz",
|
||||
"integrity": "sha512-/9RwyNTcR8osMQWOvC8NV8l57ddPyYXvuG+wa1Ua4cD/I41z7zCMsGtXLa9vxlrggjRrfejr50X0WS/9NuVWqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT-0",
|
||||
"dependencies": {
|
||||
"ipv6-normalize": "1.0.1",
|
||||
"nodemailer": "10.0.14",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.1.tgz",
|
||||
"integrity": "sha512-GiVhRI1BatGARSCmlXWNCjDT0cWrwBWoeduLoV0WSKAgaV/wa+hUWy5LiQLUs4UwiUrE52ZCMfBGiKD87TDPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"deepmerge-ts": "^8.0.1",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
"domhandler": "^5.0.3",
|
||||
"domutils": "^3.2.2",
|
||||
"entities": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/imap-handler": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/imap-handler/-/imap-handler-1.3.1.tgz",
|
||||
"integrity": "sha512-/VHETxcR5VMzLAxvb4CQw5d7hCTe4MlcvBlRoKO6z/7cEF650ha8Lwq1EbmUMutrxmb+DMB6piHbANv2vHQUAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imapflow": {
|
||||
"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.29",
|
||||
"encoding-japanese": "2.4.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.4.1",
|
||||
"libmime": "5.6.3",
|
||||
"libqp": "3.0.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.4",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz",
|
||||
"integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipv6-normalize": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ipv6-normalize/-/ipv6-normalize-1.0.1.tgz",
|
||||
"integrity": "sha512-Bm6H79i01DjgGTCWjUuCjJ6QDo1HB96PT/xCYuyJUP9WFbVDrLSbG4EZCvOCun2rNswZb0c3e4Jt/ws795esHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz",
|
||||
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.4.1.tgz",
|
||||
"integrity": "sha512-Nyh3H90B3gRtrcxIe56Ayy7635J0jmh6AbxbQgQae8wrSPQMH5wFqJChiMTB13yKG512L3YspbA5DzumhFTITA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"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",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.4.1",
|
||||
"libqp": "3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-3.0.1.tgz",
|
||||
"integrity": "sha512-7QZxpPX25pga1WiMueduWxmrHMVBFrZPH6ZUaozGYa/H98JH6NVBSET3qVw+4NxzoraIW7rJ81YxSU9IalvCpg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
|
||||
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/markdown-it"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"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.29",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "10.0.1",
|
||||
"libmime": "5.6.3",
|
||||
"linkify-it": "5.0.2",
|
||||
"nodemailer": "10.2.1",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.261.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",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz",
|
||||
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.7.0",
|
||||
"peberminta": "^0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz",
|
||||
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "10.3.1",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz",
|
||||
"integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pvtsutils": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||
@@ -310,12 +932,158 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
|
||||
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "~0.13.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/KillyMXI"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/smtp-server": {
|
||||
"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.2.1",
|
||||
"punycode.js": "2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.10",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
||||
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "^10.1.1",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream/node_modules/real-require": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.261.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
|
||||
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -339,6 +1107,31 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-3
@@ -4,13 +4,23 @@
|
||||
"description": "NE Hub: Seafile, OnlyOffice, Plex, mail and audiobooks in one PWA",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/server": "^14.0.3"
|
||||
"@simplewebauthn/server": "^14.0.3",
|
||||
"imapflow": "^2.9.0",
|
||||
"mailparser": "^3.12.2",
|
||||
"nodemailer": "^10.2.0",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"hoodiecrow-imap": "^3.3.1",
|
||||
"smtp-server": "^3.21.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -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', 'podcast'); // 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 };
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// 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');
|
||||
|
||||
// With a `kind` ('podcast' or 'audiobook'), each report also adds the time actually listened to that person's listening log (listening.json): seconds per day per kind, and
|
||||
// the items finished. A jump forward bigger than the time that passed (a seek) does not count.
|
||||
const day = t => { const d = new Date(t); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; };
|
||||
const logFile = () => path.join(users.dir(), 'listening.json');
|
||||
const loadLog = () => { try { const o = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return { days: o.days || {}, finished: o.finished || {} }; } catch { return { days: {}, finished: {} }; } };
|
||||
const stats = () => {
|
||||
const o = loadLog(), now = new Date(), today = day(now), tot = k => Object.values(o.days).reduce((n, d) => n + (d[k] || 0), 0), sum = (a, b) => Object.entries(o.days).filter(([k]) => k >= a && k <= b).reduce((n, [, d]) => n + (d.podcast || 0) + (d.audiobook || 0), 0);
|
||||
const back = n => { const d = new Date(now); d.setDate(d.getDate() - n); return day(d); }, any = k => ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) >= 60;
|
||||
let streak = 0; for (let i = any(today) ? 0 : 1; any(back(i)); i++) streak++;
|
||||
const year = String(now.getFullYear());
|
||||
return { today: sum(today, today), week: sum(back(6), today), month: sum(back(29), today), podcast: tot('podcast'), audiobook: tot('audiobook'), streak, last14: [...Array(14)].map((_, i) => { const k = back(13 - i); return { day: k, s: ((o.days[k] || {}).podcast || 0) + ((o.days[k] || {}).audiobook || 0) }; }), finishedYear: Object.values(o.finished).filter(f => day(f.t).startsWith(year)).length, audiobooksYear: Object.values(o.finished).filter(f => f.kind === 'audiobook' && day(f.t).startsWith(year)).length, ebooksYear: Object.values(o.finished).filter(f => f.kind === 'ebook' && day(f.t).startsWith(year)).length, finishedAll: Object.keys(o.finished).length };
|
||||
};
|
||||
module.exports = (name, kind) => {
|
||||
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(), log = kind ? loadLog() : null; let logged = false;
|
||||
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)) {
|
||||
const old = mine[k], n = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||
if (log && old && n.t > old.t) { const wall = (n.t - old.t) / 1000, gain = n.pos - old.pos; if (gain > 0 && gain <= wall * 2 + 5 && wall < 600) { const d = log.days[day(n.t)] = log.days[day(n.t)] || {}; d[kind] = Math.round(((d[kind] || 0) + Math.min(gain, wall + 5)) * 10) / 10; logged = true; } }
|
||||
if (log && n.dur > 60 && n.pos >= n.dur - 30 && !log.finished[k]) { log.finished[k] = { t: n.t || Date.now(), kind }; logged = true; }
|
||||
mine[k] = n;
|
||||
}
|
||||
if (logged) { for (const k of Object.keys(log.days).sort().slice(0, -400)) delete log.days[k]; const lf = logFile(), lt = lf + '.tmp'; fs.writeFileSync(lt, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(lt, lf); }
|
||||
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||
};
|
||||
return { load, merge };
|
||||
};
|
||||
/** A book (or e-book) finished: counts toward the yearly goals. Once only. */
|
||||
function markFinished(key, kind, t = Date.now()) { const log = loadLog(); if (log.finished[key]) return; log.finished[key] = { t, kind }; const lf = logFile(), tmp = lf + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(log), { mode: 0o600 }); fs.renameSync(tmp, lf); }
|
||||
// The family challenge: one yearly goal for everyone, counted across everybody's finished books.
|
||||
const goalFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'readgoal.json');
|
||||
function familyGoal() {
|
||||
const year = new Date().getFullYear(); let goal = 0; try { const g = JSON.parse(fs.readFileSync(goalFile(), 'utf8')); if (g.year === year) goal = +g.goal || 0; } catch { /* none set */ }
|
||||
let finished = 0; const people = [];
|
||||
for (const u of users.all()) { if (u.disabled) continue; let log = { finished: {} }; try { log = JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'listening.json'), 'utf8')); } catch { /* nothing yet */ } const n = Object.values(log.finished || {}).filter(f => day(f.t).startsWith(String(year))).length; finished += n; if (n) people.push({ name: u.name, n }); }
|
||||
return { year, goal, finished, people: people.sort((a, b) => b.n - a.n) };
|
||||
}
|
||||
function setFamilyGoal(goal) { const g = Math.max(0, Math.min(1000, Math.round(+goal) || 0)); fs.mkdirSync(path.dirname(goalFile()), { recursive: true }); fs.writeFileSync(goalFile(), JSON.stringify({ year: new Date().getFullYear(), goal: g })); return familyGoal(); }
|
||||
module.exports.stats = stats; module.exports.markFinished = markFinished; module.exports.familyGoal = familyGoal; module.exports.setFamilyGoal = setFamilyGoal;
|
||||
+261
-1522
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,113 @@
|
||||
'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());
|
||||
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
||||
}
|
||||
|
||||
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>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||
el.querySelector('#abp').onclick = () => go();
|
||||
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
||||
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||
el.querySelector('#abc').onclick = async () => {
|
||||
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
const 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));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use strict';
|
||||
// ---- Audiobooks: embedded site. Sign-in details saved in Settings (this browser only) can be copied with one tap,
|
||||
// because a page can't type into another site's login form.
|
||||
const abCreds = () => store.get('ab_creds', { u: '', p: '' });
|
||||
// Podcast Admin (Verse by Verse): the site refuses to be framed (X-Frame-Options), so this is a launcher.
|
||||
function viewVerse() {
|
||||
const url = cfg.verse, host = new URL(url).host;
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Podcast Admin</h1><p class="sub">${esc(host + new URL(url).pathname)}</p>
|
||||
<div class="card" style="max-width:none"><p style="margin-top:0">This site doesn't allow being shown inside another app, so it opens in its own tab.</p>
|
||||
<div class="bar"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">${icon('book')}Open admin</a></div></div></div>`;
|
||||
}
|
||||
|
||||
// Frames that must keep running (audio) live outside #view: created once, then only shown or hidden.
|
||||
function showFrame(key, url, title, allow) {
|
||||
const wrap = $('#audiowrap');
|
||||
let f = wrap.querySelector(`iframe[data-k="${key}"]`);
|
||||
if (!f) { f = document.createElement('iframe'); f.dataset.k = key; f.src = url; f.title = title; f.allow = allow; wrap.appendChild(f); }
|
||||
wrap.querySelectorAll('iframe').forEach(x => x.classList.toggle('act', x === f));
|
||||
wrap.className = 'on'; document.body.classList.add('onaudio');
|
||||
}
|
||||
|
||||
// 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';
|
||||
function viewAudio() {
|
||||
const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
|
||||
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Audiobooks</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
||||
${c.u ? `<button class="ibtn" id="cu" aria-label="Copy username" title="Copy username">${icon('copy')}</button>` : ''}
|
||||
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
|
||||
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
||||
<a class="ibtn" href="${ENTER}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
|
||||
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
|
||||
const open = fresh => {
|
||||
setSt('Opening…');
|
||||
let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
|
||||
showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
|
||||
f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
|
||||
let done = false;
|
||||
f.addEventListener('load', () => { done = true; setSt(''); });
|
||||
setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
|
||||
};
|
||||
if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
|
||||
$('#ar').onclick = () => open(true);
|
||||
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
|
||||
const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
'use strict';
|
||||
// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to
|
||||
// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened.
|
||||
const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives'], ['pantry.low', 'A pantry item runs low'], ['podcast.new', 'A new podcast episode comes out']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']];
|
||||
const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : r.when.type === 'pantry.low' ? 'When ' + (r.when.match ? '“' + r.when.match + '”' : 'a pantry item') + ' runs low' : r.when.type === 'podcast.new' ? 'When a new episode of “' + r.when.match + '” comes out' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`;
|
||||
function autoSettings(el) {
|
||||
if (!el) return;
|
||||
const draw = rules => {
|
||||
el.innerHTML = `<h2>Automations</h2><p class="sub">Little rules that run for you. Each can run up to 20 times a day.</p>
|
||||
<div class="list">${rules.map(r => `<div class="row" style="align-items:flex-start"><label class="grow" style="cursor:pointer;white-space:normal"><b>${esc(r.name)}</b><small style="white-space:normal">${esc(auSummary(r))}${r.runs ? ' · ran ' + r.runs + ' time' + (r.runs === 1 ? '' : 's') : ''}</small></label>
|
||||
<input type="checkbox" data-on="${esc(r.id)}" aria-label="Turn ${esc(r.name)} on or off"${r.on ? ' checked' : ''}><button class="ibtn" data-e="${esc(r.id)}" aria-label="Edit ${esc(r.name)}" title="Edit">${icon('edit')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>No automations yet.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn ghost" id="auadd">${icon('plus')}Add an automation</button></div>`;
|
||||
el.querySelector('#auadd').onclick = () => edit(null);
|
||||
el.querySelectorAll('[data-on]').forEach(c => c.onchange = async () => { try { draw((await auApi('/toggle', auOpt({ id: c.dataset.on, on: c.checked }))).rules); } catch (e) { toast(e.message); c.checked = !c.checked; } });
|
||||
el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => edit(rules.find(r => r.id === b.dataset.e)));
|
||||
};
|
||||
const edit = r => {
|
||||
const w = r ? r.when : { type: 'time', at: '08:00', match: '' }, t = r ? r.then : { type: 'task', text: '' };
|
||||
const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `<form id="auf"><label for="aun">Name (optional)</label><input type="text" id="aun" value="${esc(r ? r.name : '')}" autocomplete="off">
|
||||
<label for="auw">When</label><select id="auw">${AU_WHEN.map(([v, l]) => `<option value="${v}"${v === w.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<div id="auwd"></div><label for="aut">Then</label><select id="aut">${AU_THEN.map(([v, l]) => `<option value="${v}"${v === t.type ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="aux" id="auxl"></label><input type="text" id="aux" value="${esc(t.text)}" autocomplete="off" maxlength="200"><p class="sub" style="margin:4px 0 0">You can use {title} (the task, item or episode), {from} and {show}.</p><div class="err" id="aue"></div>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button>${r ? '<button type="button" class="btn ghost" id="autest">Test</button><button type="button" class="btn danger" id="aude">Delete</button>' : ''}</div></form>`);
|
||||
const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; };
|
||||
const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `<label for="aua">At</label><input type="time" id="aua" value="${esc(w.at || '08:00')}" required>` : `<label for="aum">${ty === 'mail.new' ? 'Sender or subject contains' : ty === 'podcast.new' ? 'Show name contains' : ty === 'pantry.low' ? 'Item name contains (blank: any item)' : 'Task title contains (blank: any task)'}</label><input type="text" id="aum" value="${esc(w.type === ty ? w.match || '' : '')}" autocomplete="off"${ty === 'mail.new' || ty === 'podcast.new' ? ' required' : ''}>`; label(); };
|
||||
detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label;
|
||||
const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; };
|
||||
sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } };
|
||||
if (r) {
|
||||
sh.querySelector('#autest').onclick = async () => { try { const o = await auApi('/test', auOpt({ id: r.id })); toast(o.ok ? 'Ran it once' : 'Did not run (the daily limit?)'); } catch (e) { toast(e.message); } };
|
||||
sh.querySelector('#aude').onclick = () => confirmSheet('Delete this automation?', r.name, 'Delete', async () => { try { draw((await auApi('/delete', auOpt({ id: r.id }))).rules); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
};
|
||||
auApi('/').then(r => draw(r.rules)).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'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]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
|
||||
else para += `<span class="vs" data-v="${b[1]}" tabindex="0" role="button">${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();
|
||||
if (args[0] === 'today' && typeof bbToday === 'function') return bbToday();
|
||||
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="bbtd" aria-label="Devotion: verse for today, reading plan, prayer list" title="Devotion">${icon('heart')}</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('keydown', e => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('.vs')) { e.preventDefault(); e.target.click(); } }); // (a verse is picked with the keyboard too)
|
||||
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'; }; $('#bbtd').onclick = () => { location.hash = '#/bible/today'; }; $('#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><span class="grow"></span>${list.length ? `<button class="btn ghost" id="bbexp">Download</button>` : ''}</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>`;
|
||||
const bx = $('#bbexp'); if (bx) bx.onclick = () => downloadText('Bible highlights and notes.md', '# Bible highlights and notes\n\n' + list.map(m => `**${bbRef(m.b, m.c, m.v)}**${m.type === 'note' ? ' (note)' : ''}\n${m.text ? '> ' + String(m.text).replace(/\n+/g, ' ') + '\n' : ''}${m.note ? '\n' + m.note + '\n' : ''}`).join('\n') + '\nBerean Standard Bible (BSB), public domain.\n');
|
||||
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;
|
||||
}
|
||||
@@ -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
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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), goal = mine.find(t => t.id === p.goal), got = mine.filter(t => p.earned.includes(t.id)); return `<div style="margin-top:8px"><div style="display:flex;justify-content:space-between"><b>${esc(p.name)}</b><small class="sub">${p.points} pts · ${p.count} done</small></div><div class="bar2" style="position:relative">${mine.map(t => `<span style="position:absolute;top:0;left:${Math.round(t.points / top * 100)}%;width:2px;height:100%;background:var(--text);opacity:.5"></span>`).join('')}<i style="width:${Math.round(p.points / top * 100)}%"></i></div><small>${got.length ? '🎁 ' + esc(got.map(t => t.title).join(', ')) : ''}${goal ? (got.length ? ' · ' : '') + (p.points >= goal.points ? '🎯 ' + esc(goal.title) + ' reached!' : '🎯 ' + (goal.points - p.points) + ' more for ' + esc(goal.title)) : next ? (got.length ? ' · ' : '') + (next.points - p.points) + ' more for ' + esc(next.title) : ''}</small></div>`; }).join('');
|
||||
}
|
||||
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); }
|
||||
@@ -0,0 +1,50 @@
|
||||
'use strict';
|
||||
// ---- Boot
|
||||
(async () => {
|
||||
try { cfg = { ...cfg, ...(await (await fetch('/api/config')).json()) }; } catch {}
|
||||
const loadedBuild = cfg.build;
|
||||
// When a new version is deployed, an open page keeps running the old one. Tell the person, and offer a reload.
|
||||
const checkForUpdate = async () => {
|
||||
try {
|
||||
const c = await (await fetch('/api/config', { cache: 'no-store' })).json();
|
||||
if (c.build && loadedBuild && c.build !== loadedBuild && !document.getElementById('upd')) {
|
||||
const b = document.createElement('div'); b.id = 'upd';
|
||||
b.innerHTML = 'A new version of NE Hub is available. <button class="btn" id="updgo">Reload</button>';
|
||||
document.body.appendChild(b); document.getElementById('updgo').onclick = () => location.reload();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkForUpdate(); });
|
||||
setInterval(checkForUpdate, 10 * 60e3);
|
||||
await loadMe();
|
||||
await loadSettings();
|
||||
if (typeof themeStart === 'function') themeStart();
|
||||
sgStart(); if (typeof chStart === 'function') chStart(); if (typeof kidStart === 'function') kidStart(); if (typeof devStart === 'function') devStart(); if (typeof simpleStart === 'function') simpleStart();
|
||||
addEventListener('hashchange', route);
|
||||
restorePlayer();
|
||||
route();
|
||||
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(() => {});
|
||||
// tell the service worker who is signed in, so what it saves for offline use is kept for that person only
|
||||
const who = () => navigator.serviceWorker.ready.then(r => (r.active || navigator.serviceWorker.controller || {}).postMessage && (r.active || navigator.serviceWorker.controller).postMessage({ type: 'who', id: ME ? ME.id : '' })).catch(() => {});
|
||||
who(); navigator.serviceWorker.addEventListener('controllerchange', who);
|
||||
}
|
||||
// offline: say so once, and that what is on screen is the last saved copy
|
||||
{ const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); if (typeof offqNote === 'function') offqNote(); };
|
||||
addEventListener('offline', note); addEventListener('online', note); if (!navigator.onLine) note(); }
|
||||
})();
|
||||
|
||||
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
|
||||
addEventListener('keydown', e => {
|
||||
const tag = e.target.tagName || '', k = e.key;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || !cur() || document.body.classList.contains('onvideo') || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
|
||||
if (k === ' ') { e.preventDefault(); toggle(); }
|
||||
else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
|
||||
else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Calendar week and day views: a time grid with all-day strip, overlapping events side by side and a "now" line.
|
||||
// Uses the calendar globals from app.js (calState, loadRange, evStore, ymd, fmtTime, esc, icon, newEvent, viewCalendar).
|
||||
const HOUR_PX = 48;
|
||||
|
||||
/** Lay out one day's timed events in columns so overlapping ones sit side by side. */
|
||||
function layoutDay(events) {
|
||||
const evs = events.map(e => ({ e, s: e.sm, en: Math.max(e.em, e.sm + 20) })).sort((a, b) => a.s - b.s || b.en - a.en);
|
||||
const out = []; let cluster = [], clusterEnd = -1;
|
||||
const flush = () => { const cols = []; for (const it of cluster) { let c = cols.findIndex(end => end <= it.s); if (c < 0) { c = cols.length; cols.push(0); } cols[c] = it.en; it.col = c; } cluster.forEach(it => { it.cols = cols.length; out.push(it); }); cluster = []; };
|
||||
for (const it of evs) { if (cluster.length && it.s >= clusterEnd) { flush(); clusterEnd = -1; } cluster.push(it); clusterEnd = Math.max(clusterEnd, it.en); }
|
||||
if (cluster.length) flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
async function renderTimeGrid(nDays) {
|
||||
const sel = new Date(calState.sel + 'T00:00:00'), start = new Date(sel);
|
||||
if (nDays === 7) start.setDate(start.getDate() - start.getDay());
|
||||
const { byDay, errors } = await loadRange(start, nDays);
|
||||
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
|
||||
evStore.length = 0;
|
||||
const today = ymd(new Date()), days = [...Array(nDays)].map((_, i) => { const d = new Date(start); d.setDate(d.getDate() + i); return d; });
|
||||
const title = nDays === 1 ? sel.toLocaleDateString([], { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
||||
: days[0].toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' – ' + days[6].toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
const allDay = days.map(d => (byDay.get(ymd(d)) || []).filter(e => e.allDay));
|
||||
const cols = days.map(d => {
|
||||
const k = ymd(d), t0 = new Date(k + 'T00:00:00').getTime(), t1 = t0 + 864e5;
|
||||
const timed = (byDay.get(k) || []).filter(e => !e.allDay).map(e => ({ ...e, sm: (Math.max(e.start, t0) - t0) / 6e4, em: (Math.min(e.end, t1) - t0) / 6e4, orig: e }));
|
||||
return layoutDay(timed).map(({ e, s, en, col, cols: n }) => {
|
||||
const i = evStore.push(e.orig) - 1, h = Math.max(18, (en - s) / 60 * HOUR_PX - 2);
|
||||
return `<button class="tgev" data-ei="${i}" style="top:${s / 60 * HOUR_PX}px;height:${h}px;left:calc(${col * 100 / n}% + 1px);width:calc(${100 / n}% - 2px);--c:${esc(e.color)}" aria-label="${esc(e.title)}, ${fmtTime(e.start)} to ${fmtTime(e.end)}">
|
||||
<b>${esc(e.title)}</b>${h > 30 ? `<small>${fmtTime(e.start)}</small>` : ''}</button>`;
|
||||
}).join('');
|
||||
});
|
||||
const hours = [...Array(24)].map((_, h) => `<div class="tgh" style="height:${HOUR_PX}px"><span>${h === 0 ? '' : new Date(2000, 0, 1, h).toLocaleTimeString([], { hour: 'numeric' })}</span></div>`).join('');
|
||||
const now = new Date(), nowTop = (now.getHours() * 60 + now.getMinutes()) / 60 * HOUR_PX;
|
||||
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="tp" aria-label="Previous ${nDays === 1 ? 'day' : 'week'}">${icon('back')}</button>
|
||||
<h2 style="margin:0;flex:1;text-align:center;font-size:16px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(title)}</h2>
|
||||
<button class="ibtn" id="tn" aria-label="Next ${nDays === 1 ? 'day' : 'week'}" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tt" style="min-height:36px">Today</button></div>
|
||||
<div class="tgwrap"><div class="tg" style="min-width:${nDays === 1 ? 0 : 560}px">
|
||||
<div class="tgtop"><div class="tghead"><span class="tgg"></span>${days.map(d => `<button class="tgd${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" aria-label="${esc(d.toDateString())}"><small>${d.toLocaleDateString([], { weekday: 'short' })}</small><b>${d.getDate()}</b></button>`).join('')}</div>
|
||||
${allDay.some(a => a.length) ? `<div class="tgad"><span class="tgg">all day</span>${allDay.map(a => `<div>${a.map(e => { const i = evStore.push(e) - 1; return `<button class="tgchip" data-ei="${i}" style="--c:${esc(e.color)}">${e.task ? '☐ ' : ''}${esc(e.title)}</button>`; }).join('')}</div>`).join('')}</div>` : ''}</div>
|
||||
<div class="tgbody" id="tgbody"><div class="tgg">${hours}</div>${days.map((d, i) => `<div class="tgcol${ymd(d) === today ? ' today' : ''}" data-day="${ymd(d)}" style="height:${24 * HOUR_PX}px">${cols[i]}${ymd(d) === today ? `<i class="tgnow" style="top:${nowTop}px"></i>` : ''}</div>`).join('')}</div>
|
||||
</div></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
|
||||
const body = $('#tgbody'); if (body) { const wrap = body.parentElement.parentElement; wrap.scrollTop = Math.max(0, ((days.some(d => ymd(d) === today) ? now.getHours() - 1 : 7) * HOUR_PX)); }
|
||||
const shift = n => { const d = new Date(sel); d.setDate(d.getDate() + n * (nDays === 1 ? 1 : 7)); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
|
||||
$('#tp').onclick = () => shift(-1); $('#tn').onclick = () => shift(1);
|
||||
$('#tt').onclick = () => { const d = new Date(); calState.sel = ymd(d); calState.y = d.getFullYear(); calState.m = d.getMonth(); renderTimeGrid(nDays); };
|
||||
$('#cbody').querySelectorAll('.tgd').forEach(b => b.onclick = () => { if (nDays === 7) { calState.sel = b.dataset.day; store.set('cal_view', 'day'); viewCalendar(); } });
|
||||
$('#cbody').querySelectorAll('.tgcol').forEach(c => c.onclick = e => { if (e.target === c) newEvent(c.dataset.day); });
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use strict';
|
||||
// ---- Calendar: agenda built from private ICS links (managed here; fetched and parsed by the server)
|
||||
const ymd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
async function loadRange(start, days, withTasks = true) {
|
||||
const end = new Date(start); end.setDate(end.getDate() + days);
|
||||
const last = new Date(end.getTime() - 1);
|
||||
const q = new URLSearchParams({ from: start.getTime(), to: end.getTime() - 1, fd: ymd(start), td: ymd(last) });
|
||||
const [{ events, errors }, due] = await Promise.all([jfetch('/api/cal/events?' + q),
|
||||
withTasks && allowed('tasks') ? jfetch(`/api/tasks/due?from=${ymd(start)}&to=${ymd(last)}`).catch(() => []) : []]);
|
||||
const byDay = new Map();
|
||||
const put = (k, e) => { if (!byDay.has(k)) byDay.set(k, []); byDay.get(k).push(e); };
|
||||
for (const e of events) {
|
||||
if (e.allDay) { // spans start..end-1 (end exclusive)
|
||||
for (let d = new Date(e.start + 'T00:00:00'); ymd(d) < e.end; d.setDate(d.getDate() + 1)) { const k = ymd(d); if (k >= ymd(start) && k <= ymd(last)) put(k, e); }
|
||||
} else put(ymd(new Date(e.start)), e);
|
||||
}
|
||||
for (const t of due) { // tasks with a due date show up on that day, after the all-day events
|
||||
if (!byDay.has(t.due)) byDay.set(t.due, []);
|
||||
byDay.get(t.due).push({ task: true, t, title: t.title, color: '#8a93a6', allDay: true, start: t.due, end: t.due, cal: t.listName || 'Tasks', editable: true });
|
||||
}
|
||||
for (const list of byDay.values()) list.sort((a, b) => (b.allDay - a.allDay) || (!!a.task - !!b.task) || ((a.allDay ? 0 : a.start) - (b.allDay ? 0 : b.start)));
|
||||
return { byDay, errors, start };
|
||||
}
|
||||
const loadAgenda = (days, withTasks = true) => { const start = new Date(); start.setHours(0, 0, 0, 0); return loadRange(start, days, withTasks); };
|
||||
const fmtTime = ms => new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
const evStore = []; // events currently on screen, so a tap can open the right one
|
||||
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.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; }
|
||||
const b = e.target.closest('[data-ei]'); if (b) openEvent(evStore[+b.dataset.ei]);
|
||||
});
|
||||
const dayLabel = k => { const d = new Date(k + 'T00:00:00'), t = ymd(new Date()); const tm = new Date(); tm.setDate(tm.getDate() + 1);
|
||||
return (k === t ? 'Today · ' : k === ymd(tm) ? 'Tomorrow · ' : '') + d.toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); };
|
||||
|
||||
async function renderAgenda(el, mode) { // Home card (today only)
|
||||
if (!el) return;
|
||||
try {
|
||||
const cals = await jfetch('/api/cal/list');
|
||||
const { byDay } = await loadAgenda(1, false); // (the Tasks card on Home already covers what is due)
|
||||
const list = byDay.get(ymd(new Date())) || [];
|
||||
if (cals.length < 2 && !list.length) return; // only the empty NE Hub calendar: say nothing
|
||||
if (!document.body.contains(el)) return;
|
||||
el.innerHTML = `<h2>Today</h2><div class="list">${list.length ? list.slice(0, 6).map(evRow).join('') : '<div class="row"><small>Nothing on the calendar today</small></div>'}</div>`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const now0 = new Date();
|
||||
const calState = { y: now0.getFullYear(), m: now0.getMonth(), sel: ymd(now0) };
|
||||
|
||||
async function viewCalendar() {
|
||||
const mode = store.get('cal_view', 'month');
|
||||
const v = $('#view');
|
||||
const views = `<div class="sgrp" role="tablist" aria-label="Calendar view">${[['month', 'Month'], ['week', 'Week'], ['day', 'Day'], ['agenda', 'Agenda']].map(([k, l]) => `<a href="#" role="tab" data-v="${k}" aria-selected="${k === mode}" class="${k === mode ? 'on' : ''}">${l}</a>`).join('')}</div>`
|
||||
+ (mode === 'agenda' ? `<select id="crange" class="psel" aria-label="How far ahead">${[[1, 'Today'], [7, '7 days'], [30, '30 days']].map(([n, l]) => `<option value="${n}" ${n === store.get('cal_range', 7) ? 'selected' : ''}>${l}</option>`).join('')}</select>` : '');
|
||||
v.innerHTML = `<div class="wrap">${plannerHead('calendar', `<button class="btn" id="ne">${icon('cal')}Add event</button>`, views)}
|
||||
<div id="cbody"><p class="empty">Loading…</p></div><div class="bar"><button class="btn ghost" id="mc">Manage calendars</button></div></div>`;
|
||||
$('#ne').onclick = () => newEvent(mode === 'agenda' ? ymd(new Date()) : calState.sel);
|
||||
if ($('#crange')) $('#crange').onchange = e => { store.set('cal_range', +e.target.value); viewCalendar(); };
|
||||
v.querySelectorAll('.sgrp a[data-v]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('cal_view', a.dataset.v); viewCalendar(); });
|
||||
$('#mc').onclick = manageCalendars;
|
||||
try {
|
||||
const cals = await jfetch('/api/cal/list');
|
||||
if (!cals.length) { $('#cbody').innerHTML = '<p class="empty">No calendars yet. Add a private calendar link to see your events here.</p><div class="bar" style="justify-content:center"><button class="btn" id="ac">Add a calendar</button></div>'; $('#ac').onclick = addCalendar; return; }
|
||||
if (mode === 'month') await renderMonth(); else if (mode === 'week') await renderTimeGrid(7); else if (mode === 'day') await renderTimeGrid(1); else await renderAgendaList();
|
||||
} catch (e) { const b = $('#cbody'); if (b) b.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
}
|
||||
|
||||
async function renderAgendaList() {
|
||||
const range = store.get('cal_range', 7);
|
||||
const { byDay, errors } = await loadAgenda(range);
|
||||
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
|
||||
const keys = [...byDay.keys()].sort();
|
||||
$('#cbody').innerHTML = (keys.map(k => `<h2>${esc(dayLabel(k))}</h2><div class="list">${byDay.get(k).map(evRow).join('')}</div>`).join('') || '<p class="empty">Nothing coming up</p>')
|
||||
+ errors.map(e => `<p class="err">${esc(e)}</p>`).join('');
|
||||
}
|
||||
|
||||
async function renderMonth() {
|
||||
const { y, m } = calState;
|
||||
const first = new Date(y, m, 1), gridStart = new Date(y, m, 1 - first.getDay()); // the Sunday on or before the 1st
|
||||
const { byDay, errors } = await loadRange(gridStart, 42);
|
||||
if (!location.hash.startsWith('#/calendar') || !$('#cbody')) return;
|
||||
const today = ymd(new Date());
|
||||
if (calState.sel < ymd(gridStart) || calState.sel > ymd(new Date(y, m, 1 - first.getDay() + 41))) calState.sel = ymd(first);
|
||||
const cells = [];
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(gridStart); d.setDate(d.getDate() + i);
|
||||
const k = ymd(d), evs = byDay.get(k) || [], other = d.getMonth() !== m;
|
||||
cells.push(`<button class="cday${other ? ' dim' : ''}${k === today ? ' today' : ''}${k === calState.sel ? ' sel' : ''}" data-d="${k}" aria-label="${esc(d.toDateString())}, ${evs.length} events">
|
||||
<span class="dn">${d.getDate()}</span>
|
||||
<span class="dots">${evs.slice(0, 4).map(e => `<i style="background:${esc(e.color)}"></i>`).join('')}</span>
|
||||
<span class="chips">${evs.slice(0, 3).map(e => `<em style="--c:${esc(e.color)}">${e.task ? '☐ ' : ''}${esc(e.title)}</em>`).join('')}${evs.length > 3 ? `<small>+${evs.length - 3} more</small>` : ''}</span></button>`);
|
||||
}
|
||||
$('#cbody').innerHTML = `<div class="mhdr"><button class="ibtn" id="pm" aria-label="Previous month">${icon('back')}</button>
|
||||
<h2 style="margin:0;flex:1;text-align:center;font-size:17px;text-transform:none;letter-spacing:0;color:var(--text)">${first.toLocaleDateString([], { month: 'long', year: 'numeric' })}</h2>
|
||||
<button class="ibtn" id="nm" aria-label="Next month" style="transform:scaleX(-1)">${icon('back')}</button><button class="btn ghost" id="tm" style="min-height:36px">Today</button></div>
|
||||
<div class="cgrid wk">${['S', 'M', 'T', 'W', 'T', 'F', 'S'].map(d => `<span>${d}</span>`).join('')}</div>
|
||||
<div class="cgrid">${cells.join('')}</div>
|
||||
<div id="dayev"></div>${errors.map(e => `<p class="err">${esc(e)}</p>`).join('')}`;
|
||||
const showDay = () => {
|
||||
const k = calState.sel, evs = byDay.get(k) || [];
|
||||
$('#dayev').innerHTML = `<h2>${esc(dayLabel(k))}</h2><div class="list">${evs.length ? evs.map(evRow).join('') : '<div class="row"><small>No events</small></div>'}</div>`;
|
||||
$('#cbody').querySelectorAll('.cday').forEach(c => c.classList.toggle('sel', c.dataset.d === k));
|
||||
};
|
||||
showDay();
|
||||
$('#cbody').querySelectorAll('.cday').forEach(c => c.onclick = () => { calState.sel = c.dataset.d; showDay(); });
|
||||
const go = (dm) => { const t = new Date(y, m + dm, 1); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
|
||||
$('#pm').onclick = () => go(-1); $('#nm').onclick = () => go(1);
|
||||
$('#tm').onclick = () => { const t = new Date(); calState.y = t.getFullYear(); calState.m = t.getMonth(); calState.sel = ymd(t); renderMonth(); };
|
||||
}
|
||||
|
||||
const deviceTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
const pad2 = n => String(n).padStart(2, '0');
|
||||
const localStr = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
|
||||
function openEvent(e) {
|
||||
if (!e) return;
|
||||
if (e.task) { // a task on the calendar: the normal task sheet, and the calendar refreshes when it closes
|
||||
taskSheet({ ...e.t, repeat: e.t.repeat || 'none' }, e.t.list, { name: e.cal, local: String(e.t.list).startsWith('local:') });
|
||||
const sh = document.getElementById('sheet'), mo = new MutationObserver(() => { if (!document.getElementById('sheet')) { mo.disconnect(); if (/^#\/calendar/.test(location.hash)) route(); } });
|
||||
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>`);
|
||||
const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); };
|
||||
if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `<label>Invite</label><div class="invlist">${ps.map(p => `<label class="chk"><input type="checkbox" data-inv="${esc(p.id)}"><i class="invdot" style="background:${esc(p.color)}"></i>${esc(p.name)}</label>`).join('')}</div><p class="sub" id="invnote" style="margin:4px 0 0">The event is added to their calendar and they are told.</p>`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {});
|
||||
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">${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;
|
||||
}
|
||||
|
||||
async function editEvent(e) {
|
||||
try {
|
||||
const q = new URLSearchParams({ cal: e.calId, tz: deviceTz() }); if (e.uid) q.set('uid', e.uid); if (e.href) q.set('href', e.href);
|
||||
const r = await jfetch('/api/cal/event?' + q);
|
||||
eventForm({ mode: 'edit', calId: e.calId, calName: e.cal, uid: e.uid, href: e.href || r.href, etag: r.etag || e.etag, fields: r.fields, series: e.series });
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
async function newEvent(day) {
|
||||
try {
|
||||
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: (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); }
|
||||
}
|
||||
|
||||
function eventForm(o) {
|
||||
const f = { ...o.fields }, editing = o.mode === 'edit';
|
||||
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)' : 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>
|
||||
<label for="fr">Repeat</label><select id="fr">${opts.map(([v, l]) => `<option value="${v}" ${v === f.repeat ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
||||
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
||||
${calPick}
|
||||
${editing ? '' : '<div id="finv"></div>'}
|
||||
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
||||
<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">${editing ? 'Save' : 'Add'}</button></div>
|
||||
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
||||
<div class="err" id="fe"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
const draw = () => { // date-only inputs for all-day, date+time otherwise
|
||||
const t = f.allDay ? 'date' : 'datetime-local', cut = v => f.allDay ? v.slice(0, 10) : v;
|
||||
el.querySelector('#fdt').innerHTML = `<label for="fs">Starts</label><input type="${t}" id="fs" value="${cut(f.start)}" required><label for="fe2">Ends</label><input type="${t}" id="fe2" value="${cut(f.end)}" required>
|
||||
${!f.allDay && f.tz !== deviceTz() ? `<p class="sub" style="margin:6px 0 0">Times are in ${esc(f.tz)}.</p>` : ''}`;
|
||||
const s = el.querySelector('#fs'), en = el.querySelector('#fe2');
|
||||
s.onchange = () => { // moving the start keeps the length
|
||||
const len = Date.parse(f.allDay ? f.end.slice(0, 10) + 'T00:00' : f.end) - Date.parse(f.allDay ? f.start.slice(0, 10) + 'T00:00' : f.start);
|
||||
f.start = s.value; if (s.value) { const ns = Date.parse(f.allDay ? s.value + 'T00:00' : s.value); if (!isNaN(ns) && len >= 0) { const d = new Date(ns + len); f.end = f.allDay ? ymd(d) : localStr(d); en.value = f.allDay ? ymd(d) : localStr(d); } }
|
||||
};
|
||||
en.onchange = () => { f.end = en.value; };
|
||||
};
|
||||
draw();
|
||||
el.querySelector('#fa').onchange = ev => {
|
||||
f.start = el.querySelector('#fs').value || f.start; f.end = el.querySelector('#fe2').value || f.end;
|
||||
f.allDay = ev.target.checked;
|
||||
if (f.allDay) { f.start = f.start.slice(0, 10); f.end = f.end.slice(0, 10); }
|
||||
else { f.start = f.start.slice(0, 10) + 'T09:00'; f.end = f.end.slice(0, 10) + 'T10:00'; }
|
||||
draw();
|
||||
};
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#ef').onsubmit = async ev => {
|
||||
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#fe').textContent = '';
|
||||
const fields = { title: el.querySelector('#ft').value.trim(), location: el.querySelector('#fl').value.trim(), notes: el.querySelector('#fn').value.trim(), allDay: f.allDay,
|
||||
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
|
||||
const cal = editing ? o.calId : el.querySelector('#fc').value;
|
||||
try {
|
||||
const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv);
|
||||
const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) });
|
||||
if (!editing) store.set('cal_default', cal);
|
||||
closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar();
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
||||
};
|
||||
const del = el.querySelector('#fd');
|
||||
if (del) del.onclick = () => confirmSheet('Delete event?', o.series ? f.title + ' and every repeat of it' : f.title, 'Delete', async () => {
|
||||
try { await jfetch('/api/cal/event/delete', { method: 'POST', body: JSON.stringify({ cal: o.calId, uid: o.uid, href: o.href, etag: o.etag }) }); toast('Event deleted'); viewCalendar(); }
|
||||
catch (err) { toast(err.message); }
|
||||
});
|
||||
}
|
||||
|
||||
function addCalendar() {
|
||||
const el = openSheet('Add a calendar link', '', `<form><label>Name</label><input type="text" id="cn" placeholder="Personal" autocomplete="off">
|
||||
<label>Private calendar link (ICS)</label><input type="url" id="cu2" placeholder="https://… or webcal://…" autocomplete="off" autocapitalize="off" required>
|
||||
<p class="sub" style="margin:10px 0 0">Google: Settings → your calendar → "Secret address in iCal format". Outlook.com / M365: Settings → Calendar → Shared calendars → Publish → copy the ICS link. These are read-only: you see the events here but change them in the original app. Anyone with the link can read that calendar, so keep it private.</p>
|
||||
<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">Add</button></div><div class="err" id="ce"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('form').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
|
||||
try { await jfetch('/api/cal/add', { method: 'POST', body: JSON.stringify({ name: el.querySelector('#cn').value.trim() || 'Calendar', url: el.querySelector('#cu2').value.trim() }) }); closeSheet(); toast('Calendar added'); viewCalendar(); }
|
||||
catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
||||
};
|
||||
}
|
||||
|
||||
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), 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 => `<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>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Connect iCloud</button></div><div class="err" id="ice"></div></form>`}
|
||||
<h2 style="margin-top:14px">See NE Hub events in other apps</h2>
|
||||
<p class="sub">Subscribe to this private link from Outlook, Google or Apple Calendar. They show your NE Hub events (read-only there).</p>
|
||||
<input type="text" readonly id="fu" value="${esc(feed.url)}" onfocus="this.select()">
|
||||
<div class="bar"><button class="btn ghost" id="fc2" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" id="fr2" style="flex:1;justify-content:center">Make a new link</button></div>
|
||||
<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(); };
|
||||
const icf = el.querySelector('#icf');
|
||||
if (icf) icf.onsubmit = async e => {
|
||||
e.preventDefault(); const btn = icf.querySelector('.btn'); btn.disabled = true; btn.textContent = 'Connecting…'; el.querySelector('#ice').textContent = '';
|
||||
try { await jfetch('/api/cal/icloud/connect', { method: 'POST', body: JSON.stringify({ appleId: el.querySelector('#iu').value, password: el.querySelector('#ip').value }) }); toast('iCloud connected'); closeSheet(); viewCalendar(); }
|
||||
catch (err) { btn.disabled = false; btn.textContent = 'Connect iCloud'; el.querySelector('#ice').textContent = err.message; }
|
||||
};
|
||||
el.querySelector('#fc2').onclick = async () => { try { await navigator.clipboard.writeText(feed.url); toast('Link copied'); } catch { el.querySelector('#fu').select(); toast('Press copy on the selected link'); } };
|
||||
el.querySelector('#fr2').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { await jfetch('/api/cal/feed', { method: 'POST', body: '{}' }); manageCalendars(); });
|
||||
}
|
||||
@@ -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')); }
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
'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); };
|
||||
}
|
||||
// (iOS keeps a suspended app's connection open for minutes, and the server skips the phone push for anyone it thinks is looking: close the stream whenever the app is hidden)
|
||||
const chHide = () => { if (ch.es) { ch.es.close(); ch.es = null; } };
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) chHide(); else chStart(); });
|
||||
window.addEventListener('pagehide', chHide);
|
||||
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 || (f.msg.share ? f.msg.share.title : ''))}</small></span><span class="sgside"><small>${sgTime(f.msg.ts)}</small></span></a>`).join('')}` : '')
|
||||
|| '';
|
||||
if (!box.innerHTML) box.innerHTML = `<p class="empty">${q ? 'Nothing matches' : 'No chats yet. Tap New to start one.'}</p>`;
|
||||
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>`;
|
||||
}
|
||||
// ---- share cards: a song, film, book... sent from its own screen. Tapping opens it, unless that part of the hub is not turned on for this person.
|
||||
const CH_SHARE = { song: ['🎵', 'Song'], album: ['💿', 'Album'], artist: ['🎤', 'Artist'], playlist: ['📃', 'Playlist'], movie: ['🎬', 'Movie'], show: ['📺', 'TV show'], episode: ['📺', 'Episode'], book: ['📖', 'Book'], audiobook: ['🎧', 'Audiobook'], podcast: ['🎙️', 'Podcast'], podcastep: ['🎙️', 'Podcast episode'], radio: ['📻', 'Radio'] };
|
||||
/** The tab a deep link lives in decides whether this person can open it (a child account only gets the tabs a parent allowed). */
|
||||
const chShareOk = link => { const tab = String(link || '').split('/')[1]; return !!tab && !!routesOf()[tab] && allowed(tab); };
|
||||
function chShareCard(m) {
|
||||
const s = m.share, k = CH_SHARE[s.kind] || ['🔗', 'Shared'], ok = chShareOk(s.link), art = /^\/(plex|api)\//.test(s.art || '') ? s.art : '';
|
||||
const body = `${art ? `<img class="sgshare-art" alt="" loading="lazy" src="${esc(art)}" onerror="this.replaceWith(Object.assign(document.createElement('i'),{className:'sgshare-art',textContent:'${k[0]}'}))">` : `<i class="sgshare-art">${k[0]}</i>`}
|
||||
<span class="sgshare-t"><small>${k[0]} ${esc(k[1])}</small><b>${esc(s.title)}</b>${s.sub ? `<em>${esc(s.sub)}</em>` : ''}${ok ? '' : '<small class="sgshare-off">Not available for you</small>'}</span>`;
|
||||
return ok ? `<a class="sgshare" href="${esc(s.link)}">${body}<span class="sgshare-go">›</span></a>` : `<div class="sgshare off" role="note">${body}</div>`;
|
||||
}
|
||||
function chBubble(m, c, prev) {
|
||||
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.share && !m.deleted ? chShareCard(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 || (ch.reply.share && ch.reply.share.title) || '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.share && last.share.title) || (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 || (m.share && m.share.title) || '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); } });
|
||||
}
|
||||
/** From a song, film, book...: pick a chat, add a note if you like, and a share card goes in. card = { kind, title, sub, art, link } (art and link are paths inside the hub). */
|
||||
async function shareMedia(card) {
|
||||
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 k = CH_SHARE[card.kind] || ['🔗', 'Shared'];
|
||||
const el = openSheet('Share to Chat', k[0] + ' ' + card.title, `<label for="chsn">Add a note (optional)</label><input type="text" id="chsn" maxlength="300" autocomplete="off" placeholder="You will love this">
|
||||
<div class="list" style="max-height:44dvh;overflow:auto;margin-top:10px">${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>${icon('send')}</button>`).join('') || '<p class="empty">Start a chat first (Chat, then New).</p>'}</div>`);
|
||||
el.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const text = el.querySelector('#chsn').value, to = b.dataset.t; closeSheet(); try { await chPost('/send', { conv: to, text, share: { kind: card.kind, title: card.title, sub: card.sub || '', art: /^\/(plex|api)\/[^\s"'<>\\]*$/.test(card.art || '') && !card.art.includes('..') ? card.art : '', link: card.link } }); toast('Shared', { label: 'Open', run: () => { location.hash = '#/chat/' + encodeURIComponent(to); } }); } 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.offsetTop + window.visualViewport.height : window.innerHeight; // (page coordinates: the visible area can be panned down while the keyboard is up)
|
||||
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(document.body.classList.contains('kb') ? 150 : 260, Math.floor(bottom - top - 10)) + 'px';
|
||||
}
|
||||
window.addEventListener('kbfit', () => { chFit(); if (!chMounted() || !chq('.sgm')) return; const v = document.getElementById('view'); if (v) v.scrollTop = 0; const l = chq('#chmsgs'); if (l && document.activeElement && document.activeElement.id === 'chtext') l.scrollTop = l.scrollHeight; }); // (the keyboard came or went: refit, and keep the newest message in sight)
|
||||
window.addEventListener('resize', chFit); if (window.visualViewport) window.visualViewport.addEventListener('resize', chFit);
|
||||
setInterval(chFit, 700);
|
||||
@@ -0,0 +1,104 @@
|
||||
'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), goal = tiers.find(t => t.id === p.goal), canPick = tiers.length && (p.id === cr.data.me.id || admin);
|
||||
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${t.id === p.goal ? ' goal' : ''}" 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>
|
||||
${goal ? `<small class="sub" style="display:block;margin:0 0 4px 52px">🎯 ${p.points >= goal.points ? esc(goal.title) + ': reached! 🎉' : (goal.points - p.points) + ' more for ' + esc(goal.title)}</small>` : 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>' : ''}
|
||||
${canPick ? `<button type="button" class="btn ghost crgoalbtn" data-goal="${esc(p.id)}">${goal ? 'Change goal' : 'Pick a goal'}</button>` : ''}
|
||||
${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>`;
|
||||
}
|
||||
/** A child's own "aiming for" card at the top of Chores: the reward they chose and how far they have to go this week. */
|
||||
function crGoalCard() {
|
||||
const me = cr.data.me.id, p = cr.week.people.find(x => x.id === me); if (!p || !p.tiers.length) return '';
|
||||
const t = cr.week.tiers.find(x => x.id === p.goal), now = cr.week.from === crMonday(crYmd(new Date()));
|
||||
return `<div class="crgoal"><span class="crgi">🎯</span><span class="grow"><small>Aiming for</small><b>${t ? esc(t.title) : 'Pick a reward to aim for'}</b>${t && now ? `<small>${p.points >= t.points ? 'You did it! 🎉' : (t.points - p.points) + ' more points this week'}</small>` : ''}</span><button type="button" class="btn ${t ? 'ghost' : ''}" id="crgoal">${t ? 'Change' : 'Pick'}</button></div>`;
|
||||
}
|
||||
function crPickGoal(uid) {
|
||||
const p = cr.week.people.find(x => x.id === uid); if (!p) return; const tiers = cr.week.tiers.filter(t => p.tiers.includes(t.id));
|
||||
const el = openSheet('What are you aiming for?', uid === cr.data.me.id ? 'Pick a reward for this week. Chores shows how far you have to go. You pick again each Monday.' : 'Choose a goal for ' + p.name, `<div class="list">${tiers.map(t => `<button class="row" data-g="${esc(t.id)}"><span class="ic" style="background:var(--accent)">🎁</span><span class="grow"><b>${esc(t.title)}</b><small>${t.points} points in a week${t.minutes ? ' · +' + t.minutes + ' min screen time' : ''}</small></span>${p.goal === t.id ? icon('check') : ''}</button>`).join('')}</div>${p.goal ? '<div class="bar"><button class="btn ghost" id="crgno" style="width:100%;justify-content:center">No goal for now</button></div>' : ''}`);
|
||||
const set = async r => { try { await crPost('/goal', { reward: r, user: uid, date: crYmd(new Date()) }); if (typeof inboxSettled === 'function') inboxSettled([{ tag: 'goal:' + crMonday(crYmd(new Date())) }]); closeSheet(); viewChores(); } catch (e) { toast(e.message); } };
|
||||
el.querySelectorAll('[data-g]').forEach(b => b.onclick = () => set(b.dataset.g)); const n = el.querySelector('#crgno'); if (n) n.onclick = () => set('');
|
||||
}
|
||||
/** 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>
|
||||
${crGoalCard()}
|
||||
<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 ($('#crgoal')) $('#crgoal').onclick = () => crPickGoal(cr.data.me.id); v.querySelectorAll('.crgoalbtn').forEach(b => b.onclick = () => crPickGoal(b.dataset.goal));
|
||||
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; } };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
'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('anniversary', esc(ctBday(c.anniversary)))}${line('gift ideas', esc(c.gifts || '').replace(/\n/g, '<br>'))}${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: '', anniversary: '', gifts: '', 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="ctan">Anniversary</label><input type="date" id="ctan" value="${esc(x.anniversary && !x.anniversary.startsWith('--') ? x.anniversary : '')}"><label for="ctg">Gift ideas</label><textarea id="ctg" rows="3" placeholder="One idea per line">${esc(x.gifts || '')}</textarea><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 keepAnn = x.anniversary && x.anniversary.startsWith('--') && !el.querySelector('#ctan').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, anniversary: keepAnn ? x.anniversary : el.querySelector('#ctan').value, gifts: el.querySelector('#ctg').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 {}
|
||||
}
|
||||
@@ -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); } });
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
// ---- Devotion (inside Bible): a verse for today, a read-through-the-Bible-in-a-year plan with a check-in each day, and a prayer list the whole family shares.
|
||||
const DV_VERSES = ['JHN 3:16', 'PSA 23:1-3', 'PRO 3:5-6', 'ROM 8:28', 'PHP 4:6-7', 'ISA 41:10', 'JER 29:11', 'MAT 6:33', 'PSA 46:1', 'JOS 1:9', 'ROM 12:2', 'GAL 5:22-23', '1CO 13:4-7', 'EPH 2:8-9', 'PSA 119:105', 'MAT 11:28-30', 'COL 3:23', 'PSA 27:1', '2TI 1:7', 'HEB 11:1', 'PSA 121:1-2', 'MIC 6:8', 'JHN 14:27', 'ROM 5:8', '1JN 4:19', 'PSA 37:4-5', 'LAM 3:22-23', 'ISA 40:31', 'MAT 5:14-16', 'JAS 1:5', '1PE 5:7', 'PSA 34:8', 'DEU 31:6', 'PRO 22:6', 'EPH 6:1-3', '1TH 5:16-18', 'PSA 139:13-14', 'JHN 15:12-13', 'ROM 15:13', 'NUM 6:24-26', 'PSA 103:1-2', 'HEB 13:5-6', 'PRO 16:3', '2CO 12:9', 'JHN 1:1-5', 'PSA 100:4-5', 'MAT 28:19-20', 'ZEP 3:17', 'PSA 51:10', 'COL 3:12-14'];
|
||||
const dvDay = (d = new Date()) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const dvDiff = (a, b) => Math.round((new Date(b + 'T12:00:00') - new Date(a + 'T12:00:00')) / 864e5);
|
||||
/** The text of verses v..ve in a chapter, footnote markers left out. */
|
||||
function dvText(book, c, v, ve) {
|
||||
const out = []; for (const b of book.chapters[c - 1]) if (b[0] === 'v' && b[1] >= v && b[1] <= (ve || v)) out.push(b[2].map(p => typeof p === 'string' ? p : Array.isArray(p) ? ' ' + p[1] + ' ' : ' ').join('').replace(/\s+/g, ' ').replace(/\s+([,.;:!?])/g, '$1').trim());
|
||||
return out.join(' ');
|
||||
}
|
||||
function dvVerseOfDay() {
|
||||
const now = new Date(), n = Math.floor((now - new Date(now.getFullYear(), 0, 0)) / 864e5), m = /^(\w{3}) (\d+):(\d+)(?:-(\d+))?$/.exec(DV_VERSES[n % DV_VERSES.length]), book = bbBook(m[1]);
|
||||
return book ? { ref: bbRef(m[1], +m[2], +m[3], m[4] ? +m[4] : 0), text: dvText(book, +m[2], +m[3], m[4] ? +m[4] : 0), link: `#/bible/${m[1]}/${m[2]}/${m[3]}` } : null;
|
||||
}
|
||||
/** Every chapter of the Bible in order, split into 365 daily readings. */
|
||||
function dvReadings(day) {
|
||||
const all = []; for (const b of bb.books) for (let c = 1; c <= b.chapters.length; c++) all.push([b.id, c]);
|
||||
const from = Math.floor(day * all.length / 365), to = Math.floor((day + 1) * all.length / 365); return all.slice(from, to);
|
||||
}
|
||||
async function bbToday() {
|
||||
const v = $('#view'); let plan = bbSt.plan || null, prayers = [], showAnswered = false;
|
||||
try { prayers = await bbApi('/prayers'); } catch {}
|
||||
const draw = () => {
|
||||
const vod = dvVerseOfDay(), today = dvDay(), idx = plan ? dvDiff(plan.start, today) : -1, day = Math.min(364, Math.max(0, idx)), done = new Set(plan ? plan.done : []);
|
||||
let streak = 0; if (plan) for (let d = Math.min(idx, 364) - (done.has(Math.min(idx, 364)) ? 0 : 1); d >= 0 && done.has(d); d--) streak++;
|
||||
const open = prayers.filter(p => !p.answered), answered = prayers.filter(p => p.answered);
|
||||
const prayRow = p => `<div class="row"><span class="grow"><span style="display:block;white-space:normal">${esc(p.text)}</span><small>${esc(p.byName)} · ${new Date(p.answered ? p.answeredAt : p.at).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${p.answered ? ' · answered' : ''}</small></span><button class="ibtn" data-pa="${p.id}" data-on="${p.answered ? '' : '1'}" aria-label="${p.answered ? 'Mark as still praying' : 'Mark as answered'}" title="${p.answered ? 'Still praying' : 'Answered'}">${icon('check')}</button>${p.mine || (typeof ME !== 'undefined' && ME && ME.role === 'admin') ? `<button class="ibtn" data-pd="${p.id}" aria-label="Remove" title="Remove">${icon('trash')}</button>` : ''}</div>`;
|
||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Devotion</h1><a class="btn ghost" href="#/bible">${icon('bible')}Bible</a></div>
|
||||
${vod ? `<h2>Verse for today</h2><a class="card dvverse" href="${vod.link}"><p>“${esc(vod.text)}”</p><small>${esc(vod.ref)} · BSB</small></a>` : ''}
|
||||
<h2>Reading plan</h2>${plan ? `<div class="card" style="max-width:none"><b>Day ${day + 1} of 365</b><span class="sub"> · ${done.size} day${done.size === 1 ? '' : 's'} done${streak > 1 ? ` · ${streak} in a row` : ''}</span>
|
||||
<div class="dvread">${idx < 0 ? `<p class="sub">Your plan starts ${esc(plan.start)}.</p>` : dvReadings(day).map(([b, c]) => `<a class="btn ghost" href="#/bible/${b}/${c}">${esc(bbBook(b).name)} ${c}</a>`).join('')}</div>
|
||||
${idx >= 0 ? `<div class="bar"><button class="btn${done.has(day) ? ' ghost' : ''}" id="dvck">${icon('check')}${done.has(day) ? 'Done today' : 'Mark today as read'}</button><button class="btn ghost" id="dvstop">Stop plan</button></div>` : ''}</div>`
|
||||
: `<div class="card" style="max-width:none"><p class="sub" style="margin-top:0">Read the whole Bible in a year, a few chapters a day, and check in each day.</p><div class="bar"><button class="btn" id="dvstart">Start today</button></div></div>`}
|
||||
<h2>Family prayer list</h2><form class="bar" id="dvpf" style="flex-wrap:nowrap"><input type="text" id="dvpt" placeholder="Add a prayer request" maxlength="500" autocomplete="off" style="flex:1"><button class="btn">Add</button></form>
|
||||
<div class="list">${open.map(prayRow).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what the family is praying about.</small></span></div>'}</div>
|
||||
${answered.length ? `<div class="bar"><button class="btn ghost" id="dvans">${showAnswered ? 'Hide' : 'Show'} answered (${answered.length})</button></div>${showAnswered ? `<div class="list">${answered.map(prayRow).join('')}</div>` : ''}` : ''}</div>`;
|
||||
const save = async (fn) => { try { await fn(); draw(); } catch (e) { toast(e.message); } };
|
||||
const st = $('#dvstart'); if (st) st.onclick = () => save(async () => { plan = (await bbApi('/plan', bbOpt('POST', { start: dvDay() }))).plan; bbSt.plan = plan; });
|
||||
const sp = $('#dvstop'); if (sp) sp.onclick = () => confirmSheet('Stop the reading plan?', 'Your check-ins are cleared.', 'Stop plan', () => save(async () => { await bbApi('/plan', bbOpt('POST', { stop: true })); plan = bbSt.plan = null; }));
|
||||
const ck = $('#dvck'); if (ck) ck.onclick = () => save(async () => { plan = (await bbApi('/plan/check', bbOpt('POST', { day, on: !done.has(day) }))).plan; bbSt.plan = plan; });
|
||||
$('#dvpf').onsubmit = e => { e.preventDefault(); const t = $('#dvpt').value.trim(); if (t) save(async () => { prayers = await bbApi('/prayers', bbOpt('POST', { text: t })); }); };
|
||||
v.querySelectorAll('[data-pa]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/answer', bbOpt('POST', { id: b.dataset.pa, on: !!b.dataset.on })); }));
|
||||
v.querySelectorAll('[data-pd]').forEach(b => b.onclick = () => save(async () => { prayers = await bbApi('/prayers/delete', bbOpt('POST', { id: b.dataset.pd })); }));
|
||||
const an = $('#dvans'); if (an) an.onclick = () => { showAnswered = !showAnswered; draw(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
'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);
|
||||
}
|
||||
// ---- Keep the page fresh while it stays open (a tablet on the counter): every minute, and when the app comes back to the front. It waits while a sheet is open or someone is typing an item.
|
||||
const fmMounted = () => !!document.querySelector('#view .fmwrap');
|
||||
function fmRefresh() {
|
||||
if (!fmMounted() || document.hidden) return;
|
||||
const sheet = document.getElementById('sheet'); if (sheet && sheet.classList.contains('on')) return;
|
||||
const a = document.activeElement, typing = document.getElementById('fmshi'); if ((a && a.closest && a.closest('.fmwrap') && /^(INPUT|TEXTAREA)$/.test(a.tagName) && a.type !== 'checkbox') || (typing && typing.value)) return;
|
||||
const h = new Date().getHours(), head = document.querySelector('.fmwrap .homehead');
|
||||
if (head) { const t = head.querySelector('h1'), d = head.querySelector('.sub'), greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening'; if (t) t.textContent = greet + (ME ? ', ' + ME.name.split(' ')[0] : ''); if (d) d.textContent = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }); }
|
||||
if (typeof chAnnounceCard === 'function') chAnnounceCard($('#annc'));
|
||||
if (typeof inboxCount === 'function') inboxCount();
|
||||
for (const [id, load] of [['fmcal', fmEvents], ['fmch', fmChores], ['fmsh', fmShopping], ['fmnt', fmNotes], ['fmct', fmChat], ['fmtk', fmTasks]]) if (document.getElementById(id)) load().catch(() => {}); // (a failed refresh keeps what is already shown)
|
||||
}
|
||||
setInterval(fmRefresh, 60000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) fmRefresh(); });
|
||||
|
||||
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>';
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use strict';
|
||||
// ---- File actions
|
||||
const PREVIEW = { img: 'jpg jpeg png gif webp svg bmp heic avif', pdf: 'pdf', video: 'mp4 mov m4v webm', audio: 'mp3 m4a wav flac ogg aac opus', text: 'txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore' };
|
||||
const previewKind = n => Object.keys(PREVIEW).find(k => PREVIEW[k].split(' ').includes(ext(n)));
|
||||
const FORM = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
const encP = p => p.split('/').map(encodeURIComponent).join('/');
|
||||
const fileUrl = async (repo, path) => { const u = new URL(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), cfg.seafile); return '/sf' + u.pathname; };
|
||||
|
||||
// Recently opened files, shown on Home (kept in this browser only).
|
||||
function addRecent(repo, path, name) {
|
||||
const r = store.get('recents', []).filter(x => !(x.repo === repo && x.path === path));
|
||||
r.unshift({ repo, path, name, t: Date.now() }); store.set('recents', r.slice(0, 12));
|
||||
}
|
||||
|
||||
// Default tap on a file: edit documents, preview media, otherwise show the actions.
|
||||
function openFile(repo, path, name, dir) {
|
||||
if (cfg.editor && EDIT_EXT.has(ext(name)) && ext(name) !== 'txt') { addRecent(repo, path, name); location.hash = '#/edit/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); }
|
||||
else if (previewKind(name)) previewFile(repo, path, name);
|
||||
else fileSheet(repo, path, name, dir);
|
||||
}
|
||||
|
||||
async function previewFile(repo, path, name) {
|
||||
addRecent(repo, path, name);
|
||||
if (previewKind(name) === 'text') return textPreview(repo, path, name).catch(e => toast(e.message));
|
||||
try {
|
||||
const url = (await fileUrl(repo, path)) + '?hubinline=1', kind = previewKind(name);
|
||||
const media = kind === 'img' ? `<img src="${url}" alt="${esc(name)}" style="max-width:100%;max-height:70dvh;border-radius:12px;display:block;margin:8px auto">`
|
||||
: kind === 'pdf' ? `<iframe src="${url}" title="${esc(name)}" style="width:100%;height:70dvh;border:0;border-radius:12px;background:#fff"></iframe>`
|
||||
: kind === 'video' ? `<video src="${url}" controls playsinline style="width:100%;max-height:70dvh;border-radius:12px;background:#000"></video>`
|
||||
: `<audio src="${url}" controls style="width:100%;margin:12px 0"></audio>`;
|
||||
const el = openSheet(name, '', `${media}<div class="bar"><a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Open</a><button class="btn ghost" id="sx">Close</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
const shareLink = (repo, path, isDir) => shareSheet(repo, path, isDir);
|
||||
|
||||
function fileSheet(repo, path, name, dir) {
|
||||
const enc = encodeURIComponent, acts = [], ro = libPerm[repo] === 'r', it = { name, type: 'file' };
|
||||
if (previewKind(name)) acts.push({ label: 'Preview', icon: 'doc', run: () => previewFile(repo, path, name) });
|
||||
if (cfg.editor && EDIT_EXT.has(ext(name)) && !ro) acts.push({ label: 'Edit here', icon: 'edit', run: () => { addRecent(repo, path, name); location.hash = '#/edit/' + enc(repo) + path.split('/').filter(Boolean).map(s => '/' + enc(s)).join(''); } });
|
||||
if (OFFICE_EXT.has(ext(name))) acts.push({ label: 'Open in Seafile', icon: 'doc', run: () => window.open(`${cfg.seafile}/lib/${enc(repo)}/file${path.split('/').map(enc).join('/')}`, '_blank', 'noopener') });
|
||||
acts.push({ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, false) });
|
||||
acts.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, path) });
|
||||
acts.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, path, name, ro) });
|
||||
if (!ro) acts.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, false) });
|
||||
if (!ro) acts.push({ label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) });
|
||||
acts.push({ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) });
|
||||
if (!ro) acts.push({ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) });
|
||||
actionSheet(name, '', acts);
|
||||
}
|
||||
|
||||
function dirSheet(repo, path, name, dir) {
|
||||
const ro = libPerm[repo] === 'r', it = { name, type: 'dir' };
|
||||
actionSheet(name, 'Folder', [
|
||||
{ label: 'Open', icon: 'files', run: () => { location.hash = '#/files/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join(''); } },
|
||||
{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, path, true) },
|
||||
{ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [name]) },
|
||||
...(ro ? [] : [{ label: 'Rename', icon: 'edit', run: () => renameItem(repo, path, name, dir, true) }, { label: 'Move to…', icon: 'files', run: () => moveSheet('move', repo, dir, [it]) }]),
|
||||
{ label: 'Copy to…', icon: 'copy', run: () => moveSheet('copy', repo, dir, [it]) },
|
||||
...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: () => deleteItems(repo, dir, [it]) }]),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Version history for a Seafile file: see earlier versions, download one, or restore it.
|
||||
// Uses sf, fmtSize, openSheet, confirmSheet, toast, esc and FORM from app.js.
|
||||
async function fileHistory(repo, path, name, readOnly) {
|
||||
const base = `/api2/repos/${encodeURIComponent(repo)}/file`;
|
||||
const el = openSheet('Version history', name, '<p class="empty">Loading…</p>');
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
try {
|
||||
let commits = [], next = '';
|
||||
for (let page = 0; page < 3; page++) { // up to three pages of history
|
||||
const r = await sf.req(`${base}/history/?p=${encodeURIComponent(path)}${next ? '&commit_id=' + encodeURIComponent(next) : ''}`);
|
||||
commits = commits.concat(r.commits || []);
|
||||
next = r.next_start_commit; if (!next) break;
|
||||
}
|
||||
if (!document.body.contains(el)) return;
|
||||
if (!commits.length) { el.querySelector('.empty').textContent = 'No earlier versions are kept for this file.'; return; }
|
||||
const body = el.querySelector('.empty');
|
||||
body.outerHTML = `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${commits.map((c, i) => `
|
||||
<div class="row"><span class="grow"><b>${esc(new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}${i === 0 ? ' · current' : ''}</b>
|
||||
<small style="white-space:normal">${esc(fmtSize(c.rev_file_size || 0))}${c.creator_name ? ' · ' + esc(c.creator_name) : ''}</small></span>
|
||||
${i === 0 ? '' : `<button class="btn ghost" data-dl="${i}" style="min-height:36px">Download</button>${readOnly ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}`}</div>`).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-dl]').forEach(b => b.onclick = async () => {
|
||||
const c = commits[+b.dataset.dl];
|
||||
try { window.open(await sf.req(`${base}/revision/?p=${encodeURIComponent(path)}&commit_id=${encodeURIComponent(c.id)}`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
|
||||
});
|
||||
el.querySelectorAll('[data-rs]').forEach(b => b.onclick = () => {
|
||||
const c = commits[+b.dataset.rs], when = new Date((c.ctime || 0) * 1000).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
||||
confirmSheet('Restore this version?', name + ' goes back to how it was on ' + when + '. The current version stays in the history.', 'Restore', async () => {
|
||||
try { await sf.req(`${base}/revert/`, { method: 'POST', headers: FORM, body: new URLSearchParams({ p: path, commit_id: c.id }) }); toast('Restored'); if (typeof route === 'function') route(); }
|
||||
catch (e) { toast(e.message); }
|
||||
});
|
||||
});
|
||||
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use strict';
|
||||
// Files, the sturdier parts: signing in again in place, a saved copy of each folder (instant, and still there offline), upload name clashes,
|
||||
// undo and the recycle bin, search in a library, zip downloads, share link options, and text files you can read and edit.
|
||||
// Uses sf, store, ME, jfetch, openSheet, closeSheet, confirmSheet, actionSheet, toast, esc, icon, fmtSize, fmtDate, joinP, libNames, libPerm, cfg from the other scripts.
|
||||
|
||||
// ---- Seafile sign-in expired: ask again without leaving the page, then the original request carries on
|
||||
let reauthP = null;
|
||||
function sfReauth() {
|
||||
if (reauthP) return reauthP;
|
||||
reauthP = new Promise((ok, no) => {
|
||||
let settled = false;
|
||||
const finish = (good, err) => { if (settled) return; settled = true; reauthP = null; good ? ok() : (store.del('sf_token'), no(err || new Error('Session expired, sign in again'))); };
|
||||
const el = openSheet('Sign in to Seafile again', 'Your Seafile sign-in expired. You will stay where you are.', `<form id="rf"><label for="ru">Email / username</label><input type="text" id="ru" autocomplete="username" autocapitalize="off" required>
|
||||
<label for="rp">Password</label><input type="password" id="rp" autocomplete="current-password" required><div id="rotp" hidden><label for="ro">2FA code</label><input type="text" id="ro" inputmode="numeric" autocomplete="one-time-code"></div>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Not now</button><button class="btn" id="rb" style="flex:1;justify-content:center">Sign in</button></div><div class="err" id="re" role="alert"></div></form>`);
|
||||
el.querySelector('#sx').onclick = () => { closeSheet(); finish(false); };
|
||||
el.querySelector('.scrim').addEventListener('click', () => finish(false));
|
||||
el.querySelector('#rf').onsubmit = async e => {
|
||||
e.preventDefault(); const b = el.querySelector('#rb'); b.disabled = true; el.querySelector('#re').textContent = '';
|
||||
try { await sf.login(el.querySelector('#ru').value.trim(), el.querySelector('#rp').value, el.querySelector('#ro').value.trim() || undefined); closeSheet(); finish(true); }
|
||||
catch (err) { b.disabled = false; if (err.otp) el.querySelector('#rotp').hidden = false; el.querySelector('#re').textContent = err.message; }
|
||||
};
|
||||
setTimeout(() => el.querySelector('#ru').focus(), 60);
|
||||
});
|
||||
return reauthP;
|
||||
}
|
||||
|
||||
// ---- A saved copy of every folder you open (names, sizes, dates only): shown at once while the real list loads, and when Seafile cannot be reached
|
||||
const dcIndex = () => 'dc:index:' + (ME ? ME.id : '');
|
||||
const dcKey = (repo, dir) => 'dc:' + (ME ? ME.id : '') + ':' + repo + ':' + dir;
|
||||
function dcGet(repo, dir) { try { const o = JSON.parse(localStorage.getItem(dcKey(repo, dir))); return o && Array.isArray(o.items) ? o : null; } catch { return null; } }
|
||||
function dcPut(repo, dir, items) {
|
||||
const key = dcKey(repo, dir), body = JSON.stringify({ t: Date.now(), items: items.slice(0, 1500).map(x => ({ id: x.id, name: x.name, type: x.type, size: x.size, mtime: x.mtime })) });
|
||||
const touch = () => { const idx = (JSON.parse(localStorage.getItem(dcIndex()) || '[]')).filter(k => k !== key); idx.unshift(key); while (idx.length > 40) localStorage.removeItem(idx.pop()); localStorage.setItem(dcIndex(), JSON.stringify(idx)); };
|
||||
try { localStorage.setItem(key, body); touch(); }
|
||||
catch { try { const idx = JSON.parse(localStorage.getItem(dcIndex()) || '[]'); for (const k of idx.splice(Math.ceil(idx.length / 2))) localStorage.removeItem(k); localStorage.setItem(dcIndex(), JSON.stringify(idx)); localStorage.setItem(key, body); touch(); } catch { /* storage is full: go without */ } }
|
||||
}
|
||||
const fScroll = new Map(); // where each folder was scrolled to
|
||||
document.addEventListener('scroll', e => { if (e.target && e.target.id === 'view' && /^#\/files\/./.test(location.hash)) fScroll.set(location.hash, e.target.scrollTop); }, true);
|
||||
|
||||
// ---- Uploading a file whose name is already there
|
||||
async function resolveUploadConflicts(repo, dir, entries) {
|
||||
let existing;
|
||||
try { existing = new Set((await sf.req(`/api2/repos/${encodeURIComponent(repo)}/dir/?p=${encodeURIComponent(dir)}`)).map(x => x.name.toLowerCase())); } catch { return entries; }
|
||||
const clash = entries.filter(e => !e.rel && existing.has(e.file.name.toLowerCase())); // (files inside dropped folders keep Seafile's own renaming)
|
||||
if (!clash.length) return entries;
|
||||
const choice = await new Promise(ok => {
|
||||
const el = openSheet(clash.length === 1 ? 'Replace “' + clash[0].file.name + '”?' : clash.length + ' files already exist here', 'Replacing keeps the old version in the file\'s history.',
|
||||
`${clash.length > 1 ? `<div class="list" style="background:var(--panel2);border:0;max-height:30dvh;overflow:auto">${clash.map(c => `<div class="row"><span class="grow"><b>${esc(c.file.name)}</b></span></div>`).join('')}</div>` : ''}
|
||||
<div class="bar"><button class="btn" id="cr" style="flex:1;justify-content:center">Replace</button><button class="btn ghost" id="ck" style="flex:1;justify-content:center">Keep both</button></div>
|
||||
<div class="bar" style="margin-top:0"><button class="btn ghost" id="cs" style="flex:1;justify-content:center">Skip ${clash.length === 1 ? 'it' : 'those'}</button><button class="btn ghost" id="cc" style="flex:1;justify-content:center">Cancel upload</button></div>`);
|
||||
let done = false; const pick = v => { if (done) return; done = true; closeSheet(); ok(v); };
|
||||
el.querySelector('#cr').onclick = () => pick('replace'); el.querySelector('#ck').onclick = () => pick('both'); el.querySelector('#cs').onclick = () => pick('skip'); el.querySelector('#cc').onclick = () => pick('cancel');
|
||||
el.querySelector('.scrim').addEventListener('click', () => pick('cancel'));
|
||||
});
|
||||
if (choice === 'cancel') return [];
|
||||
if (choice === 'skip') return entries.filter(e => !clash.includes(e));
|
||||
return entries.map(e => clash.includes(e) ? { ...e, replace: choice === 'replace' } : e);
|
||||
}
|
||||
const waitOnline = () => new Promise(ok => { if (navigator.onLine) return ok(); const f = () => { removeEventListener('online', f); ok(); }; addEventListener('online', f); });
|
||||
|
||||
// ---- Undo a delete, and the recycle bin (Seafile keeps deleted files for a while)
|
||||
const trashWhen = t => typeof t === 'number' ? t * 1000 : Date.parse(t) || 0;
|
||||
async function trashList(repo, path = '/', limit = 100) {
|
||||
const r = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/trash/?path=${encodeURIComponent(path)}&limit=${limit}`);
|
||||
return (r && (r.items || r.data)) || [];
|
||||
}
|
||||
async function trashRestore(repo, t) {
|
||||
const parent = (t.parent_dir || '/').replace(/\/+$/, '') || '';
|
||||
await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${t.is_dir ? 'dir' : 'file'}/revert/`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ p: parent + '/' + t.obj_name, commit_id: t.commit_id }) });
|
||||
}
|
||||
/** Right after a delete: find the just-deleted items in the recycle bin and offer Undo. (Quietly nothing if Seafile has no recycle bin API.) */
|
||||
async function offerUndo(repo, dir, items, afterRestore) {
|
||||
try {
|
||||
const names = new Set(items.map(x => x.name)), newest = new Map();
|
||||
for (const t of await trashList(repo, dir, 60)) if (names.has(t.obj_name) && Date.now() - trashWhen(t.deleted_time) < 5 * 60e3 && (!newest.has(t.obj_name) || trashWhen(t.deleted_time) > trashWhen(newest.get(t.obj_name).deleted_time))) newest.set(t.obj_name, t);
|
||||
if (!newest.size) return false;
|
||||
toast(items.length === 1 ? 'Deleted “' + items[0].name + '”' : items.length + ' items deleted', { label: 'Undo', run: async () => {
|
||||
let bad = 0; for (const t of newest.values()) { try { await trashRestore(repo, t); } catch { bad++; } }
|
||||
toast(bad ? 'Could not restore ' + bad + ' item' + (bad === 1 ? '' : 's') : 'Restored'); if (afterRestore) afterRestore();
|
||||
} });
|
||||
return true;
|
||||
} catch { return false; /* no undo available */ }
|
||||
}
|
||||
async function recycleBin(repo, dir) {
|
||||
const el = openSheet('Recycle bin', (libNames[repo] || '') + (dir && dir !== '/' ? ' · ' + dir : ''), '<p class="empty">Loading…</p>');
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
try {
|
||||
const list = (await trashList(repo, dir || '/', 100)).sort((a, b) => trashWhen(b.deleted_time) - trashWhen(a.deleted_time));
|
||||
if (!document.body.contains(el)) return;
|
||||
const body = el.querySelector('.empty');
|
||||
body.outerHTML = list.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${list.map((t, i) => `<div class="row"><span class="ic" style="background:${t.is_dir ? '#e0a82b' : '#6a7280'}">${icon(t.is_dir ? 'files' : 'file')}</span>
|
||||
<span class="grow"><b>${esc(t.obj_name)}</b><small style="white-space:normal">${esc((t.parent_dir || '/'))} · ${t.is_dir ? 'folder' : esc(fmtSize(t.size || 0))} · deleted ${esc(new Date(trashWhen(t.deleted_time)).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></span>
|
||||
${libPerm[repo] === 'r' ? '' : `<button class="btn ghost" data-rs="${i}" style="min-height:36px">Restore</button>`}</div>`).join('')}</div><p class="sub">Showing the 100 most recent. Seafile empties its recycle bin after a while.</p>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`
|
||||
: '<p class="empty">Nothing here. Deleted files and folders from this library show up here so you can get them back.</p><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-rs]').forEach(b => b.onclick = async () => {
|
||||
b.disabled = true; try { await trashRestore(repo, list[+b.dataset.rs]); b.textContent = 'Restored'; if (fv && fv.repo === repo) fv.reload(true); } catch (e) { b.disabled = false; toast(e.message); }
|
||||
});
|
||||
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
|
||||
}
|
||||
|
||||
// ---- Search a whole library (names, from the Seafile index when it has one, otherwise by walking the folders)
|
||||
function libSearch(repo) {
|
||||
textSheet('Search this library', 'File or folder name', 'Search', async term => {
|
||||
const el = openSheet('Results for “' + term + '”', libNames[repo] || '', '<p class="empty">Searching…</p>'); el.querySelector('.sheet').classList.add('wide');
|
||||
try {
|
||||
const { results } = await searchFiles(term, [{ id: repo, name: libNames[repo] || '' }]);
|
||||
const mine = results.filter(r => r.repo_id === repo).slice(0, 60);
|
||||
if (!document.body.contains(el)) return;
|
||||
el.querySelector('.empty').outerHTML = `${mine.length ? `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${mine.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${r.is_dir ? '#e0a82b' : '#6a7280'}">${icon(r.is_dir ? 'files' : 'file')}</span><span class="grow"><b>${esc(r.name)}</b><small style="white-space:normal">${esc((r.fullpath || '').replace(/\/[^/]*$/, '') || '/')}</small></span></button>`).join('')}</div>` : '<p class="empty">Nothing found.</p>'}<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-r]').forEach(b => b.onclick = () => {
|
||||
const r = mine[+b.dataset.r], parent = (r.fullpath || '').replace(/\/[^/]*$/, '') || '/'; closeSheet();
|
||||
if (r.is_dir) location.hash = '#/files/' + encodeURIComponent(repo) + (r.fullpath || '').split('/').filter(Boolean).map(s => '/' + encodeURIComponent(s)).join('');
|
||||
else openFile(repo, r.fullpath, r.name, parent);
|
||||
});
|
||||
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Download several items, or a whole folder, as one zip (Seafile builds it; we wait for it)
|
||||
async function zipDownload(repo, dir, names) {
|
||||
try {
|
||||
const q = new URLSearchParams({ parent_dir: dir }); names.forEach(n => q.append('dirents', n));
|
||||
const task = await sf.req(`/api/v2.1/repos/${encodeURIComponent(repo)}/zip-task/?${q}`), token = task.zip_token;
|
||||
if (!token) throw new Error('Seafile could not make a zip of that');
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const p = await sf.req(`/api/v2.1/query-zip-progress/?token=${encodeURIComponent(token)}`);
|
||||
const total = +p.total || 0, zipped = +p.zipped || 0;
|
||||
if (p.failed || p.failed_reason) throw new Error('Seafile could not make the zip' + (p.failed_reason ? ': ' + p.failed_reason : ''));
|
||||
if (total && zipped >= total) break;
|
||||
toast('Preparing the zip… ' + (total ? Math.round(100 * zipped / total) + '%' : ''));
|
||||
await sleep(i < 5 ? 500 : 1500);
|
||||
if (i === 299) throw new Error('The zip is taking too long. Try fewer files.');
|
||||
}
|
||||
const a = document.createElement('a'); a.href = '/sf/seafhttp/zip/' + encodeURIComponent(token); a.download = ''; document.body.appendChild(a); a.click(); a.remove();
|
||||
toast('Download started');
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
// ---- Share links with a password and an expiry, and the ones that already exist
|
||||
async function shareSheet(repo, path, isDir) {
|
||||
const p = isDir ? path.replace(/\/?$/, '/') : path, enc = encodeURIComponent, FORMH = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
const el = openSheet('Share link', p.split('/').filter(Boolean).pop() || (libNames[repo] || ''), '<p class="empty">Loading…</p>');
|
||||
const draw = async () => {
|
||||
let links = []; try { links = (await sf.req(`/api/v2.1/share-links/?repo_id=${enc(repo)}&path=${enc(p)}`)) || []; } catch (e) { if (!document.body.contains(el)) return; }
|
||||
if (!document.body.contains(el)) return;
|
||||
const host = el.querySelector('.empty') || el.querySelector('#shbody');
|
||||
const html = `<div id="shbody">${links.length ? `<div class="list" style="background:var(--panel2);border:0">${links.map((l, i) => `<div class="row" style="flex-wrap:wrap"><span class="grow" style="min-width:0"><input type="text" readonly value="${esc(l.link)}" onfocus="this.select()" style="margin:0"><small style="white-space:normal">${l.expire_date ? (l.is_expired ? 'Expired ' : 'Expires ') + esc(new Date(l.expire_date).toLocaleDateString([], { dateStyle: 'medium' })) : 'Never expires'}${l.is_expired ? '' : ''}</small></span>
|
||||
<button class="btn ghost" data-cp="${i}" style="min-height:36px">Copy</button><button class="btn danger" data-rm="${i}" style="min-height:36px">Delete</button></div>`).join('')}</div>` : ''}
|
||||
<form id="shf"><h3 style="margin:14px 0 4px;font-size:15px">${links.length ? 'New link' : 'Create a link'}</h3>
|
||||
<label for="shp">Password (optional, at least 8 characters)</label><input type="password" id="shp" autocomplete="new-password" placeholder="No password">
|
||||
<label for="she">Expires</label><select id="she"><option value="">Never</option><option value="1">In 1 day</option><option value="7" selected>In 7 days</option><option value="30">In 30 days</option><option value="90">In 90 days</option></select>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Close</button><button class="btn" id="shc" style="flex:1;justify-content:center">Create link</button></div><div class="err" id="she2" role="alert"></div></form></div>`;
|
||||
host.outerHTML = html;
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelectorAll('[data-cp]').forEach(b => b.onclick = async () => { const l = links[+b.dataset.cp]; try { await navigator.clipboard.writeText(l.link); toast('Link copied'); } catch { toast('Select the link and copy it'); } });
|
||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete this link?', 'Anyone using it loses access.', 'Delete', async () => { try { await sf.req(`/api/v2.1/share-links/${enc(links[+b.dataset.rm].token)}/`, { method: 'DELETE' }); shareSheet(repo, path, isDir); } catch (e) { toast(e.message); } }));
|
||||
el.querySelector('#shf').onsubmit = async e => {
|
||||
e.preventDefault(); const pw = el.querySelector('#shp').value, days = el.querySelector('#she').value, btn = el.querySelector('#shc');
|
||||
if (pw && pw.length < 8) { el.querySelector('#she2').textContent = 'A password needs at least 8 characters.'; return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const body = new URLSearchParams({ repo_id: repo, path: p }); if (pw) body.set('password', pw); if (days) body.set('expire_days', days);
|
||||
const made = await sf.req('/api/v2.1/share-links/', { method: 'POST', headers: FORMH, body });
|
||||
try { await navigator.clipboard.writeText(made.link); toast('Link created and copied'); } catch { toast('Link created'); }
|
||||
shareSheet(repo, path, isDir);
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#she2').textContent = err.message; }
|
||||
};
|
||||
};
|
||||
draw();
|
||||
}
|
||||
|
||||
// ---- Text files: read them here, and edit small ones (the old version stays in the file's history)
|
||||
const TEXT_EXT = new Set('txt md markdown json csv tsv log xml yml yaml ini conf cfg toml js ts css html htm py sh sql env gitignore'.split(' '));
|
||||
async function textPreview(repo, path, name) {
|
||||
const dir = path.replace(/\/[^/]*$/, '') || '/', ro = libPerm[repo] === 'r', url = (await fileUrl(repo, path)) + '?hubinline=1';
|
||||
const el = openSheet(name, '', '<p class="empty">Loading…</p>'); el.querySelector('.sheet').classList.add('wide');
|
||||
const dl = `<a class="btn ghost" href="${url.replace('?hubinline=1', '')}" target="_blank" rel="noopener">${icon('dl')}Download</a>`;
|
||||
try {
|
||||
const r = await fetch(url); if (!r.ok) throw new Error('Could not open the file (' + r.status + ')');
|
||||
const len = +r.headers.get('content-length') || 0;
|
||||
if (len > 1024 * 1024) { await r.body.cancel(); el.querySelector('.empty').outerHTML = `<p class="sub">This file is ${esc(fmtSize(len))}, too big to show here.</p><div class="bar">${dl}<button class="btn ghost" id="sx">Close</button></div>`; el.querySelector('#sx').onclick = closeSheet; return; }
|
||||
let text = await r.text(); if (text.length > 1024 * 1024) text = text.slice(0, 1024 * 1024);
|
||||
const view = () => {
|
||||
if (!document.body.contains(el)) return;
|
||||
const host = el.querySelector('#tpb') || el.querySelector('.empty');
|
||||
host.outerHTML = `<div id="tpb"><pre class="ptxt">${esc(text)}</pre><div class="bar">${ro || len > 256 * 1024 ? '' : '<button class="btn" id="ted">Edit</button>'}${dl}<button class="btn ghost" id="sx">Close</button></div></div>`;
|
||||
el.querySelector('#sx').onclick = closeSheet; const ed = el.querySelector('#ted'); if (ed) ed.onclick = edit;
|
||||
};
|
||||
const edit = () => {
|
||||
el.querySelector('#tpb').outerHTML = `<div id="tpb"><textarea id="tta" class="ptxt-edit" spellcheck="false">${esc(text)}</textarea><div class="bar"><button class="btn" id="tsv">Save</button><button class="btn ghost" id="tcx">Cancel</button></div><div class="err" id="ter" role="alert"></div></div>`;
|
||||
el.querySelector('#tcx').onclick = view;
|
||||
el.querySelector('#tsv').onclick = async () => {
|
||||
const b = el.querySelector('#tsv'); b.disabled = true; const next = el.querySelector('#tta').value;
|
||||
try {
|
||||
const link = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/update-link/?p=${encodeURIComponent(dir)}`), fd = new FormData();
|
||||
fd.append('target_file', path); fd.append('parent_dir', dir); fd.append('file', new Blob([next], { type: 'text/plain' }), name);
|
||||
const up = await fetch('/sf' + new URL(link, cfg.seafile).pathname, { method: 'POST', body: fd });
|
||||
if (!up.ok) throw new Error('Could not save (' + up.status + ')');
|
||||
text = next; toast('Saved'); view(); if (fv && fv.repo === repo) fv.reload(true);
|
||||
} catch (e) { b.disabled = false; el.querySelector('#ter').textContent = e.message; }
|
||||
};
|
||||
};
|
||||
view();
|
||||
} catch (e) { const b = el.querySelector('.empty'); if (b) b.textContent = e.message; }
|
||||
}
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
// ---- In-app editor (OnlyOffice Document Server, config signed server-side with the JWT secret)
|
||||
async function viewEdit([repo, ...rawParts], opts = {}) {
|
||||
const parts = rawParts.filter(Boolean);
|
||||
const path = '/' + parts.join('/'), name = parts[parts.length - 1] || '';
|
||||
const back = opts.fromOffice ? '#/office' : '#/files/' + encodeURIComponent(repo) + parts.slice(0, -1).map(s => '/' + encodeURIComponent(s)).join('');
|
||||
document.body.classList.toggle('inedit', !!opts.fromOffice); // Office tab keeps the navigation visible around the editor
|
||||
const v = $('#view');
|
||||
v.innerHTML = `<div class="edbar"><a class="btn ghost" href="${back}">${icon('back')}${opts.fromOffice ? 'Office' : 'Close'}</a><b>${esc(name)}</b></div>
|
||||
<div class="edwrap"><div id="oo"></div><p class="empty" id="oomsg">Opening…</p></div>`;
|
||||
if (!sf.token()) return (location.hash = '#/files');
|
||||
const fail = m => { const e = $('#oomsg'); if (e) { e.className = 'err'; e.textContent = m; } };
|
||||
try {
|
||||
const r = await fetch('/api/oo/config', { method: 'POST', headers: { Authorization: 'Token ' + sf.token() },
|
||||
body: JSON.stringify({ repo, path, mobile: innerWidth < 768 }) });
|
||||
const conf = await r.json();
|
||||
if (!r.ok) return fail(conf.error || 'Could not open document');
|
||||
if (!window.DocsAPI) await new Promise((ok, no) => {
|
||||
const s = document.createElement('script'); s.src = cfg.onlyoffice + '/web-apps/apps/api/documents/api.js';
|
||||
s.onload = ok; s.onerror = () => no(new Error('Could not load the OnlyOffice editor from ' + cfg.onlyoffice)); document.head.appendChild(s);
|
||||
});
|
||||
if (!/^#\/(edit|office\/doc)\//.test(location.hash)) return;
|
||||
$('#oomsg').remove();
|
||||
conf.events = { onError: e => toast('Editor error: ' + (e.data?.errorDescription || e.data?.errorCode || '')) };
|
||||
editor = new DocsAPI.DocEditor('oo', conf);
|
||||
} catch (e) { fail(e.message); }
|
||||
}
|
||||
|
||||
// ---- Files
|
||||
function viewFiles([repo, ...parts]) {
|
||||
if (!sf.token()) return filesLogin();
|
||||
const dir = '/' + parts.join('/');
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><div class="files ${repo ? 'open' : ''}" id="files">
|
||||
<div class="libs" id="libs"><p class="empty">Loading…</p></div><div class="main" id="fmain"></div></div></div>`;
|
||||
libsP = loadLibs(repo);
|
||||
if (repo) loadDir(repo, dir);
|
||||
}
|
||||
|
||||
function filesLogin(msg = '', needOtp = false) {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Files</h1><p class="sub">Sign in to ${esc(new URL(cfg.seafile).host)}</p>
|
||||
<form class="card" id="lf"><label>Email / username</label><input type="text" name="u" autocomplete="username" autocapitalize="off" required>
|
||||
<label>Password</label><input type="password" name="p" autocomplete="current-password" required>
|
||||
${needOtp ? '<label>2FA code</label><input type="text" name="o" inputmode="numeric" autocomplete="one-time-code">' : ''}
|
||||
<div class="bar"><button class="btn" type="submit">Sign in</button></div><div class="err">${esc(msg)}</div></form></div>`;
|
||||
$('#lf').onsubmit = async e => {
|
||||
e.preventDefault();
|
||||
const f = new FormData(e.target);
|
||||
try { await sf.login(f.get('u'), f.get('p'), f.get('o')); route(); }
|
||||
catch (err) { filesLogin(err.message, !!err.otp || needOtp); }
|
||||
};
|
||||
}
|
||||
|
||||
async function loadLibs(active) {
|
||||
try {
|
||||
const repos = (await sf.req('/api2/repos/')).sort((a, b) => a.name.localeCompare(b.name));
|
||||
libNames = Object.fromEntries(repos.map(r => [r.id, r.name])); libPerm = Object.fromEntries(repos.map(r => [r.id, r.permission]));
|
||||
$('#libs').innerHTML = `<div class="bar" style="margin-top:0"><button class="btn ghost" id="nl">${icon('newdir')}New library</button></div><h2 style="margin-top:8px">Libraries</h2><div class="list">${repos.map(r => `
|
||||
<a class="row" href="#/files/${encodeURIComponent(r.id)}" ${r.id === active ? 'style="background:var(--panel2)"' : ''}>
|
||||
<span class="ic" style="background:#2f9e6e">${icon('files')}</span>
|
||||
<span class="grow"><b>${esc(r.name)}</b><small>${r.encrypted ? 'Encrypted · ' : ''}${esc(r.type === 'repo' ? 'Mine' : r.owner_name || r.owner || 'Shared')}</small></span>${icon('chev', 'chev')}</a>`).join('') || '<p class="empty">No libraries</p>'}</div>`;
|
||||
$('#nl').onclick = () => textSheet('New library', 'Library name', 'Create', async name => {
|
||||
try { const r = await sf.req('/api2/repos/', { method: 'POST', body: new URLSearchParams({ name }), headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }); location.hash = '#/files/' + encodeURIComponent(r.repo_id); }
|
||||
catch (e) { toast(e.message); }
|
||||
});
|
||||
if (!active) return;
|
||||
const r = repos.find(x => x.id === active);
|
||||
if (r && r.encrypted) $('#fmain').innerHTML = '<p class="empty">This library is encrypted. Unlock it in the Seafile web UI first.</p>';
|
||||
} catch (e) { $('#libs').innerHTML = `<p class="err">${esc(e.message)}</p>`; if (!sf.token()) setTimeout(route, 800); }
|
||||
}
|
||||
|
||||
// ---- Files: browse, select, move/copy/delete, rename, upload (drag in from the computer, drag a file out to download)
|
||||
let fv = null, libsP = Promise.resolve(), libNames = {}, libPerm = {};
|
||||
const SORTS = [['name', 'Name'], ['mtime', 'Date modified'], ['size', 'Size'], ['type', 'Kind']];
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); // (one collator is much faster than localeCompare in a big folder)
|
||||
const nameCmp = (a, b) => collator.compare(a.name, b.name);
|
||||
function sortItems(items, s) {
|
||||
const by = { name: nameCmp, mtime: (a, b) => a.mtime - b.mtime, size: (a, b) => (a.size || 0) - (b.size || 0), type: (a, b) => ext(a.name).localeCompare(ext(b.name)) }[s.k] || nameCmp;
|
||||
return [...items].sort((a, b) => a.type !== b.type ? (a.type === 'dir' ? -1 : 1) // folders stay on top
|
||||
: (a.type === 'dir' && s.k !== 'name' && s.k !== 'mtime') ? nameCmp(a, b) : s.d * by(a, b) || nameCmp(a, b));
|
||||
}
|
||||
const badName = (n, items, self) => !n || n === '.' || n === '..' ? 'Enter a name' : /[\/\\]/.test(n) ? 'A name cannot contain / or \\' : n.length > 255 ? 'That name is too long'
|
||||
: items.some(x => x.name.toLowerCase() === n.toLowerCase() && x.name !== self) ? 'Something with that name is already here' : '';
|
||||
const sfJson = (path, body) => sf.req(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const hereItems = (repo, dir) => (fv && fv.repo === repo && fv.dir === dir ? fv.items : []);
|
||||
// After something changed in a folder, redraw it if it is the one on screen.
|
||||
function afterChange(repo, dir) { if (fv && document.contains(fv.root) && fv.repo === repo && fv.dir === dir) { fv.sel.clear(); fv.selecting = false; fv.reload(); } }
|
||||
|
||||
async function removeItems(repo, dir, items) {
|
||||
const enc = encodeURIComponent;
|
||||
try { await sfJson('/api/v2.1/repos/batch-delete-item/', { repo_id: repo, parent_dir: dir, dirents: items.map(x => x.name) }); }
|
||||
catch (e) { // older servers have no batch call: one at a time
|
||||
const failed = [];
|
||||
for (const it of items) { try { await sf.req(`/api2/repos/${enc(repo)}/${it.type === 'dir' ? 'dir' : 'file'}/?p=${enc(joinP(dir, it.name))}`, { method: 'DELETE' }); } catch { failed.push(it.name); } }
|
||||
if (failed.length) throw new Error('Could not delete: ' + failed.join(', '));
|
||||
}
|
||||
}
|
||||
function deleteItems(repo, dir, items) {
|
||||
const one = items.length === 1;
|
||||
confirmSheet(one ? (items[0].type === 'dir' ? 'Delete folder?' : 'Delete file?') : `Delete ${items.length} items?`,
|
||||
one ? items[0].name + (items[0].type === 'dir' ? ' and everything in it' : '') : 'Folders are deleted with everything inside them', 'Delete', async () => {
|
||||
try { await removeItems(repo, dir, items); afterChange(repo, dir); if (!(await offerUndo(repo, dir, items, () => afterChange(repo, dir)))) toast(one ? 'Deleted' : items.length + ' items deleted'); } catch (e) { toast(e.message); afterChange(repo, dir); }
|
||||
});
|
||||
}
|
||||
async function transferItems(mode, repo, dir, names, dst) {
|
||||
await sfJson(`/api/v2.1/repos/sync-batch-${mode}-item/`, { src_repo_id: repo, src_parent_dir: dir, dst_repo_id: dst.repo, dst_parent_dir: dst.dir, src_dirents: names });
|
||||
}
|
||||
function moveSheet(mode, repo, dir, items) {
|
||||
const names = items.map(x => x.name), folders = items.filter(x => x.type === 'dir').map(x => joinP(dir, x.name)), verb = mode === 'move' ? 'Move' : 'Copy';
|
||||
chooseFolder(verb + (items.length === 1 ? ' “' + names[0] + '”' : ' ' + items.length + ' items') + ' to…', { repo, repoName: libNames[repo] || '', dir },
|
||||
{ okLabel: verb + ' here', hide: (r, d) => (r === repo && d === dir ? names : []), newFolder: true,
|
||||
blocked: (r, d) => r === repo && ((mode === 'move' && d === dir) || folders.some(f => d === f || d.startsWith(f + '/'))) }).then(async dst => {
|
||||
if (!dst) return;
|
||||
try { await transferItems(mode, repo, dir, names, dst); toast((mode === 'move' ? 'Moved' : 'Copied') + (items.length > 1 ? ' ' + items.length + ' items' : '')); }
|
||||
catch (e) { toast(e.message); }
|
||||
afterChange(repo, dir);
|
||||
});
|
||||
}
|
||||
function renameItem(repo, path, name, dir, isDir) {
|
||||
textSheet('Rename', 'New name', 'Rename', async n => {
|
||||
if (n === name) return;
|
||||
try { await sf.req(`/api2/repos/${encodeURIComponent(repo)}/${isDir ? 'dir' : 'file'}/?p=${encodeURIComponent(path)}`, { method: 'POST', headers: FORM, body: new URLSearchParams({ operation: 'rename', newname: n }) }); toast('Renamed'); }
|
||||
catch (e) { toast(e.message); }
|
||||
afterChange(repo, dir);
|
||||
}, name, n => badName(n, hereItems(repo, dir), name));
|
||||
}
|
||||
async function downloadItem(repo, path) {
|
||||
try { window.open(await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`), '_blank', 'noopener'); } catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
// ---- Uploads: a queue that survives leaving the folder, with progress, retries and a way to cancel
|
||||
const uq = { items: [], running: 0 };
|
||||
const upActive = () => uq.items.some(x => x.state === 'wait' || x.state === 'up');
|
||||
async function enqueueUploads(repo, dir, entries) {
|
||||
if (!entries.length) return;
|
||||
entries = await resolveUploadConflicts(repo, dir, entries); // a name that is already there: replace, keep both, or skip
|
||||
if (!entries.length) return;
|
||||
for (const e of entries) uq.items.push({ repo, dir, file: e.file, rel: e.rel || '', replace: !!e.replace, pct: 0, state: 'wait', tries: 0 });
|
||||
drawUpBar(); pumpUploads();
|
||||
}
|
||||
function pumpUploads() {
|
||||
while (uq.running < 2) {
|
||||
const it = uq.items.find(x => x.state === 'wait'); if (!it) break;
|
||||
uq.running++; it.state = 'up';
|
||||
sendUpload(it).then(() => { it.state = 'done'; }, err => { it.state = it.cancelled ? 'cancel' : 'fail'; it.err = err.message; })
|
||||
.finally(() => { uq.running--; drawUpBar(); pumpUploads(); if (!upActive()) uploadsFinished(); });
|
||||
}
|
||||
}
|
||||
async function sendUpload(it) {
|
||||
const enc = encodeURIComponent;
|
||||
for (;;) {
|
||||
it.tries++; it.pct = 0;
|
||||
try {
|
||||
const link = await sf.req(`/api2/repos/${enc(it.repo)}/upload-link/?p=${enc(it.dir)}`); // a fresh link each time, so an expired one never blocks a retry
|
||||
await new Promise((ok, no) => {
|
||||
const x = it.xhr = new XMLHttpRequest(), fd = new FormData();
|
||||
fd.append('parent_dir', it.dir); fd.append('replace', it.replace ? '1' : '0'); if (it.rel) fd.append('relative_path', it.rel); fd.append('file', it.file, it.file.name);
|
||||
x.open('POST', '/sf' + new URL(link, cfg.seafile).pathname + '?ret-json=1');
|
||||
x.upload.onprogress = e => { if (e.lengthComputable) { it.pct = e.loaded / e.total; drawUpBar(); } };
|
||||
x.onload = () => x.status >= 200 && x.status < 300 ? ok() : no(Object.assign(new Error(x.status === 413 ? 'Too large for the server' : x.status === 403 ? 'Not allowed (or out of space)' : 'Upload failed (' + x.status + ')'), { fatal: x.status >= 400 && x.status < 500 }));
|
||||
x.onerror = () => no(new Error('Connection lost'));
|
||||
x.onabort = () => no(Object.assign(new Error('Cancelled'), { fatal: true }));
|
||||
x.send(fd);
|
||||
});
|
||||
return;
|
||||
} catch (e) {
|
||||
if (it.cancelled) throw e;
|
||||
if (e.fatal || !sf.token()) throw new Error(it.file.name + ': ' + e.message);
|
||||
if (!navigator.onLine) { it.waiting = true; drawUpBar(); await waitOnline(); it.waiting = false; it.tries = Math.max(0, it.tries - 1); } // no connection: wait for it instead of failing
|
||||
else if (it.tries >= 5) throw new Error(it.file.name + ': ' + e.message);
|
||||
else await sleep(Math.min(15000, 1500 * 2 ** (it.tries - 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
function uploadsFinished() {
|
||||
const done = uq.items.filter(x => x.state === 'done').length, bad = uq.items.filter(x => x.state === 'fail').length;
|
||||
if (done) toast(done + (done === 1 ? ' file' : ' files') + ' uploaded' + (bad ? ', ' + bad + ' failed' : ''));
|
||||
uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar();
|
||||
if (fv && document.contains(fv.root)) fv.reload(true);
|
||||
}
|
||||
function drawUpBar() {
|
||||
let b = $('#upbar');
|
||||
const live = uq.items.filter(x => x.state === 'wait' || x.state === 'up'), bad = uq.items.filter(x => x.state === 'fail');
|
||||
if (!live.length && !bad.length) { if (b) b.remove(); return; }
|
||||
if (!b) { b = document.createElement('div'); b.id = 'upbar'; document.body.appendChild(b); }
|
||||
if (live.length) {
|
||||
const total = uq.items.length, doneN = uq.items.filter(x => x.state === 'done').length, cur = live.find(x => x.state === 'up') || live[0];
|
||||
const pct = Math.round(((doneN + live.reduce((a, x) => a + (x.state === 'up' ? x.pct : 0), 0)) / total) * 100);
|
||||
b.innerHTML = `<div class="upm"><b>${live.some(x => x.waiting) ? 'Waiting for a connection…' : 'Uploading ' + esc(live.length === 1 ? cur.file.name : live.length + ' files')}</b><small>${pct}%${cur.tries > 1 && !cur.waiting ? ' · retrying' : ''}</small><i style="width:${pct}%"></i></div><button class="btn ghost" id="upx">Cancel</button>`;
|
||||
$('#upx').onclick = () => { uq.items.forEach(x => { if (x.state === 'wait') x.state = 'cancel'; if (x.state === 'up') { x.cancelled = true; x.xhr && x.xhr.abort(); } }); uq.items = uq.items.filter(x => x.state === 'fail'); drawUpBar(); };
|
||||
} else {
|
||||
b.innerHTML = `<div class="upm"><b>${bad.length} ${bad.length === 1 ? 'upload' : 'uploads'} failed</b><small>${esc(bad[0].err || '')}</small></div><button class="btn" id="upr">Retry</button><button class="btn ghost" id="upd">Dismiss</button>`;
|
||||
$('#upr').onclick = () => { bad.forEach(x => { x.state = 'wait'; x.tries = 0; x.cancelled = false; }); drawUpBar(); pumpUploads(); };
|
||||
$('#upd').onclick = () => { uq.items = uq.items.filter(x => x.state !== 'fail'); drawUpBar(); };
|
||||
}
|
||||
}
|
||||
addEventListener('beforeunload', e => { if (upActive()) { e.preventDefault(); e.returnValue = ''; } });
|
||||
|
||||
// Everything dropped from the computer, including whole folders (their structure is kept).
|
||||
async function droppedFiles(dt) {
|
||||
const ents = [...(dt.items || [])].map(i => (i.webkitGetAsEntry ? i.webkitGetAsEntry() : null)).filter(Boolean), out = [];
|
||||
if (!ents.length) return [...dt.files].map(file => ({ file, rel: '' }));
|
||||
const walk = async (en, rel) => {
|
||||
if (en.isFile) await new Promise(ok => en.file(f => { out.push({ file: f, rel }); ok(); }, ok));
|
||||
else if (en.isDirectory) {
|
||||
const here = rel ? rel + '/' + en.name : en.name, rd = en.createReader(); let batch;
|
||||
do { batch = await new Promise(ok => rd.readEntries(ok, () => ok([]))); for (const c of batch) await walk(c, here); } while (batch.length);
|
||||
}
|
||||
};
|
||||
for (const en of ents) await walk(en, '');
|
||||
return out;
|
||||
}
|
||||
const dlUrls = new Map(); // direct download addresses, fetched ahead so a file can be dragged out to the desktop
|
||||
async function prefetchDl(repo, path) {
|
||||
const k = repo + path, c = dlUrls.get(k); if (c && Date.now() - c.t < 45 * 60e3) return;
|
||||
dlUrls.set(k, { t: Date.now() });
|
||||
try { const u = await sf.req(`/api2/repos/${encodeURIComponent(repo)}/file/?p=${encodeURIComponent(path)}&reuse=1`); dlUrls.set(k, { t: Date.now(), url: new URL(u, cfg.seafile).href }); } catch { dlUrls.delete(k); }
|
||||
}
|
||||
|
||||
async function loadDir(repo, dir) {
|
||||
const main = $('#fmain'); if (!main) return;
|
||||
main.innerHTML = '<p class="empty">Loading…</p>';
|
||||
await libsP.catch(() => {});
|
||||
if (!document.contains(main)) return;
|
||||
const enc = encodeURIComponent, ro = libPerm[repo] === 'r';
|
||||
const hash = p => '#/files/' + enc(repo) + p.split('/').filter(Boolean).map(s => '/' + enc(s)).join('');
|
||||
const segs = dir.split('/').filter(Boolean);
|
||||
const crumbs = `<button data-go="/">${esc(libNames[repo] || 'Library')}</button>` + segs.map((s, i) => `<span>/</span><button data-go="/${esc(segs.slice(0, i + 1).join('/'))}">${esc(s)}</button>`).join('');
|
||||
const v = fv = { root: main, repo, dir, items: [], sel: new Set(), selecting: false, last: null, filter: '', tok: null, limit: 300, stale: false };
|
||||
const sortCfg = () => store.get('files_sort', { k: 'name', d: 1 });
|
||||
const byName = n => v.items.find(x => x.name === n);
|
||||
const selItems = () => v.items.filter(x => v.sel.has(x.name));
|
||||
const visible = () => { const q = v.filter.trim().toLowerCase(); return sortItems(v.items, sortCfg()).filter(x => !q || x.name.toLowerCase().includes(q)); };
|
||||
|
||||
main.innerHTML = `<div id="ftop"></div><div class="crumbs">${crumbs}</div>
|
||||
<input type="search" id="ff" placeholder="Filter this folder" autocomplete="off" hidden><div id="items"><p class="empty">Loading…</p></div><p class="sub" id="fsum" style="text-align:center"></p>`;
|
||||
const go = p => { location.hash = hash(p); };
|
||||
|
||||
const drawTop = () => {
|
||||
const n = v.sel.size, one = n === 1 ? selItems()[0] : null;
|
||||
$('#ftop').innerHTML = v.selecting
|
||||
? `<div class="selbar"><div class="bar"><b class="selcount">${n ? n + ' selected' : 'Select items'}</b>
|
||||
<button class="btn ghost" data-a="all">${n && n === visible().length ? 'None' : 'All'}</button><button class="btn" data-a="done">Done</button></div>
|
||||
<div class="bar">${ro ? '' : `<button class="btn ghost" data-a="move" ${n ? '' : 'disabled'}>${icon('files')}Move</button>`}
|
||||
<button class="btn ghost" data-a="copy" ${n ? '' : 'disabled'}>${icon('copy')}Copy</button>
|
||||
${ro ? '' : `<button class="btn ghost" data-a="del" ${n ? '' : 'disabled'} style="color:var(--danger)">${icon('trash')}Delete</button>`}
|
||||
<button class="btn ghost" data-a="zip" ${n ? '' : 'disabled'}>${icon('dl')}Zip</button><button class="btn ghost" data-a="more" aria-label="More" ${one ? '' : 'disabled'}>${icon('more')}</button></div></div>`
|
||||
: `<div class="bar"><a class="btn ghost backbtn" href="#/files">${icon('back')}Libraries</a>
|
||||
${ro ? '' : `<button class="btn ghost" data-a="up">${icon('up')}Upload</button><button class="btn ghost" data-a="new">${icon('newdir')}New…</button>`}
|
||||
<button class="btn ghost" data-a="sel">${icon('check')}Select</button>
|
||||
<button class="btn ghost" data-a="sort" aria-label="Sort">${icon('sort')}</button>
|
||||
<button class="btn ghost" data-a="refresh" aria-label="Refresh">${icon('refresh')}</button><button class="btn ghost" data-a="lib" aria-label="More for this folder">${icon('more')}</button>
|
||||
<input id="upf" type="file" multiple hidden><input id="upd2" type="file" webkitdirectory hidden></div>`;
|
||||
};
|
||||
const drawItems = () => {
|
||||
const list = visible(), box = $('#items'), ff = $('#ff');
|
||||
ff.hidden = v.items.length < 8;
|
||||
const row = it => { const d = it.type === 'dir', on = v.sel.has(it.name);
|
||||
return `<div class="row it fr${on ? ' sel' : ''}" data-n="${esc(it.name)}" ${ro ? '' : 'draggable="true"'}>
|
||||
${v.selecting ? `<span class="ck${on ? ' on' : ''}" aria-hidden="true">${on ? icon('check') : ''}</span>` : ''}
|
||||
<button class="rmain" aria-label="${d ? 'Open folder ' : 'Open '}${esc(it.name)}"><span class="ic" style="background:${d ? '#e0a82b' : OFFICE_EXT.has(ext(it.name)) ? '#e0742b' : '#6a7280'}">${icon(d ? 'files' : 'file')}</span>
|
||||
<span class="grow"><b>${esc(it.name)}</b><small>${d ? fmtDate(it.mtime) : fmtSize(it.size) + ' · ' + fmtDate(it.mtime)}</small></span></button>
|
||||
${v.selecting ? '' : `<button class="ibtn" data-more aria-label="Actions for ${esc(it.name)}">${icon('more')}</button>`}</div>`; };
|
||||
box.innerHTML = (v.stale ? `<p class="sub" style="margin:0 0 8px" role="status">${v.stale === 'old' ? 'Showing the last saved list. ' : ''}${esc(v.staleWhy || '')}</p>` : '') + (list.length ? `<div class="list">${list.slice(0, v.limit).map(row).join('')}</div>${list.length > v.limit ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="fmore">Show ${Math.min(300, list.length - v.limit)} more (${list.length - v.limit} not shown)</button></div>` : ''}`
|
||||
: `<p class="empty">${v.items.length ? 'Nothing matches “' + esc(v.filter) + '”' : ro ? 'This folder is empty' : 'This folder is empty. Drop files here or tap Upload.'}</p>`);
|
||||
const fm = $('#fmore'); if (fm) fm.onclick = () => { v.limit += 300; drawItems(); };
|
||||
const dirs = v.items.filter(x => x.type === 'dir').length, files = v.items.length - dirs, size = v.items.reduce((a, x) => a + (x.size || 0), 0);
|
||||
$('#fsum').textContent = v.items.length ? [dirs && dirs + (dirs === 1 ? ' folder' : ' folders'), files && files + (files === 1 ? ' file' : ' files'), size && fmtSize(size)].filter(Boolean).join(' · ') : '';
|
||||
};
|
||||
const redraw = () => { drawTop(); drawItems(); };
|
||||
|
||||
const toggle = (it, range) => {
|
||||
const vis = visible().map(x => x.name);
|
||||
if (range && v.last && vis.includes(v.last) && vis.includes(it.name)) { const a = vis.indexOf(v.last), b = vis.indexOf(it.name); vis.slice(Math.min(a, b), Math.max(a, b) + 1).forEach(n => v.sel.add(n)); }
|
||||
else { v.sel.has(it.name) ? v.sel.delete(it.name) : v.sel.add(it.name); v.last = it.name; }
|
||||
v.selecting = true; redraw();
|
||||
};
|
||||
const itemSheet = it => it.type === 'dir' ? dirSheet(repo, joinP(dir, it.name), it.name, dir) : fileSheet(repo, joinP(dir, it.name), it.name, dir);
|
||||
const openIt = it => it.type === 'dir' ? go(joinP(dir, it.name)) : openFile(repo, joinP(dir, it.name), it.name, dir);
|
||||
const oneActions = it => {
|
||||
const p = joinP(dir, it.name), d = it.type === 'dir', a = [{ label: 'Share link', icon: 'copy', run: () => shareLink(repo, p, d) }];
|
||||
if (!ro) a.push({ label: 'Rename', icon: 'edit', run: () => renameItem(repo, p, it.name, dir, d) });
|
||||
if (!d) a.push({ label: 'Download', icon: 'dl', run: () => downloadItem(repo, p) });
|
||||
if (d) a.push({ label: 'Download as zip', icon: 'dl', run: () => zipDownload(repo, dir, [it.name]) });
|
||||
if (!d) a.push({ label: 'Version history', icon: 'refresh', run: () => fileHistory(repo, p, it.name, ro) });
|
||||
return a;
|
||||
};
|
||||
|
||||
const create = async (path, op, extra = {}, kind = 'file') => {
|
||||
try { await sf.req(`/api2/repos/${enc(repo)}/${kind}/?p=${enc(path)}`, { method: 'POST', body: new URLSearchParams({ operation: op, ...extra }), headers: FORM }); v.reload(true); }
|
||||
catch (e) { toast(e.message); }
|
||||
};
|
||||
const actions = {
|
||||
all: () => { const vis = visible(); if (v.sel.size === vis.length) v.sel.clear(); else vis.forEach(x => v.sel.add(x.name)); redraw(); },
|
||||
move: () => v.sel.size && moveSheet('move', repo, dir, selItems()),
|
||||
copy: () => v.sel.size && moveSheet('copy', repo, dir, selItems()),
|
||||
del: () => v.sel.size && deleteItems(repo, dir, selItems()),
|
||||
more: () => { const it = selItems()[0]; if (it) actionSheet(it.name, '', oneActions(it)); },
|
||||
done: () => { v.selecting = false; v.sel.clear(); redraw(); },
|
||||
sel: () => { v.selecting = true; redraw(); },
|
||||
refresh: () => v.reload(),
|
||||
zip: () => v.sel.size && zipDownload(repo, dir, [...v.sel]),
|
||||
lib: () => actionSheet(dir === '/' ? (libNames[repo] || 'Library') : dir.split('/').pop(), 'This folder', [
|
||||
{ label: 'Search this library', icon: 'refresh', run: () => libSearch(repo) },
|
||||
{ label: 'Recycle bin', icon: 'trash', run: () => recycleBin(repo, dir) },
|
||||
...(dir === '/' ? [] : [{ label: 'Download this folder as zip', icon: 'dl', run: () => zipDownload(repo, dir.replace(/\/[^/]*$/, '') || '/', [dir.split('/').pop()]) }]),
|
||||
...(v.items.length ? [{ label: 'Download everything here as zip', icon: 'dl', run: () => zipDownload(repo, dir, v.items.map(x => x.name)) }] : []),
|
||||
]),
|
||||
sort: () => { const c = sortCfg(); actionSheet('Sort by', 'Folders stay on top', SORTS.map(([k, l]) => ({ label: l + (c.k === k ? (c.d > 0 ? ' ↑' : ' ↓') : ''), icon: 'sort',
|
||||
run: () => { store.set('files_sort', { k, d: c.k === k ? -c.d : 1 }); drawItems(); } }))); },
|
||||
up: () => actionSheet('Upload', '', [{ label: 'Files', icon: 'up', run: () => $('#upf').click() }, { label: 'A folder', icon: 'newdir', run: () => $('#upd2').click() }]),
|
||||
new: () => actionSheet('Create new', '', [
|
||||
{ label: 'Folder', icon: 'newdir', run: () => textSheet('New folder', 'Folder name', 'Create', n => create(joinP(dir, n), 'mkdir', {}, 'dir'), '', n => badName(n, v.items)) },
|
||||
...[['Document', 'docx'], ['Spreadsheet', 'xlsx'], ['Presentation', 'pptx'], ['Text file', 'txt']].map(([label, x]) => ({ label, icon: 'doc',
|
||||
run: () => textSheet('New ' + label.toLowerCase(), 'File name', 'Create', async n => {
|
||||
const want = n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x;
|
||||
try { const name = await createFromTemplate(repo, dir, want, x); v.reload(true); if (x !== 'txt') openFile(repo, joinP(dir, name), name, dir); }
|
||||
catch (e) { toast(e.message); }
|
||||
}, '', n => badName(n.toLowerCase().endsWith('.' + x) ? n : n + '.' + x, v.items)) })),
|
||||
]),
|
||||
};
|
||||
v.actions = actions; v.selItems = selItems; v.openIt = openIt; v.visible = visible; v.one = oneActions;
|
||||
$('#ftop').onclick = e => { const b = e.target.closest('[data-a]'); if (b && !b.disabled && actions[b.dataset.a]) actions[b.dataset.a](); };
|
||||
$('#ftop').onchange = e => {
|
||||
const pick = e.target.id === 'upf' || e.target.id === 'upd2' ? [...e.target.files] : null; if (!pick) return;
|
||||
enqueueUploads(repo, dir, pick.map(f => ({ file: f, rel: f.webkitRelativePath ? f.webkitRelativePath.split('/').slice(0, -1).join('/') : '' }))); e.target.value = '';
|
||||
};
|
||||
$('#ff').oninput = e => { v.filter = e.target.value; drawItems(); };
|
||||
main.querySelector('.crumbs').onclick = e => { const b = e.target.closest('[data-go]'); if (b) go(b.dataset.go); };
|
||||
|
||||
// tap / click / ctrl-click / shift-click, right-click, and long-press on a touch screen
|
||||
const box = $('#items'); let suppress = false, lastTouch = 0, lp = null;
|
||||
box.onclick = e => {
|
||||
if (suppress) { suppress = false; return; }
|
||||
const r = e.target.closest('.fr'); if (!r) return; const it = byName(r.dataset.n); if (!it) return;
|
||||
if (e.target.closest('[data-more]')) return itemSheet(it);
|
||||
if (v.selecting || e.metaKey || e.ctrlKey || e.shiftKey) return toggle(it, e.shiftKey);
|
||||
openIt(it);
|
||||
};
|
||||
box.oncontextmenu = e => {
|
||||
const r = e.target.closest('.fr'); if (!r) return; e.preventDefault(); if (Date.now() - lastTouch < 1200) return;
|
||||
const it = byName(r.dataset.n); if (!it) return;
|
||||
if (v.selecting && v.sel.size > 1 && v.sel.has(it.name)) return actionSheet(v.sel.size + ' selected', '', [ ...(ro ? [] : [{ label: 'Move', icon: 'files', run: actions.move }]), { label: 'Copy', icon: 'copy', run: actions.copy }, ...(ro ? [] : [{ label: 'Delete', icon: 'trash', danger: true, run: actions.del }]) ]);
|
||||
itemSheet(it);
|
||||
};
|
||||
box.onpointerdown = e => {
|
||||
if (e.pointerType !== 'touch') return; lastTouch = Date.now();
|
||||
const r = e.target.closest('.fr'); if (!r || v.selecting) return; const x0 = e.clientX, y0 = e.clientY;
|
||||
const stop = () => { clearTimeout(lp); box.removeEventListener('pointermove', mv); };
|
||||
const mv = m => { if (Math.abs(m.clientX - x0) + Math.abs(m.clientY - y0) > 10) stop(); };
|
||||
box.addEventListener('pointermove', mv); box.addEventListener('pointerup', stop, { once: true }); box.addEventListener('pointercancel', stop, { once: true });
|
||||
lp = setTimeout(() => { stop(); const it = byName(r.dataset.n); if (!it) return; suppress = true; v.sel.add(it.name); v.last = it.name; v.selecting = true; redraw(); try { navigator.vibrate && navigator.vibrate(12); } catch {} }, 450);
|
||||
};
|
||||
box.onmouseover = e => { const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (it && it.type !== 'dir' && !ro) prefetchDl(repo, joinP(dir, it.name)); };
|
||||
|
||||
// Drag: out to the desktop (one file, Chrome/Edge), onto a folder or breadcrumb to move (Alt/Option copies), in from the computer to upload.
|
||||
box.ondragstart = e => {
|
||||
const r = e.target.closest('.fr'); const it = r && byName(r.dataset.n); if (!it) return;
|
||||
const names = v.sel.has(it.name) && v.sel.size ? [...v.sel] : [it.name];
|
||||
e.dataTransfer.setData('application/x-hub-items', JSON.stringify({ repo, dir, names })); e.dataTransfer.effectAllowed = 'copyMove';
|
||||
const c = it.type !== 'dir' && names.length === 1 && dlUrls.get(repo + joinP(dir, it.name));
|
||||
if (c && c.url) e.dataTransfer.setData('DownloadURL', `application/octet-stream:${it.name.replace(/:/g, '-')}:${c.url}`);
|
||||
else if (it.type !== 'dir') prefetchDl(repo, joinP(dir, it.name));
|
||||
};
|
||||
const target = e => { const t = e.target.closest('.fr, [data-go]'); if (!t) return null;
|
||||
if (t.dataset.go) return { el: t, path: t.dataset.go }; const it = byName(t.dataset.n); return it && it.type === 'dir' ? { el: t, path: joinP(dir, it.name) } : null; };
|
||||
const clearDrop = () => main.querySelectorAll('.dropt').forEach(x => x.classList.remove('dropt')), isFiles = e => [...(e.dataTransfer.types || [])].includes('Files');
|
||||
main.ondragover = e => {
|
||||
const mine = [...(e.dataTransfer.types || [])].includes('application/x-hub-items'); if (!mine && !isFiles(e) || ro) return;
|
||||
e.preventDefault(); clearDrop(); const t = target(e); if (t) t.el.classList.add('dropt'); else if (isFiles(e)) main.classList.add('dropt'); e.dataTransfer.dropEffect = mine && e.altKey ? 'copy' : mine ? 'move' : 'copy';
|
||||
};
|
||||
main.ondragleave = e => { if (!main.contains(e.relatedTarget)) { clearDrop(); main.classList.remove('dropt'); } };
|
||||
main.ondrop = async e => {
|
||||
if (ro) return; e.preventDefault(); clearDrop(); main.classList.remove('dropt');
|
||||
const t = target(e), mine = e.dataTransfer.getData('application/x-hub-items');
|
||||
if (mine) {
|
||||
const { names } = JSON.parse(mine); if (!t || t.path === dir || names.some(n => joinP(dir, n) === t.path)) return;
|
||||
try { await transferItems(e.altKey ? 'copy' : 'move', repo, dir, names, { repo, dir: t.path }); toast((e.altKey ? 'Copied' : 'Moved') + (names.length > 1 ? ' ' + names.length + ' items' : '')); } catch (err) { toast(err.message); }
|
||||
return afterChange(repo, dir);
|
||||
}
|
||||
if (isFiles(e)) { const files = await droppedFiles(e.dataTransfer); enqueueUploads(repo, t ? t.path : dir, files); }
|
||||
};
|
||||
|
||||
v.reload = async quiet => {
|
||||
const tok = v.tok = Symbol(), saved = dcGet(repo, dir);
|
||||
const restore = () => { if (v.scrolled) return; v.scrolled = true; const y = fScroll.get(location.hash); if (y) requestAnimationFrame(() => { const view = $('#view'); if (view) view.scrollTop = y; }); };
|
||||
if (!quiet) {
|
||||
if (!v.items.length && saved) { v.items = saved.items; v.stale = 'old'; v.staleWhy = 'Updating…'; redraw(); restore(); } // the saved list at once, the real one right behind it
|
||||
else $('#items').innerHTML = '<p class="empty">Loading…</p>';
|
||||
}
|
||||
try {
|
||||
const items = await sf.req(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`);
|
||||
if (v.tok !== tok || !document.contains(main)) return;
|
||||
v.items = items; v.stale = false; for (const n of [...v.sel]) if (!byName(n)) v.sel.delete(n);
|
||||
dcPut(repo, dir, items);
|
||||
redraw(); restore();
|
||||
} catch (e) {
|
||||
if (v.tok !== tok || !document.contains(main)) return;
|
||||
if (!sf.token()) return route();
|
||||
const why = navigator.onLine ? e.message : 'You are offline.';
|
||||
if (saved && v.items.length) { // keep showing the saved list instead of an error
|
||||
v.stale = 'old'; v.staleWhy = why + ' Saved ' + fmtDate(saved.t / 1000) + '. '; redraw(); restore();
|
||||
const n = $('#items .sub'); if (n) { n.insertAdjacentHTML('beforeend', '<a href="#" id="fretry2" style="color:var(--accent)">Try again</a>'); $('#fretry2').onclick = ev => { ev.preventDefault(); v.reload(); }; }
|
||||
return;
|
||||
}
|
||||
$('#items').innerHTML = `<p class="err">${esc(navigator.onLine ? e.message : 'You are offline. ' + e.message)}</p><div class="bar"><button class="btn ghost" id="fretry">Try again</button></div>`;
|
||||
$('#fretry').onclick = () => v.reload();
|
||||
}
|
||||
};
|
||||
drawTop(); v.reload();
|
||||
}
|
||||
|
||||
const joinP = (d, n) => (d === '/' ? '' : d) + '/' + n;
|
||||
// Keep the folder fresh when you come back to the tab, and add Finder-style keys.
|
||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && fv && document.contains(fv.root) && !upActive() && !document.getElementById('sheet')) fv.reload(true); });
|
||||
addEventListener('keydown', e => {
|
||||
if (!fv || !document.contains(fv.root) || document.getElementById('sheet') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '')) return;
|
||||
const mod = e.metaKey || e.ctrlKey, n = fv.sel.size, one = n === 1 ? fv.selItems()[0] : null, a = fv.actions;
|
||||
if (e.key === 'Escape' && (n || fv.selecting)) { a.done(); }
|
||||
else if (mod && e.key.toLowerCase() === 'a') { e.preventDefault(); fv.selecting = true; fv.visible().forEach(x => fv.sel.add(x.name)); a.sel(); }
|
||||
else if ((e.key === 'Delete' || e.key === 'Backspace') && n) { e.preventDefault(); a.del(); }
|
||||
else if (e.key === 'F2' && one) { e.preventDefault(); const p = joinP(fv.dir, one.name); renameItem(fv.repo, p, one.name, fv.dir, one.type === 'dir'); }
|
||||
else if (e.key === 'Enter' && one) fv.openIt(one);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
'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(); if (typeof pushResync === 'function') pushResync(); }
|
||||
function inboxPaint() { const n = document.getElementById('inboxn'); if (n) { n.hidden = !inboxN; n.textContent = inboxN > 99 ? '99+' : inboxN; } }
|
||||
/** What a read or cleared notification stands for, on this device: its banner on the lock screen goes, and an announcement stops showing on Home. (The server marks the chats and mail read.) */
|
||||
async function inboxSettled(items, all) {
|
||||
for (const i of items) if (i.ref && i.ref.announce) { try { localStorage.setItem('ch_ann_gone:' + (typeof ME !== 'undefined' && ME ? ME.id : ''), i.ref.announce); } catch {} const a = document.getElementById('annc'); if (a) a.innerHTML = ''; }
|
||||
try { const reg = await navigator.serviceWorker.ready, tags = items.map(i => i.tag).filter(Boolean); for (const n of await reg.getNotifications()) if (all || tags.includes(n.tag)) n.close(); } catch {}
|
||||
}
|
||||
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; inboxSettled(items.filter(i => i.id === 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 { inboxSettled(items.filter(i => !i.read), true); 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 { inboxSettled(items.filter(i => !i.read), true); await post('clear'); items = []; inboxN = 0; inboxPaint(); draw(); } catch (e) { toast(e.message); } };
|
||||
}
|
||||
+60
-3
@@ -49,10 +49,67 @@
|
||||
<body>
|
||||
<div id="shell">
|
||||
<nav id="nav" aria-label="Main"></nav>
|
||||
<div id="main"><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>
|
||||
<script src="/app.js"></script>
|
||||
<script defer src="/webauthn.js"></script>
|
||||
<script defer src="/links.js"></script>
|
||||
<script defer src="/palette.js"></script>
|
||||
<script defer src="/planner.js"></script>
|
||||
<script defer src="/reminders.js"></script>
|
||||
<script defer src="/file-history.js"></script>
|
||||
<script defer src="/music-extras.js"></script>
|
||||
<script defer src="/music-more.js"></script>
|
||||
<script defer src="/screentime-view.js"></script>
|
||||
<script defer src="/calendar-views.js"></script>
|
||||
<script defer src="/app.js"></script>
|
||||
<script defer src="/search-view.js"></script>
|
||||
<script defer src="/today.js"></script>
|
||||
<script defer src="/calendar.js"></script>
|
||||
<script defer src="/tasks.js"></script>
|
||||
<script defer src="/audiobooks.js"></script>
|
||||
<script defer src="/music.js"></script>
|
||||
<script defer src="/offline-queue.js"></script>
|
||||
<script defer src="/video.js"></script>
|
||||
<script defer src="/office.js"></script>
|
||||
<script defer src="/player.js"></script>
|
||||
<script defer src="/offline.js"></script>
|
||||
<script defer src="/podcast-view.js"></script>
|
||||
<script defer src="/reader.js"></script>
|
||||
<script defer src="/audiobooks-view.js"></script>
|
||||
<script defer src="/librivox-view.js"></script>
|
||||
<script defer src="/radio-view.js"></script>
|
||||
<script defer src="/store-view.js"></script>
|
||||
<script defer src="/recipes-view.js"></script>
|
||||
<script defer src="/contacts-view.js"></script>
|
||||
<script defer src="/bible-view.js"></script>
|
||||
<script defer src="/devotion-view.js"></script>
|
||||
<script defer src="/save-to-files.js"></script>
|
||||
<script defer src="/vbv-view.js"></script>
|
||||
<script defer src="/lyrics-view.js"></script>
|
||||
<script defer src="/visualizer.js"></script>
|
||||
<script defer src="/settings.js"></script>
|
||||
<script defer src="/files.js"></script>
|
||||
<script defer src="/files-extra.js"></script>
|
||||
<script defer src="/sheets.js"></script>
|
||||
<script defer src="/file-actions.js"></script>
|
||||
<script defer src="/signal-view.js"></script>
|
||||
<script defer src="/chat-view.js"></script>
|
||||
<script defer src="/notes-view.js"></script>
|
||||
<script defer src="/chores-view.js"></script>
|
||||
<script defer src="/family-view.js"></script>
|
||||
<script defer src="/mail.js"></script>
|
||||
<script defer src="/share.js"></script>
|
||||
<script defer src="/cast.js"></script>
|
||||
<script defer src="/rows.js"></script>
|
||||
<script defer src="/keys.js"></script>
|
||||
<script defer src="/inbox-view.js"></script>
|
||||
<script defer src="/device-ui.js"></script>
|
||||
<script defer src="/settings-family.js"></script>
|
||||
<script defer src="/simple-mode.js"></script>
|
||||
<script defer src="/themes.js"></script>
|
||||
<script defer src="/automations-view.js"></script>
|
||||
<script defer src="/boot.js"></script>
|
||||
<script defer src="/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'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>${allowed('chat') ? `<button class="btn ghost" id="lvh">${icon('send')}Share</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('#lvh')) el.querySelector('#lvh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: b.title, sub: b.author || '', link: '#/audio/book/lv:' + id }); }; // (no picture: LibriVox covers live on another site)
|
||||
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); } };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Links tab: your own bookmarks, grouped. Saved with the other per-person settings (see app.js: store).
|
||||
// ---- Links: your own bookmarks, grouped, saved with your other settings (private to you, every device)
|
||||
const safeUrl = s => { let t = String(s || '').trim(); if (!t) return ''; if (!/^[a-z][a-z0-9+.-]*:/i.test(t)) t = 'https://' + t; try { const u = new URL(t); return /^https?:$/.test(u.protocol) ? u.href : ''; } catch { return ''; } };
|
||||
const linkList = () => { const l = store.get('links', []); return Array.isArray(l) ? l.filter(x => x && safeUrl(x.url)) : []; };
|
||||
const LINK_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f9d9a', '#c2417a'];
|
||||
const linkColor = l => LINK_COLORS[[...String(l.title || l.url)].reduce((n, c) => n + c.charCodeAt(0), 0) % LINK_COLORS.length];
|
||||
/** Links that come with the hub rather than from you (Podcast Admin, for people who may use it). They cannot be edited or deleted. */
|
||||
const builtinLinks = () => allowed('verse') && cfg.verse && safeUrl(cfg.verse) ? [{ id: 'builtin-verse', title: 'Podcast Admin', url: safeUrl(cfg.verse), group: 'Podcast', builtin: true }] : [];
|
||||
const allLinks = () => [...builtinLinks(), ...linkList()];
|
||||
let linkQuery = '';
|
||||
function viewLinks() {
|
||||
const all = allLinks(), q = linkQuery.trim().toLowerCase();
|
||||
const shown = q ? all.filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(q)) : all;
|
||||
const groups = [...new Set(shown.map(l => l.group || ''))].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Links</h1>
|
||||
<div class="bar"><input type="search" id="lq" placeholder="Filter links" value="${esc(linkQuery)}" aria-label="Filter links" style="flex:1">${!ME || ME.tabs.includes('links') ? `<button class="btn" id="ladd">${icon('plus')}Add</button>` : ''}</div>
|
||||
${all.length ? '' : '<p class="empty">No links yet. Add the sites you use most; they are saved on your server and show up on every device.</p>'}
|
||||
${all.length && !shown.length ? '<p class="empty">Nothing matches.</p>' : ''}
|
||||
${groups.map(g => `${g ? `<h2>${esc(g)}</h2>` : (groups.length > 1 ? '<h2>Other</h2>' : '')}<div class="list">${shown.filter(l => (l.group || '') === g).map(l => `
|
||||
<div class="row"><a class="grow" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer" style="display:flex;align-items:center;gap:12px;color:inherit;text-decoration:none;min-width:0">
|
||||
<span class="ic" style="background:${linkColor(l)};color:#fff;font-weight:700">${esc([...(l.title || l.url)][0].toUpperCase())}</span>
|
||||
<span class="grow" style="min-width:0"><b>${esc(l.title || l.url)}</b><small style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(new URL(safeUrl(l.url)).host)}</small></span></a>
|
||||
${l.builtin ? '<small style="color:var(--muted)">Built in</small>' : `<button class="btn ghost" data-edit="${esc(l.id)}" aria-label="Edit ${esc(l.title)}" style="min-height:36px">Edit</button>`}</div>`).join('')}</div>`).join('')}</div>`;
|
||||
$('#lq').oninput = e => { linkQuery = e.target.value; const p = e.target.selectionStart; viewLinks(); const n = $('#lq'); n.focus(); n.setSelectionRange(p, p); };
|
||||
if ($('#ladd')) $('#ladd').onclick = () => linkSheet();
|
||||
document.querySelectorAll('[data-edit]').forEach(b => b.onclick = () => linkSheet(all.find(l => l.id === b.dataset.edit)));
|
||||
}
|
||||
function linkSheet(link) {
|
||||
const groups = [...new Set(linkList().map(l => l.group).filter(Boolean))];
|
||||
const el = openSheet(link ? 'Edit link' : 'Add a link', '', `<form id="lf"><label for="lt">Name</label><input type="text" id="lt" required autocomplete="off" value="${esc(link ? link.title : '')}" placeholder="Router">
|
||||
<label for="lu">Address</label><input type="text" id="lu" required autocomplete="off" autocapitalize="off" inputmode="url" value="${esc(link ? link.url : '')}" placeholder="https://192.168.1.1">
|
||||
<label for="lg">Group (optional)</label><input type="text" id="lg" list="lgl" autocomplete="off" value="${esc(link ? link.group || '' : '')}" placeholder="Home network"><datalist id="lgl">${groups.map(g => `<option value="${esc(g)}">`).join('')}</datalist>
|
||||
<div class="bar">${link ? '<button class="btn danger" type="button" id="ld" style="flex:1;justify-content:center">Delete</button>' : '<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><div class="err" id="le"></div></form>`);
|
||||
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
|
||||
if (link) el.querySelector('#ld').onclick = () => confirmSheet('Delete link?', link.title, 'Delete', () => { store.set('links', linkList().filter(l => l.id !== link.id)); viewLinks(); });
|
||||
el.querySelector('#lf').onsubmit = e => {
|
||||
e.preventDefault();
|
||||
const url = safeUrl(el.querySelector('#lu').value);
|
||||
if (!url) { el.querySelector('#le').textContent = 'That is not a web address (it must be http or https).'; return; }
|
||||
const next = { id: link ? link.id : 'l' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), title: el.querySelector('#lt').value.trim().slice(0, 60), url, group: el.querySelector('#lg').value.trim().slice(0, 40) };
|
||||
const rest = linkList();
|
||||
store.set('links', link ? rest.map(l => l.id === link.id ? next : l) : [...rest, next]);
|
||||
closeSheet(); viewLinks();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+36
-4
@@ -6,19 +6,51 @@ 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>';
|
||||
$('#go').onclick = signIn;
|
||||
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device? Use an invite code</a></p>');
|
||||
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device or lost your devices? Use an invite or recovery code</a></p>');
|
||||
$('#inv').onclick = e => {
|
||||
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
|
||||
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>.</p>
|
||||
<label for="ic">Invite code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
|
||||
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>. Lost every device? Use one of the <b>recovery codes</b> you made in Settings.</p>
|
||||
<label for="ic">Invite or recovery code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
|
||||
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
|
||||
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
|
||||
$('#f').onsubmit = async ev => {
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
'use strict';
|
||||
// ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song.
|
||||
function lyrParseLrc(text) {
|
||||
const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
|
||||
const lines = [];
|
||||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||
const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
|
||||
if (!tags.length) continue;
|
||||
let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
|
||||
if (/<\d+:\d{1,2}/.test(body)) {
|
||||
const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
|
||||
words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
|
||||
words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
|
||||
body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
|
||||
}
|
||||
lines.sort((a, b) => a.t - b.t);
|
||||
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 {} };
|
||||
// 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:' + 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)
|
||||
try {
|
||||
const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key);
|
||||
if (!st) return null;
|
||||
const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null;
|
||||
let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics)
|
||||
const timed = lyrParseLrc(text);
|
||||
if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) };
|
||||
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) {
|
||||
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(id, res);
|
||||
return lyrForRadio(t, res);
|
||||
}
|
||||
|
||||
function lyrOpen() {
|
||||
const t = cur(); if (!t || lyr.open) return;
|
||||
lyr.open = true; document.body.classList.add('lyropen');
|
||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||
}
|
||||
function lyrClose() {
|
||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||
}
|
||||
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 && !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>
|
||||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
||||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
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('#lyrsh').onclick = lyrShare;
|
||||
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
||||
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
||||
syncUI();
|
||||
}
|
||||
async function lyrReload(force) {
|
||||
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 = 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 = '';
|
||||
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') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
||||
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
||||
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
||||
body.scrollTop = 0; lyr.idx = -2;
|
||||
}
|
||||
function lyrSearchAgain() {
|
||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
||||
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||||
<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:' + 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 = 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) {
|
||||
lyr.idx = idx; let n = -1;
|
||||
nodes.forEach(x => { const i = +x.dataset.i; x.classList.toggle('on', i === idx); x.classList.toggle('past', i < idx); x.classList.toggle('near', Math.abs(i - idx) === 1); x.classList.toggle('upnext', i === idx + 1); if (i === idx) n = i; });
|
||||
const cur1 = body.querySelector('.ll.on'); if (cur1) cur1.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
const on = body.querySelector('.ll.on'); if (!on) return; const l = L[idx];
|
||||
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'));
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
'use strict';
|
||||
// Mail tab: your Outlook.com mailbox (through the server, see mail.js there). Folders, message list, reader, compose, reply/forward,
|
||||
// search, unread badge. Message bodies are shown in a sandboxed frame with remote pictures blocked until you ask for them.
|
||||
let mailUnread = 0;
|
||||
const mail = { st: null, accts: [], acct: '', folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
|
||||
|
||||
// The last mail you looked at is kept on this device (subjects, senders, dates; no message bodies) so Mail can show it the instant you open the tab
|
||||
// while the fresh copy loads behind it. Removed when you disconnect an account or sign out.
|
||||
const mcKey = k => 'mc:' + (ME ? ME.id : '') + ':' + k;
|
||||
const mcGet = k => { try { const o = JSON.parse(localStorage.getItem(mcKey(k))); return o && o.t ? o : null; } catch { return null; } };
|
||||
function mcPut(k, v) {
|
||||
try {
|
||||
localStorage.setItem(mcKey(k), JSON.stringify({ t: Date.now(), ...v }));
|
||||
const ix = mcKey('index'), list = (JSON.parse(localStorage.getItem(ix) || '[]')).filter(x => x !== k); list.unshift(k);
|
||||
for (const old of list.splice(16)) localStorage.removeItem(mcKey(old));
|
||||
localStorage.setItem(ix, JSON.stringify(list));
|
||||
} catch { /* storage full or blocked: go without */ }
|
||||
}
|
||||
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 '';
|
||||
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
if (now - d < 6 * 864e5) return d.toLocaleDateString([], { weekday: 'short' });
|
||||
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : '2-digit' });
|
||||
};
|
||||
const mailFolderName = id => id === 'unified' ? 'All inboxes' : (mail.folders.find(f => f.id === id) || {}).name || (/~inbox$/.test(id) || id === 'inbox' ? 'Inbox' : 'Folder');
|
||||
|
||||
/** The page a message is shown in: no scripts, nothing loaded from the internet unless the person allows pictures. */
|
||||
function mailDoc(html, images) {
|
||||
const csp = `default-src 'none'; img-src data: ${images ? 'https: http:' : ''}; style-src 'unsafe-inline'; font-src data:`;
|
||||
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(); } if (location.hash.startsWith('#/mail') && typeof mailDrawChips === 'function') mailDrawChips(); } catch {}
|
||||
}
|
||||
async function renderMailCard(el) {
|
||||
if (!el) return;
|
||||
try {
|
||||
const s = mail.st || await jfetch('/api/mail/status'); mail.st = s;
|
||||
if (!s.connected || !document.body.contains(el)) return;
|
||||
const lists = await Promise.all(s.accounts.slice(0, 4).map(a => jfetch('/api/mail/messages?folder=' + encodeURIComponent(a.id + '~inbox')).then(r => r.items.map(x => ({ ...x, acct: a.address }))).catch(() => [])));
|
||||
if (!document.body.contains(el)) return;
|
||||
const top = lists.flat().filter(m => !m.read).sort((a, b) => String(b.date).localeCompare(String(a.date))).slice(0, 3);
|
||||
el.innerHTML = `<h2>Mail${s.unread ? ' · ' + s.unread + ' unread' : ''}</h2><div class="list">${top.length ? top.map(m => `<a class="row" href="#/mail/m/${encodeURIComponent(m.id)}"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>${esc(mailFrom(m.from))}</b><small>${esc(m.subject)}${s.accounts.length > 1 ? ' · ' + esc(m.acct) : ''}</small></span></a>`).join('') : `<a class="row" href="#/mail"><span class="ic" style="background:#4f7cff">${icon('mail')}</span><span class="grow"><b>Inbox is clear</b><small>${esc(s.accounts.map(a => a.address).join(', '))}</small></span></a>`}</div>`;
|
||||
} catch {}
|
||||
}
|
||||
const MAIL_LABEL = { outlook: 'Outlook.com', gmail: 'Gmail', icloud: 'iCloud Mail' };
|
||||
function mailSettings() {
|
||||
const box = $('#mailst'); if (!box) return;
|
||||
if (allowed('mail') && cfg.mail) jfetch('/api/mail/status').then(s => {
|
||||
if (!$('#mailst')) return;
|
||||
box.innerHTML = (s.accounts.length ? s.accounts.map(a => `<div class="row"><span class="grow"><b>${esc(MAIL_LABEL[a.type] || a.type)}</b><small>${esc(a.address || 'Connected')}${a.error ? ' · ' + esc(a.error) : ''}</small></span><button class="btn danger" data-dis="${esc(a.id)}" style="min-height:36px">Disconnect</button></div>`).join('') : `<div class="row"><span class="grow"><b>No mail account yet</b><small>${s.expired ? 'Microsoft signed you out. ' : ''}Add Outlook.com, Gmail or iCloud Mail</small></span></div>`)
|
||||
+ `<div class="row"><span class="grow"></span><button class="btn ghost" id="madd" style="min-height:36px">Add an account</button></div>`;
|
||||
$('#madd').onclick = () => mailAddAccount(s);
|
||||
box.querySelectorAll('[data-dis]').forEach(d => d.onclick = () => { const a = s.accounts.find(x => x.id === d.dataset.dis); confirmSheet('Disconnect ' + (a ? a.address : 'this account') + '?', 'NE Hub forgets the sign-in. Your mailbox is not touched.', 'Disconnect', async () => { try { await jfetch('/api/mail/disconnect', { method: 'POST', body: JSON.stringify({ acct: d.dataset.dis }) }); mail.st = null; mcClear(); mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); });
|
||||
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
|
||||
else box.innerHTML = '';
|
||||
if (cfg.mailAdmin) mailAdminCard();
|
||||
}
|
||||
|
||||
/** Choose a provider; Outlook.com signs in on Microsoft's page, Gmail and iCloud take an app password. */
|
||||
function mailAddAccount(st) {
|
||||
const choices = [['gmail', 'Gmail', 'App password'], ['icloud', 'iCloud Mail', 'App-specific password'], ...(st && st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft']] : [])];
|
||||
const el = openSheet('Add a mail account', '', `<div class="list">${choices.map(([k, l, s]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${k === 'gmail' ? '#d9453a' : k === 'icloud' ? '#3b97e8' : '#0a64c8'}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
|
||||
const k = b.dataset.p;
|
||||
if (k === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { toast(e.message); } return; }
|
||||
mailPasswordForm(k);
|
||||
});
|
||||
}
|
||||
function mailPasswordForm(type) {
|
||||
const g = type === 'gmail';
|
||||
const el = openSheet('Add ' + (g ? 'Gmail' : 'iCloud Mail'), '', `<form id="paf">
|
||||
<p class="sub" style="margin-top:0">${g ? 'Google needs an <b>app password</b>, not your normal password. Turn on 2-Step Verification, then make one at <b>myaccount.google.com/apppasswords</b> (a 16-letter code).' : 'Apple needs an <b>app-specific password</b>. Go to <b>appleid.apple.com</b> → Sign-In and Security → App-Specific Passwords, and make one for NE Hub.'}</p>
|
||||
<label for="pae">Email address</label><input type="text" id="pae" inputmode="email" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="${g ? 'you@gmail.com' : 'you@icloud.com'}" required>
|
||||
<label for="pap">${g ? 'App password' : 'App-specific password'}</label><input type="password" id="pap" autocomplete="new-password" required>
|
||||
<p class="sub" style="margin:6px 0 0">It is checked with ${g ? 'Google' : 'Apple'} before saving, and kept encrypted on your server. NE Hub never sees your normal password.</p>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="pab" style="flex:1;justify-content:center">Connect</button></div><div class="err" id="pax" role="alert"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#paf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('#pab'); btn.disabled = true; btn.textContent = 'Checking…'; el.querySelector('#pax').textContent = '';
|
||||
try { await jfetch('/api/mail/accounts', { method: 'POST', body: JSON.stringify({ type, address: el.querySelector('#pae').value, password: el.querySelector('#pap').value }) }); closeSheet(); toast('Connected'); mail.st = null; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); if (location.hash.startsWith('#/mail')) route(); else if ($('#mailst')) mailSettings(); }
|
||||
catch (err) { btn.disabled = false; btn.textContent = 'Connect'; el.querySelector('#pax').textContent = err.message; }
|
||||
};
|
||||
setTimeout(() => el.querySelector('#pae').focus(), 60);
|
||||
}
|
||||
|
||||
/** Admin: the Microsoft app behind Mail (client ID, secret, expiry), with the redirect address to register and a test button. */
|
||||
async function mailAdminCard() {
|
||||
let host = $('#mailadm');
|
||||
if (!host) { $('#sec-mail').insertAdjacentHTML('beforeend', '<div id="mailadm"></div>'); host = $('#mailadm'); }
|
||||
let a; try { a = await jfetch('/api/mail/admin'); } catch (e) { host.innerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||
if (!$('#mailadm')) return;
|
||||
const warn = a.days === null ? '' : a.days < 0 ? `<p class="err" style="margin:8px 0 0">The client secret expired ${-a.days} day${a.days === -1 ? '' : 's'} ago. Mail cannot connect until you replace it.</p>`
|
||||
: a.days <= 30 ? `<p class="err" style="margin:8px 0 0">The client secret expires in ${a.days} day${a.days === 1 ? '' : 's'} (${esc(a.secretExpires)}). Make a new one in Microsoft Entra and paste it below.</p>` : '';
|
||||
host.innerHTML = `<h2>Microsoft app (admin)</h2>
|
||||
<p class="sub">Mail uses a free Microsoft app registration. ${a.source === 'env' ? 'This server gets it from its environment settings (MS_CLIENT_ID), so change it there.' : a.source === 'app' ? 'Saved here, encrypted on your server.' : 'Not set up yet: follow the steps below, then paste the values here.'}</p>
|
||||
${warn}
|
||||
<p class="sub" style="margin:8px 0 0">${a.verifiedAt ? '✓ Last successful connection: ' + esc(new Date(a.verifiedAt).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' })) : a.clientId ? 'Not proven yet. Open the Mail tab and press Connect: Microsoft checks everything then.' : ''}</p>
|
||||
<div class="card" style="max-width:none">
|
||||
<label>Redirect address to register with Microsoft</label>
|
||||
<div style="display:flex;gap:8px"><input type="text" id="mredir" readonly value="${esc(a.redirect)}" style="flex:1"><button class="btn ghost" id="mcopy" type="button">Copy</button></div>
|
||||
<p class="sub" style="margin:4px 0 0">It must match exactly, and be https (set PUBLIC_URL on the server if this shows the wrong address).</p>
|
||||
${a.source === 'env' ? `<label>Application (client) ID</label><input type="text" readonly value="${esc(a.clientId)}"><p class="sub">Client secret: ${a.hasSecret ? 'set' : 'not set'}</p>` : `<form id="madm">
|
||||
<label for="mcid">Application (client) ID</label><input type="text" id="mcid" value="${esc(a.clientId)}" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="1a2b3c4d-1234-5678-9abc-def012345678">
|
||||
<label for="mcsec">Client secret (the Value)</label><input type="password" id="mcsec" autocomplete="new-password" placeholder="${a.hasSecret ? 'Saved. Leave blank to keep it' : 'Leave blank if the app has no secret'}">
|
||||
<label for="mcexp">Secret expires on</label><input type="date" id="mcexp" value="${esc(a.secretExpires)}"><p class="sub" style="margin:4px 0 0">Microsoft secrets last at most 24 months. NE Hub warns you a month before.</p>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div>
|
||||
${a.source === 'app' ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="mclear" style="flex:1;justify-content:center">Remove these settings</button></div>' : ''}
|
||||
<div id="mres" class="err" role="status"></div></form>`}
|
||||
</div>
|
||||
<details style="margin-top:10px"><summary style="cursor:pointer;color:var(--accent)">How to register the app (about 5 minutes)</summary>
|
||||
<ol class="sub" style="line-height:1.55;padding-left:20px">
|
||||
<li>Go to <b>entra.microsoft.com</b> and sign in with any Microsoft account. Accept the free "Default Directory" if asked.</li>
|
||||
<li><b>Entra ID → App registrations → New registration</b>. Name it NE Hub, choose <b>Personal Microsoft accounts only</b>, set the redirect platform to <b>Web</b>, paste the redirect address above, press Register.</li>
|
||||
<li>Copy the <b>Application (client) ID</b> from the Overview page into the first box above.</li>
|
||||
<li><b>API permissions → Add a permission → Microsoft Graph → Delegated</b>: Mail.ReadWrite, Mail.Send, User.Read, offline_access.</li>
|
||||
<li><b>Certificates & secrets → New client secret</b> (24 months). Copy its <b>Value</b> now (shown once) into the secret box, and set the expiry date.</li>
|
||||
<li>Press Save, then open the Mail tab and press <b>Connect</b>. Microsoft checks the ID, secret and redirect address at that moment; if something is wrong it says what.</li>
|
||||
</ol></details>`;
|
||||
$('#mcopy').onclick = async () => { try { await navigator.clipboard.writeText(a.redirect); toast('Copied'); } catch { $('#mredir').select(); toast('Press Ctrl/Cmd-C to copy'); } };
|
||||
const f = $('#madm'); if (!f) return;
|
||||
const res = (t, good) => { const el = $('#mres'); el.textContent = t; el.style.color = good ? 'var(--accent)' : ''; };
|
||||
const save = async extra => jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: $('#mcid').value, clientSecret: $('#mcsec').value, secretExpires: $('#mcexp').value, ...(extra || {}) }) });
|
||||
f.onsubmit = async e => { e.preventDefault(); res(''); try { await save(); const r = await jfetch('/api/config'); cfg = { ...cfg, ...r }; toast('Saved. Now open the Mail tab and press Connect.'); renderNav(); mailSettings(); } catch (err) { res(err.message); } };
|
||||
if ($('#mclear')) $('#mclear').onclick = () => confirmSheet('Remove the Microsoft app settings?', 'Mail stops working for everyone until you set it up again. Connected mailboxes stay saved.', 'Remove', async () => { try { await jfetch('/api/mail/admin', { method: 'POST', body: JSON.stringify({ clientId: '', clearSecret: true, secretExpires: '' }) }); const c = await jfetch('/api/config'); cfg = { ...cfg, ...c }; renderNav(); mailSettings(); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
|
||||
// ---- the tab
|
||||
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;
|
||||
if (!st) {
|
||||
const saved = mcGet('st'); // the accounts as of last time: draw from them now and check with the server behind it
|
||||
if (saved && saved.connected) {
|
||||
st = saved; mail.st = saved;
|
||||
jfetch('/api/mail/status?light=1').then(fresh => { if (!fresh.connected || fresh.accounts.map(a => a.id).join() !== saved.accounts.map(a => a.id).join()) { mail.st = null; mcClear(); if (location.hash.startsWith('#/mail')) route(); } else { mail.st = { ...fresh, accounts: fresh.accounts.map(a => ({ ...a, unread: (mail.st.accounts.find(x => x.id === a.id) || {}).unread ?? a.unread })) }; mcPut('st', mail.st); } }).catch(() => {});
|
||||
} else {
|
||||
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
|
||||
try { st = await jfetch('/api/mail/status?light=1'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||
if (st.connected) mcPut('st', { connected: true, accounts: st.accounts, outlook: st.outlook, providers: st.providers });
|
||||
}
|
||||
}
|
||||
if (!location.hash.startsWith('#/mail')) return;
|
||||
mail.st = st; mail.accts = st.accounts || [];
|
||||
if (!st.connected) { mail.st = null; mcClear(); return mailConnectScreen(st); }
|
||||
const [kind, key] = args;
|
||||
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
|
||||
const want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
|
||||
const keepUni = mail.folder === 'unified' && kind === 'm'; // reading a message from the all-inboxes list keeps that list behind it
|
||||
if (want !== mail.acct) { mail.acct = want; mail.folders = []; if (!keepUni) { mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; } }
|
||||
if (kind === 'f' && key) mail.folder = key === 'unified' ? 'unified' : idAcct(key) ? key : mail.acct + '~' + key;
|
||||
else if (mail.folder !== 'unified' && !String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
|
||||
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
|
||||
mailShell(kind === 'm' && key ? 'read' : 'list');
|
||||
const acct = mail.acct;
|
||||
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); else mailDrawList();
|
||||
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
|
||||
// folders (with counts) and the real unread numbers arrive when they arrive
|
||||
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.length > 1 ? `<button type="button" role="tab" data-a="unified" aria-selected="${mail.folder === 'unified'}" class="${mail.folder === 'unified' ? 'on' : ''}"><span class="mal">All inboxes</span><small>every account</small></button>` : ''}${mail.accts.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct && mail.folder !== 'unified'}" class="${a.id === mail.acct && mail.folder !== 'unified' ? '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 === 'unified') { mail.q = ''; location.hash = '#/mail/f/unified'; return; } if (b.dataset.a === mail.acct && mail.folder !== 'unified') 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 = (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) {
|
||||
const choices = [['gmail', 'Gmail', 'Uses an app password', '#d9453a'], ['icloud', 'iCloud Mail', 'Uses an app-specific password', '#3b97e8'], ...(st.outlook ? [['outlook', 'Outlook.com', 'Sign in with Microsoft', '#0a64c8']] : [])];
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Mail</h1>
|
||||
<p class="sub">Read and send mail from your own accounts right here, all in one place. Passwords are checked with the provider and kept encrypted on your server; Outlook.com signs in on Microsoft's own page.</p>
|
||||
${st.expired ? '<p class="err">Microsoft signed you out of mail. Connect again to continue.</p>' : ''}
|
||||
<div class="list">${choices.map(([k, l, s, c]) => `<button class="row" data-p="${k}"><span class="ic" style="background:${c}">${icon('mail')}</span><span class="grow"><b>${l}</b><small>${s}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="mce"></div>
|
||||
${st.outlook ? '' : '<p class="sub">Outlook.com needs a one-time Microsoft app set up by an admin (Settings → Microsoft app).</p>'}</div>`;
|
||||
$('#view').querySelectorAll('[data-p]').forEach(b => b.onclick = async () => {
|
||||
if (b.dataset.p === 'outlook') { try { const r = await jfetch('/api/mail/connect'); location.href = r.url; } catch (e) { $('#mce').textContent = e.message; } }
|
||||
else mailPasswordForm(b.dataset.p);
|
||||
});
|
||||
}
|
||||
|
||||
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 ? 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 ? 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="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); };
|
||||
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); };
|
||||
}
|
||||
|
||||
async function mailLoadList(reset) {
|
||||
const folder = mail.folder, first = reset && !mail.q;
|
||||
if (reset) {
|
||||
mail.items = []; mail.next = 0; mail.more = false;
|
||||
const saved = first && mcGet('l:' + folder);
|
||||
if (saved) { mail.items = saved.items; mail.more = saved.more; mail.next = saved.next; mailDrawList(); } // last time's list, right away
|
||||
else { const rows = $('#mrows'); if (rows) rows.innerHTML = '<p class="empty">Loading…</p>'; }
|
||||
}
|
||||
const showing = reset && mail.items.length; // a saved list is on screen: the fresh one replaces it instead of being added to it
|
||||
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 ? 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(); 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; 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 && !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 ? `<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 = 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$|^unified$/.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 (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(() => {
|
||||
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>`; }
|
||||
}
|
||||
|
||||
function mailDrawReader() {
|
||||
const rd = $('#mread'); if (!rd) return;
|
||||
const m = mail.msg;
|
||||
if (!m) { rd.innerHTML = '<p class="empty" style="padding:24px">Choose a message to read it.</p>'; return; }
|
||||
const addr = l => l.map(a => esc(a.name ? `${a.name} <${a.address}>` : a.address)).join(', ');
|
||||
const remote = mailHasRemote(m.html) && !mail.images;
|
||||
rd.innerHTML = `<div class="mrhead"><a class="btn ghost mback" href="#/mail/f/${encodeURIComponent(mail.folder)}">${icon('back')}${esc(mailFolderName(mail.folder))}</a>
|
||||
<h2 class="msubj">${esc(m.subject)}</h2>
|
||||
<p class="mfrom"><b>${esc(mailFrom(m.from))}</b> <small>${esc(m.from.address)}</small></p>
|
||||
<p class="mto"><small>To ${addr(m.to) || 'me'}${m.cc.length ? ' · Cc ' + addr(m.cc) : ''} · ${esc(new Date(m.date).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }))}</small></p>
|
||||
<div class="mact">
|
||||
<button class="btn" data-a="reply">Reply</button><button class="btn ghost" data-a="replyAll">Reply all</button><button class="btn ghost" data-a="forward">Forward</button>
|
||||
<button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost" data-a="snooze">Snooze</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>${allowed('files') ? `<button class="btn ghost" data-att="${esc(a.id)}" aria-label="Save ${esc(a.name)} to Files" title="Save to Files">${icon('files')}</button>` : ''}`).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-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));
|
||||
rd.querySelectorAll('[data-att]').forEach(b => b.onclick = () => { const at = m.attachments.find(x => x.id === b.dataset.att); if (at) saveToFiles(at.name, { kind: 'mail-attachment', id: m.id, aid: at.id }); });
|
||||
}
|
||||
|
||||
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 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') return away('move', { to: 'archive' }, 'Archived');
|
||||
if (a === 'snooze') return mailSnoozeSheet(m, async until => { await post('snooze', { until, subject: m.subject, from: mailFrom(m.from) }); toast('Snoozed until ' + new Date(until).toLocaleString([], { weekday: 'short', hour: 'numeric', minute: '2-digit' })); back(); });
|
||||
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', () => away('delete', { permanent: true }, 'Deleted'));
|
||||
return away('delete', {}, 'Moved to Deleted');
|
||||
}
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
// ---- compose, reply, forward
|
||||
const MAIL_MAX = 3 * 1024 * 1024;
|
||||
function mailCompose(o = {}) {
|
||||
const reply = o.mode, m = o.msg, files = [];
|
||||
const title = reply === 'reply' ? 'Reply' : reply === 'replyAll' ? 'Reply all' : reply === 'forward' ? 'Forward' : 'New message';
|
||||
const el = openSheet(title, reply ? m.subject : '', `<form id="mf">
|
||||
${reply && reply !== 'forward' ? `<p class="sub" style="margin:0">To ${esc(reply === 'reply' ? mailFrom(m.from) : 'everyone on the message')}. The original is quoted below your text automatically.</p>` : `${mail.accts.length > 1 ? `<label for="mfrom">From</label><select id="mfrom">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}<label for="mto">To</label><input type="text" id="mto" autocomplete="off" autocapitalize="off" inputmode="email" placeholder="name@example.com, another@example.com" required>`}
|
||||
${reply ? '' : `<div id="mccw"><a href="#" id="mcc" style="color:var(--accent);font-size:14px">Add Cc / Bcc</a></div><div id="mcb" hidden><label for="mcc2">Cc</label><input type="text" id="mcc2" autocomplete="off" autocapitalize="off"><label for="mbcc">Bcc</label><input type="text" id="mbcc" autocomplete="off" autocapitalize="off"></div>
|
||||
<label for="msb">Subject</label><input type="text" id="msb" autocomplete="off">`}
|
||||
<label for="mbd">${reply ? 'Your message' : 'Message'}</label><div id="mtpl" class="bar" style="margin:0 0 6px;justify-content:flex-start"></div><textarea id="mbd" rows="${reply ? 6 : 9}"></textarea>
|
||||
${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;
|
||||
mailTemplates(el);
|
||||
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);
|
||||
const drawFiles = () => { el.querySelector('#mfl').textContent = files.length ? files.map(f => f.name).join(', ') + ' (' + fmtSize(total()) + ')' : ''; };
|
||||
if (fi) fi.onchange = () => { for (const f of fi.files) files.push(f); fi.value = ''; if (total() > MAIL_MAX) { files.pop(); el.querySelector('#mer').textContent = 'Attachments can total about 3 MB.'; } drawFiles(); };
|
||||
const b64 = f => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(String(r.result).split(',')[1] || ''); r.onerror = () => no(new Error('Could not read ' + f.name)); r.readAsDataURL(f); });
|
||||
el.querySelector('#mf').onsubmit = async e => {
|
||||
e.preventDefault(); const btn = el.querySelector('#msend'); btn.disabled = true; el.querySelector('#mer').textContent = '';
|
||||
try {
|
||||
const body = el.querySelector('#mbd').value;
|
||||
if (reply) await jfetch('/api/mail/reply', { method: 'POST', body: JSON.stringify({ id: m.id, mode: reply, comment: body, to: reply === 'forward' ? el.querySelector('#mto').value : undefined }) });
|
||||
else await jfetch('/api/mail/send', { method: 'POST', body: JSON.stringify({ acct: el.querySelector('#mfrom') ? el.querySelector('#mfrom').value : mail.acct || undefined, to: el.querySelector('#mto').value, cc: el.querySelector('#mcc2').value, bcc: el.querySelector('#mbcc').value, subject: el.querySelector('#msb').value, body,
|
||||
attachments: await Promise.all(files.map(async f => ({ name: f.name, type: f.type, data: await b64(f) }))) }) });
|
||||
closeSheet(); toast('Sent');
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#mer').textContent = err.message; }
|
||||
};
|
||||
setTimeout(() => { const f = el.querySelector('#mto') || el.querySelector('#mbd'); if (f) f.focus(); }, 60);
|
||||
}
|
||||
|
||||
/** Pick when a message comes back to the inbox. */
|
||||
function mailSnoozeSheet(m, go) {
|
||||
const at = (days, h) => { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(h, 0, 0, 0); return d.getTime(); }, now = new Date(), sat = (6 - now.getDay() + 7) % 7 || 7, mon = (8 - now.getDay()) % 7 || 7;
|
||||
const opts = [...(now.getHours() < 17 ? [['Later today', at(0, 17)]] : []), ['Tomorrow morning', at(1, 8)], ['This weekend', at(sat, 9)], ['Next week', at(mon, 8)]];
|
||||
const el = openSheet('Snooze', m.subject, `<div class="list">${opts.map(([l, t], i) => `<a href="#" class="row" data-i="${i}"><span class="grow"><b>${l}</b><small>${new Date(t).toLocaleString([], { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}</small></span></a>`).join('')}</div>
|
||||
<form id="msf" class="bar" style="flex-wrap:nowrap;margin-top:12px"><input type="datetime-local" id="mst" style="flex:1" aria-label="Pick a date and time"><button class="btn ghost">Pick</button></form><div class="err" id="mse"></div>`);
|
||||
const run = async t => { try { closeSheet(); await go(t); } catch (e) { toast(e.message); } };
|
||||
el.querySelectorAll('[data-i]').forEach(a => a.onclick = e => { e.preventDefault(); run(opts[+a.dataset.i][1]); });
|
||||
el.querySelector('#msf').onsubmit = e => { e.preventDefault(); const t = Date.parse(el.querySelector('#mst').value); if (!(t > Date.now())) { el.querySelector('#mse').textContent = 'Pick a time in the future.'; return; } run(t); };
|
||||
}
|
||||
/** Templates in a new message: insert one, or save what is typed as a template. */
|
||||
function mailTemplates(el) {
|
||||
const bar = el.querySelector('#mtpl'); if (!bar) return;
|
||||
const draw = list => {
|
||||
bar.innerHTML = `<button type="button" class="btn ghost" id="mtp">Templates${list.length ? ' (' + list.length + ')' : ''}</button><button type="button" class="btn ghost" id="mts">Save as template</button>`;
|
||||
bar.querySelector('#mtp').onclick = () => { if (!list.length) return toast('No templates yet. Write a message, then Save as template.');
|
||||
const sh = openSheet('Templates', 'Tap one to use it', `<div class="list">${list.map(t => `<div class="row"><a href="#" class="grow" data-use="${esc(t.id)}"><b>${esc(t.name)}</b><small>${esc(t.subject || t.body.slice(0, 60))}</small></a><button class="ibtn" data-del="${esc(t.id)}" aria-label="Delete ${esc(t.name)}">${icon('trash')}</button></div>`).join('')}</div>`);
|
||||
sh.querySelectorAll('[data-use]').forEach(a => a.onclick = e => { e.preventDefault(); const t = list.find(x => x.id === a.dataset.use); closeSheet(); const sb = el.querySelector('#msb'), bd = el.querySelector('#mbd'); if (sb && t.subject && !sb.value) sb.value = t.subject; bd.value = bd.value ? bd.value + '\n' + t.body : t.body; bd.focus(); });
|
||||
sh.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => { try { const l = await jfetch('/api/mail/templates/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.del }) }); closeSheet(); draw(l); toast('Template deleted'); } catch (e) { toast(e.message); } }); };
|
||||
bar.querySelector('#mts').onclick = () => { const body = el.querySelector('#mbd').value.trim(); if (!body) return toast('Write the message first');
|
||||
const sh = openSheet('Save as template', '', `<form id="mtf"><label for="mtn">Name</label><input type="text" id="mtn" autocomplete="off" required><div class="bar"><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||
sh.querySelector('#mtf').onsubmit = async e => { e.preventDefault(); try { const l = await jfetch('/api/mail/templates', { method: 'POST', body: JSON.stringify({ name: sh.querySelector('#mtn').value, subject: (el.querySelector('#msb') || {}).value || '', body }) }); closeSheet(); draw(l); toast('Template saved'); } catch (er) { toast(er.message); } }; };
|
||||
};
|
||||
jfetch('/api/mail/templates').then(draw).catch(() => {});
|
||||
}
|
||||
@@ -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": [
|
||||
"*/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Music extras: Plex playlists you can change (create, add, remove, rename, delete, save the queue) and a sleep timer.
|
||||
// Uses the player globals from app.js (px, pl, jfetch, actionSheet, textSheet, confirmSheet, toast, syncUI, saveState).
|
||||
|
||||
// ---- Plex playlists. The hub only lets /plex/playlists... writes through, so nothing else on the server can be changed from here.
|
||||
const plx = {
|
||||
_m: null,
|
||||
async mid() { return plx._m || (plx._m = (await px.get('/identity')).machineIdentifier); },
|
||||
async uri(keys) { return `server://${await plx.mid()}/com.plexapp.plugins.library/library/metadata/${keys.join(',')}`; },
|
||||
async call(method, path) {
|
||||
const r = await fetch('/plex' + path, { method, headers: { Accept: 'application/json' } });
|
||||
if (!r.ok) throw new Error('Plex said no (' + r.status + '). Your Plex account may not be allowed to change playlists.');
|
||||
return r.json().catch(() => ({}));
|
||||
},
|
||||
async list() { return ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(p => !p.smart); },
|
||||
async create(title, keys) { return plx.call('POST', `/playlists?type=audio&smart=0&title=${encodeURIComponent(title)}&uri=${encodeURIComponent(await plx.uri(keys))}`); },
|
||||
async add(id, keys) { return plx.call('PUT', `/playlists/${encodeURIComponent(id)}/items?uri=${encodeURIComponent(await plx.uri(keys))}`); },
|
||||
remove: (id, pid) => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}/items/${encodeURIComponent(pid)}`),
|
||||
rename: (id, title) => plx.call('PUT', `/playlists/${encodeURIComponent(id)}?title=${encodeURIComponent(title)}`),
|
||||
del: id => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}`),
|
||||
};
|
||||
|
||||
/** Pick a playlist (or make a new one) and put these tracks in it. */
|
||||
async function addToPlaylist(tracks, suggest = '') {
|
||||
const keys = tracks.map(t => t.key).filter(Boolean); if (!keys.length) return;
|
||||
let lists = [];
|
||||
try { lists = await plx.list(); } catch (e) { return toast(e.message); }
|
||||
const newOne = () => textSheet('New playlist', 'Playlist name', 'Create', async name => {
|
||||
try { await plx.create(name, keys); toast('Playlist “' + name + '” created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); }
|
||||
}, 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); } } })),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---- Sleep timer: fades the music out and pauses after a set time, or when the current song ends.
|
||||
const sleepT = { end: 0, eot: false, timer: null, fade: null };
|
||||
function sleepClear() { clearTimeout(sleepT.timer); clearInterval(sleepT.fade); sleepT.end = 0; sleepT.eot = false; pl.a.volume = 1; }
|
||||
function sleepLabel() {
|
||||
if (sleepT.eot) return 'Sleep · end of song';
|
||||
if (sleepT.end) return 'Sleep · ' + Math.max(1, Math.round((sleepT.end - Date.now()) / 6e4)) + ' min';
|
||||
return 'Sleep';
|
||||
}
|
||||
function sleepFade() {
|
||||
clearInterval(sleepT.fade);
|
||||
const t0 = Date.now(), v0 = pl.a.volume || 1;
|
||||
sleepT.fade = setInterval(() => {
|
||||
const f = Math.min(1, (Date.now() - t0) / 8000);
|
||||
pl.a.volume = Math.max(0, v0 * (1 - f));
|
||||
if (f >= 1) { clearInterval(sleepT.fade); pl.a.pause(); pl.a.volume = 1; sleepT.end = 0; const b = document.getElementById('cnz'); if (b) b.textContent = 'Sleep'; toast('Sleep timer: music stopped'); }
|
||||
}, 200);
|
||||
}
|
||||
function sleepSet(minutes) {
|
||||
sleepClear();
|
||||
if (minutes === 'eot') sleepT.eot = true;
|
||||
else if (minutes > 0) { sleepT.end = Date.now() + minutes * 6e4; sleepT.timer = setTimeout(sleepFade, minutes * 6e4); }
|
||||
const b = document.getElementById('cnz'); if (b) b.textContent = sleepLabel();
|
||||
toast(minutes ? (minutes === 'eot' ? 'Music will stop when this song ends' : 'Music will fade out in ' + minutes + ' minutes') : 'Sleep timer off');
|
||||
}
|
||||
function sleepSheet() {
|
||||
const opts = [[15, '15 minutes'], [30, '30 minutes'], [45, '45 minutes'], [60, '1 hour'], [90, '1.5 hours'], ['eot', 'When this song ends']].map(([v, label]) => ({ label, icon: 'pause', run: () => sleepSet(v) }));
|
||||
if (sleepT.end || sleepT.eot) opts.push({ label: 'Turn off the timer', icon: 'x', run: () => sleepSet(0) });
|
||||
actionSheet('Sleep timer', sleepLabel() === 'Sleep' ? 'Fades the music out and pauses' : sleepLabel(), opts);
|
||||
}
|
||||
|
||||
// ---- Plex Home: use Music as someone else in your Plex Home (their PIN if they have one)
|
||||
const plexHome = { t: 0, data: null };
|
||||
async function plexHomeUsers() {
|
||||
if (plexHome.data && Date.now() - plexHome.t < 5 * 60e3) return plexHome.data;
|
||||
plexHome.data = await jfetch('/api/plex/users'); plexHome.t = Date.now(); return plexHome.data;
|
||||
}
|
||||
/** After the Music header is drawn: "Playing as Nate" with a switch link, but only when the Plex Home has more than one person. */
|
||||
async function plexHomeBadge(st) {
|
||||
try {
|
||||
const d = await plexHomeUsers(), el = document.getElementById('phu');
|
||||
if (!el || !d.users || d.users.length < 2) return;
|
||||
const me = d.users.find(x => x.current) || d.users.find(x => x.admin) || d.users[0];
|
||||
el.innerHTML = ` · <a href="#" id="pswitch" style="color:var(--accent)">Playing as ${esc(me.title)} ▾</a>`;
|
||||
document.getElementById('pswitch').onclick = e => { e.preventDefault(); plexSwitchSheet(d.users); };
|
||||
} catch { /* no Plex Home: nothing to switch */ }
|
||||
}
|
||||
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(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) 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>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="ppb" style="flex:1;justify-content:center">Switch</button></div><div class="err" id="ppe" role="alert"></div></form>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#ppf').onsubmit = e => { e.preventDefault(); plexSwitchTo(u, el.querySelector('#ppi').value, msg => { el.querySelector('#ppe').textContent = msg; el.querySelector('#ppi').select(); }); };
|
||||
setTimeout(() => el.querySelector('#ppi').focus(), 60);
|
||||
}
|
||||
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; 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); }
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
'use strict';
|
||||
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
|
||||
const jfetch = async (path, opts) => {
|
||||
let r; try { r = await fetch(path, opts); } catch (e) { const q = typeof offQueued === 'function' && offQueued(path, opts); if (q) return q; throw e; } // (a few kinds of change are kept for later when there is no signal)
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
|
||||
return j;
|
||||
};
|
||||
const px = {
|
||||
secs: null,
|
||||
async get(path) {
|
||||
const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
|
||||
if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
|
||||
if (!r.ok) throw new Error('Plex error ' + r.status);
|
||||
return (await r.json()).MediaContainer || {};
|
||||
},
|
||||
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv|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;
|
||||
const d = (await px.get('/library/sections')).Directory || [];
|
||||
px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
|
||||
if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
|
||||
return px.secs;
|
||||
},
|
||||
async section() {
|
||||
const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
|
||||
return (secs.find(x => x.key === want) || secs[0]).key;
|
||||
},
|
||||
serverKey: '',
|
||||
};
|
||||
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
|
||||
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, artistKey: t.grandparentRatingKey, albumKey: t.parentRatingKey };
|
||||
};
|
||||
|
||||
async function viewMusic(args) {
|
||||
const v = $('#view');
|
||||
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
|
||||
try {
|
||||
const st = await jfetch('/api/plex/status');
|
||||
if (!location.hash.startsWith('#/music')) return;
|
||||
if (!st.signedIn) return plexSignIn();
|
||||
if (!st.server) return plexPickServer();
|
||||
await musicRoute(args, st);
|
||||
renderNpCard();
|
||||
} catch (e) {
|
||||
if (e.auth) return plexSignIn();
|
||||
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
|
||||
$('#rs').onclick = plexPickServer;
|
||||
}
|
||||
}
|
||||
|
||||
// The sign-in on plex.tv may take over this very tab (installed apps, some browsers) and then come back, so the pending sign-in is remembered
|
||||
// and picked up again when this page loads.
|
||||
const PLEX_PIN = 'plex_pin';
|
||||
async function plexWaitPin(pin, w) {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
if (!location.hash.startsWith('#/music') && !location.hash.startsWith('#/video')) return;
|
||||
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
||||
if (r.ok) { try { localStorage.removeItem(PLEX_PIN); } catch {} try { w && w.close(); } catch {} return route(); }
|
||||
}
|
||||
try { localStorage.removeItem(PLEX_PIN); } catch {}
|
||||
const e = $('#pe'); if (e) e.textContent = 'Timed out waiting for Plex. Try again.';
|
||||
}
|
||||
function plexSignIn(msg = '') {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
|
||||
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
|
||||
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
|
||||
let pend = null; try { pend = JSON.parse(localStorage.getItem(PLEX_PIN)); } catch {}
|
||||
if (pend && Date.now() - pend.t < 10 * 60e3) { $('#pe').textContent = 'Finishing your Plex sign-in…'; plexWaitPin(pend, null).catch(e => { const x = $('#pe'); if (x) x.textContent = e.message; }); }
|
||||
$('#pl').onclick = async () => {
|
||||
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
||||
try {
|
||||
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
|
||||
try { localStorage.setItem(PLEX_PIN, JSON.stringify({ id: pin.id, code: pin.code, t: Date.now() })); } catch {}
|
||||
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
|
||||
await plexWaitPin(pin, w);
|
||||
} catch (e) { $('#pe').textContent = e.message; }
|
||||
};
|
||||
}
|
||||
|
||||
async function plexPickServer() {
|
||||
const v = $('#view');
|
||||
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Finding your Plex servers…</p></div>';
|
||||
try {
|
||||
const servers = await jfetch('/api/plex/servers');
|
||||
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
|
||||
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
|
||||
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
|
||||
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
|
||||
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
|
||||
b.disabled = true; $('#pe').textContent = '';
|
||||
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
|
||||
catch (e) {
|
||||
b.disabled = false; $('#pe').textContent = e.message;
|
||||
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
|
||||
$('#pm').innerHTML = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
|
||||
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
|
||||
<div class="bar"><button class="btn">Try this address</button></div></form>`;
|
||||
$('#pf2').onsubmit = async ev => {
|
||||
ev.preventDefault(); $('#pe').textContent = '';
|
||||
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
|
||||
catch (e2) { $('#pe').textContent = e2.message; }
|
||||
};
|
||||
}
|
||||
});
|
||||
} 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 = 'featured', key, key2], st) {
|
||||
px.serverKey = st.server;
|
||||
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
|
||||
const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' };
|
||||
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${TAB_NAME[t] || t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
|
||||
const 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}${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(); } }))); };
|
||||
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, 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, 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>${allowed('chat') ? `<button class="btn ghost" id="ash">${icon('send')}Share</button>` : ''}</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>`;
|
||||
if ($('#ash')) $('#ash').onclick = () => shareMedia({ kind: 'artist', title: mc.title2 || mc.parentTitle || 'Artist', art: px.art((am.Metadata || [])[0] && am.Metadata[0].thumb, 200), link: '#/music/artist/' + key });
|
||||
return;
|
||||
}
|
||||
if (kind === 'album' || kind === 'playlist') {
|
||||
const album = kind === 'album';
|
||||
const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
|
||||
px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
|
||||
const info = (meta.Metadata || [])[0] || {};
|
||||
const tracks = (mc.Metadata || []).map(t => { const m = mapTrack(t, mc); if (m && !album) m.pid = t.playlistItemID; return m; }).filter(Boolean);
|
||||
const mine = !album && !info.smart;
|
||||
const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
|
||||
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>
|
||||
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||||
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
||||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
||||
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
||||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
||||
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) {
|
||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
||||
}
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
|
||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||
markPlaying();
|
||||
return;
|
||||
}
|
||||
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([]);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(); };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use strict';
|
||||
// ---- Changes made with no signal: adding a task, ticking one off, adding to the shopping list and writing a note are kept on this device and sent when the connection is back.
|
||||
// Only these few safe changes are queued (nothing that could clash with what someone else did meanwhile); anything else says it needs a connection. Tasks show the waiting changes.
|
||||
const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id]];
|
||||
const offqKey = () => 'offq:' + (typeof ME !== 'undefined' && ME ? ME.id : '');
|
||||
const offqRead = () => { try { const a = JSON.parse(localStorage.getItem(offqKey())); return Array.isArray(a) ? a : []; } catch { return []; } };
|
||||
const offqWrite = l => { try { if (l.length) localStorage.setItem(offqKey(), JSON.stringify(l)); else localStorage.removeItem(offqKey()); } catch { /* storage full or blocked */ } offqNote(); };
|
||||
const offPending = () => offqRead().length;
|
||||
function offqNote() { const n = offPending(); let el = document.getElementById('offnote'); if (!el && n) { el = Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); document.body.appendChild(el); } if (!el) return; if (n || !navigator.onLine) { el.hidden = false; el.textContent = (navigator.onLine ? '' : 'You are offline. Showing what was last saved. ') + (n ? n + ' change' + (n === 1 ? '' : 's') + ' waiting to send.' : 'Changes need a connection.'); } else el.hidden = true; }
|
||||
/** Called by jfetch when a request failed on the network: keep it for later if it is one of the safe kinds. Returns the answer to give, or null (the error stands). */
|
||||
function offQueued(path, opts) {
|
||||
const method = String((opts && opts.method) || 'GET').toUpperCase(); if (method !== 'POST' || typeof (opts && opts.body) !== 'string') return null;
|
||||
let b; try { b = JSON.parse(opts.body); } catch { return null; } const rule = OFFQ_RULES.find(r => r[0].test(path)); if (!rule || !b || !rule[1](b)) return null;
|
||||
const l = offqRead(); if (l.length >= 100) return null; l.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), path, body: opts.body, t: Date.now() }); offqWrite(l);
|
||||
toast('Saved on this device. It sends when you are back online.'); return { ok: true, queued: true };
|
||||
}
|
||||
let offqBusy = false;
|
||||
/** Send what is waiting, oldest first; stops at the first network failure, and drops a change the server refuses (saying so). */
|
||||
async function offqFlush() {
|
||||
if (offqBusy || !navigator.onLine) return; offqBusy = true; let sent = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const l = offqRead(); if (!l.length) break; const it = l[0];
|
||||
let r; try { r = await fetch(it.path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: it.body }); } catch { break; }
|
||||
if (!r.ok && r.status >= 500) break; // (the server is having trouble: try again later)
|
||||
offqWrite(offqRead().filter(x => x.id !== it.id)); if (r.ok) sent++; else { const j = await r.json().catch(() => ({})); toast('A change made offline was not accepted: ' + (j.error || r.status)); }
|
||||
}
|
||||
} finally { offqBusy = false; }
|
||||
if (sent) { toast(sent + ' change' + (sent === 1 ? '' : 's') + ' sent'); if (/^#\/(tasks|planner)/.test(location.hash) && typeof route === 'function') route(); }
|
||||
}
|
||||
/** What the Tasks list should show while changes are waiting: queued ticks applied, and queued new tasks added (marked as waiting). */
|
||||
function offApplyTasks(tasks) {
|
||||
const q = offqRead(); if (!q.length) return tasks; const out = tasks.map(t => ({ ...t }));
|
||||
for (const it of q) { let b; try { b = JSON.parse(it.body); } catch { continue; }
|
||||
if (/toggle$/.test(it.path)) { const t = out.find(x => x.id === b.id); if (t) t.done = !!b.done; }
|
||||
else if (it.path === '/api/tasks' && b.fields) out.push({ id: 'queued:' + it.id, list: b.list, title: b.fields.title, notes: b.fields.notes || '', due: b.fields.due || '', dueTime: b.fields.dueTime || '', done: !!b.fields.done, parent: '', repeat: 'none', _ln: 'waiting to send' }); }
|
||||
return out;
|
||||
}
|
||||
addEventListener('online', () => { offqNote(); setTimeout(offqFlush, 800); }); addEventListener('offline', offqNote); setTimeout(offqFlush, 4000); setInterval(offqFlush, 60e3);
|
||||
@@ -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(); }
|
||||
@@ -0,0 +1,118 @@
|
||||
// Command palette (Ctrl/Cmd-K) and quick capture. Uses the app's globals (allowed, TABS, icon, jfetch, openSheet, store, toast ...).
|
||||
|
||||
/** "buy milk tomorrow" -> { title: 'buy milk', due: '2026-10-23' }. Understands today, tomorrow, a weekday name, "in N days" and YYYY-MM-DD at the end. */
|
||||
function parseQuickTask(text) {
|
||||
let t = String(text || '').trim(), due = '';
|
||||
const d0 = new Date(); d0.setHours(12, 0, 0, 0);
|
||||
const fmt = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||
let m;
|
||||
if ((m = /\s+(\d{4}-\d{2}-\d{2})$/.exec(t))) { due = m[1]; t = t.slice(0, m.index); }
|
||||
else if ((m = /\s+in (\d{1,3}) days?$/i.exec(t))) { const d = new Date(d0); d.setDate(d.getDate() + +m[1]); due = fmt(d); t = t.slice(0, m.index); }
|
||||
else if ((m = /\s+(today|tomorrow|tonight)$/i.exec(t))) { const d = new Date(d0); if (/tomorrow/i.test(m[1])) d.setDate(d.getDate() + 1); due = fmt(d); t = t.slice(0, m.index); }
|
||||
else if ((m = /\s+(?:on |next )?(sun|mon|tue|wed|thu|fri|sat)[a-z]*$/i.exec(t))) {
|
||||
const d = new Date(d0), want = days.indexOf(m[1].toLowerCase()); let add = (want - d.getDay() + 7) % 7; if (add === 0) add = 7; d.setDate(d.getDate() + add); due = fmt(d); t = t.slice(0, m.index);
|
||||
}
|
||||
return { title: t.trim() || String(text || '').trim(), due };
|
||||
}
|
||||
|
||||
async function quickTask(text) {
|
||||
const { title, due } = parseQuickTask(text);
|
||||
if (!title) return;
|
||||
await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: 'local:inbox', fields: { title, notes: '', due, done: false } }) });
|
||||
toast('Task added' + (due ? ' (due ' + due + ')' : ''));
|
||||
if (location.hash.startsWith('#/tasks') && typeof viewTasks === 'function') viewTasks();
|
||||
}
|
||||
|
||||
/** Small sheet to capture something fast from any screen. */
|
||||
function quickAdd() {
|
||||
const can = t => allowed(t);
|
||||
const el = openSheet('Quick add', '', `${can('tasks') ? `<form id="qf"><input type="text" id="qt" placeholder="Task, e.g. Call mom tomorrow" autocomplete="off" aria-label="New task" style="margin-top:6px">
|
||||
<p class="sub" style="margin:6px 0 0">Understands today, tomorrow, a weekday, "in 3 days" or a date (2026-12-25) at the end.</p>
|
||||
<div class="bar"><button class="btn" style="flex:1;justify-content:center">Add task</button></div></form>` : ''}
|
||||
<div class="bar">${can('calendar') ? '<button class="btn ghost" id="qe" style="flex:1;justify-content:center">New event</button>' : ''}${can('links') ? '<button class="btn ghost" id="ql" style="flex:1;justify-content:center">New link</button>' : ''}</div><div class="err" id="qerr"></div>`);
|
||||
const qt = el.querySelector('#qt'); if (qt) qt.focus();
|
||||
if (el.querySelector('#qf')) el.querySelector('#qf').onsubmit = async e => { e.preventDefault(); try { await quickTask(qt.value); closeSheet(); } catch (err) { el.querySelector('#qerr').textContent = err.message; } };
|
||||
if (el.querySelector('#qe')) el.querySelector('#qe').onclick = () => { closeSheet(); newEvent(ymd(new Date())); };
|
||||
if (el.querySelector('#ql')) el.querySelector('#ql').onclick = () => { closeSheet(); linkSheet(); };
|
||||
}
|
||||
|
||||
// ---- Command palette
|
||||
let palEl = null;
|
||||
function openPalette() {
|
||||
if (palEl) return closePalette();
|
||||
const lastFocus = document.activeElement;
|
||||
palEl = document.createElement('div'); palEl.id = 'pal';
|
||||
palEl.innerHTML = `<div class="scrim"></div><div class="palbox" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input type="text" id="palq" role="combobox" aria-expanded="true" aria-controls="pall" aria-autocomplete="list" placeholder="Type a command, a page, a task or a link" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<div id="pall" role="listbox" aria-label="Results"></div></div>`;
|
||||
document.body.appendChild(palEl);
|
||||
const input = palEl.querySelector('#palq'), list = palEl.querySelector('#pall');
|
||||
let items = [], sel = 0, seq = 0, timer, live = [];
|
||||
const go = h => () => { location.hash = h; };
|
||||
const base = () => {
|
||||
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' });
|
||||
return out;
|
||||
};
|
||||
const build = () => {
|
||||
const q = input.value.trim(), ql = q.toLowerCase();
|
||||
let all = base();
|
||||
if (typeof allLinks === 'function' && allowed('links')) all = all.concat(allLinks().map(l => ({ label: l.title || l.url, hint: 'Link · ' + new URL(l.url).host, ic: 'link', run: () => window.open(l.url, '_blank', 'noopener'), kw: (l.group || '') + ' ' + l.url })));
|
||||
let res = ql ? all.filter(i => (i.label + ' ' + i.kw).toLowerCase().includes(ql)) : all.slice(0, 12);
|
||||
if (ql) {
|
||||
res = res.sort((a, b) => (b.label.toLowerCase().startsWith(ql)) - (a.label.toLowerCase().startsWith(ql)));
|
||||
res = res.concat(live);
|
||||
if (allowed('tasks')) res.push({ label: 'Add task: ' + q, hint: 'Create', ic: 'plus', run: () => quickTask(q).catch(e => toast(e.message)) });
|
||||
if (allowed('search')) res.push({ label: 'Search everything for “' + q + '”', hint: 'Search', ic: 'search', run: () => { window.pendingSearch = q; if (location.hash === '#/search') route(); else location.hash = '#/search'; } });
|
||||
}
|
||||
items = res; sel = Math.min(sel, Math.max(0, items.length - 1));
|
||||
list.innerHTML = items.length ? items.map((i, n) => `<div class="palrow${n === sel ? ' on' : ''}" role="option" id="po${n}" aria-selected="${n === sel}" data-n="${n}">${icon(i.ic)}<span class="grow">${esc(i.label)}</span><small>${esc(i.hint)}</small></div>`).join('') : '<p class="empty">Nothing matches.</p>';
|
||||
input.setAttribute('aria-activedescendant', items.length ? 'po' + sel : '');
|
||||
const on = list.querySelector('.on'); if (on) on.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
const fetchLive = q => { // tasks and events from the server, shown under the commands
|
||||
clearTimeout(timer); live = [];
|
||||
if (q.length < 2 || !(allowed('tasks') || allowed('calendar'))) return;
|
||||
const mine = ++seq;
|
||||
timer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await jfetch('/api/search?q=' + encodeURIComponent(q));
|
||||
if (mine !== seq || !palEl) return;
|
||||
live = (r.tasks || []).slice(0, 4).map(t => ({ label: t.title, hint: 'Task' + (t.due ? ' · due ' + t.due : '') + (t.done ? ' · done' : ''), ic: 'check', run: go('#/tasks') }))
|
||||
.concat((r.events || []).slice(0, 4).map(e => ({ label: e.title, hint: 'Event · ' + (e.allDay ? e.start : new Date(e.start).toLocaleDateString([], { month: 'short', day: 'numeric' })), ic: 'cal', run: go('#/calendar') })));
|
||||
build();
|
||||
} catch {}
|
||||
}, 250);
|
||||
};
|
||||
const run = n => { const it = items[n]; if (!it) return; closePalette(); setTimeout(it.run, 0); };
|
||||
input.oninput = () => { sel = 0; fetchLive(input.value.trim()); build(); };
|
||||
input.onkeydown = e => {
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); sel = (sel + 1) % Math.max(1, items.length); build(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); sel = (sel - 1 + items.length) % Math.max(1, items.length); build(); }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); run(sel); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); closePalette(); }
|
||||
else if (e.key === 'Tab') e.preventDefault(); // keep focus in the box
|
||||
};
|
||||
list.onclick = e => { const r = e.target.closest('.palrow'); if (r) run(+r.dataset.n); };
|
||||
palEl.querySelector('.scrim').onclick = closePalette;
|
||||
palEl._restore = lastFocus;
|
||||
build(); input.focus();
|
||||
requestAnimationFrame(() => palEl && palEl.classList.add('on'));
|
||||
}
|
||||
function closePalette() { if (!palEl) return; const r = palEl._restore; palEl.remove(); palEl = null; try { r && r.focus && r.focus(); } catch {} }
|
||||
addEventListener('keydown', e => {
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
|
||||
if (document.body.classList.contains('editing') || !document.getElementById('nav')) return; // not on the sign-in page or inside the document editor
|
||||
e.preventDefault(); openPalette();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
// Planner: Calendar and Tasks in one menu entry. Each part keeps its own address (#/calendar, #/tasks) so links and reminders still work.
|
||||
const PLANNER_PANES = [['calendar', 'Calendar'], ['tasks', 'Tasks']];
|
||||
function viewPlanner(args = []) {
|
||||
const ok = PLANNER_PANES.map(p => p[0]).filter(p => allowed(p));
|
||||
const want = args[0] && ok.includes(args[0]) ? args[0] : store.get('planner_pane', ok[0]);
|
||||
location.replace('#/' + (ok.includes(want) ? want : ok[0] || 'home'));
|
||||
}
|
||||
/** One compact toolbar instead of stacked rows: title, Calendar | Tasks switch, then `mid` (the view switch) and `extra` (a button) pushed to the right. */
|
||||
function plannerHead(active, extra = '', mid = '') {
|
||||
try { localStorage.setItem(nk('planner_pane'), JSON.stringify(active)); } catch {}
|
||||
const panes = PLANNER_PANES.filter(p => allowed(p[0]));
|
||||
const two = panes.length > 1;
|
||||
const group = two ? `<div class="sgrp" role="tablist" aria-label="Planner">${panes.map(([k, l]) => `<a href="#/${k}" role="tab" aria-selected="${k === active}" class="${k === active ? 'on' : ''}">${l}</a>`).join('')}</div>` : '';
|
||||
const title = two ? 'Planner' : (PLANNER_PANES.find(p => p[0] === active) || [0, 'Planner'])[1];
|
||||
return `<div class="ptop"><h1>${esc(title)}</h1>${group}${mid}<span class="pspace"></span>${extra}</div>`;
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
'use strict';
|
||||
// ---- Player: one <audio> element that lives outside the views, so music keeps playing as you move around.
|
||||
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.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;
|
||||
const t = cur();
|
||||
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
|
||||
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
|
||||
pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
|
||||
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
|
||||
}
|
||||
function npQueueCount() { const q = $('#cnq'); if (q) q.textContent = 'Queue · ' + Math.max(0, pl.q.length - pl.i - 1) + ' up next'; }
|
||||
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); modeUI(); }
|
||||
const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
|
||||
// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
|
||||
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
|
||||
// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
|
||||
// so changing songs is instant and a slow or lost connection does not stop the music.
|
||||
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||
const tc = { m: new Map(), ctl: null, busy: false };
|
||||
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
|
||||
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); }
|
||||
}
|
||||
async function tcFetch(src, signal) {
|
||||
for (let n = 0; n < 4 && !signal.aborted; n++) {
|
||||
try {
|
||||
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
|
||||
const b = await r.blob(); // throws if the connection drops part way
|
||||
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
|
||||
return;
|
||||
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
||||
}
|
||||
}
|
||||
async function tcPump() {
|
||||
if (tc.busy) return; tc.busy = true;
|
||||
try {
|
||||
for (;;) {
|
||||
tcTrim();
|
||||
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
|
||||
tc.ctl = new AbortController(); tc.cur = src;
|
||||
await tcFetch(src, tc.ctl.signal);
|
||||
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
|
||||
}
|
||||
} finally { tc.busy = false; tc.ctl = null; }
|
||||
}
|
||||
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
||||
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
||||
|
||||
let loadTok = 0, loadTimer = null, stallTimer = null;
|
||||
pl.retries = 0; pl.buf = false;
|
||||
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
|
||||
function loadCur(delay = 220) {
|
||||
const t = cur(); if (!t) return;
|
||||
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
|
||||
const tok = ++loadTok; pl.buf = true;
|
||||
if (delay) pl.a.pause();
|
||||
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
||||
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
||||
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);
|
||||
};
|
||||
if (delay) loadTimer = setTimeout(go, delay); else go();
|
||||
}
|
||||
function setMeta(t) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
|
||||
}
|
||||
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
|
||||
function recover(now) {
|
||||
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
||||
if (!t || a.paused) return;
|
||||
stallTimer = setTimeout(() => {
|
||||
if (a.paused || (a.readyState >= 3 && !a.error)) return;
|
||||
const hit = tc.m.get(t.src), pos = a.currentTime;
|
||||
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
||||
if (bad && !hit) {
|
||||
if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
|
||||
else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
|
||||
}
|
||||
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
||||
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
|
||||
if (!hit || a.src !== hit.url) pl.retries++;
|
||||
a.src = want;
|
||||
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
||||
recover();
|
||||
}, now ? 300 : 4000);
|
||||
}
|
||||
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
|
||||
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
|
||||
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
|
||||
function trackSheet(t, extra) {
|
||||
actionSheet(t.title, t.artist, [
|
||||
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
|
||||
{ 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: () => shareMedia({ kind: 'song', title: t.title, sub: t.artist, art: px.art(t.thumb, 200), link: '#/music/album/' + t.albumKey }) }] : []),
|
||||
...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []),
|
||||
] : []),
|
||||
...(extra || []),
|
||||
]);
|
||||
}
|
||||
// Restore the last queue and position on launch (paused, so nothing starts by itself).
|
||||
function restorePlayer() {
|
||||
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
|
||||
const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
|
||||
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
|
||||
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
|
||||
renderMini(); syncUI();
|
||||
}
|
||||
function openQueue() {
|
||||
const upcoming = pl.q.map((t, i) => ({ t, i }));
|
||||
const el = openSheet('Up next', pl.q.length + ' tracks' + (pl.radio ? ' · library radio is on' : ''), `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
|
||||
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
|
||||
${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;
|
||||
el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); };
|
||||
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) {
|
||||
if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; }
|
||||
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
|
||||
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
|
||||
loadCur(auto ? 0 : 220);
|
||||
}
|
||||
const next = () => advance(false);
|
||||
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
|
||||
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
|
||||
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) => /^(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
|
||||
tlKey = t.key; tlAt = Date.now(); tlPos = (pl.a.currentTime || 0) * 1000; tlDur = isFinite(pl.a.duration) ? pl.a.duration * 1000 : 0;
|
||||
tlSend(state, tlKey, tlPos, tlDur);
|
||||
}
|
||||
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
||||
pl.a.addEventListener('ended', () => advance(true));
|
||||
let lastSave = 0, lastPos = 0;
|
||||
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 && 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);
|
||||
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
||||
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
|
||||
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
||||
pl.a.addEventListener('canplaythrough', () => tcKick());
|
||||
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
||||
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
|
||||
if ('mediaSession' in navigator) {
|
||||
const ms = navigator.mediaSession;
|
||||
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
|
||||
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
|
||||
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
|
||||
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
|
||||
}
|
||||
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)}">
|
||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
||||
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
|
||||
// tap or drag along the thin bar to seek
|
||||
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||
// swipe the track area: left for next, right for previous
|
||||
const mini = el.querySelector('.mini'); let x0 = null, y0 = 0;
|
||||
mini.onpointerdown = e => { if (e.target.closest('button:not(.meta), #mseek')) return; x0 = e.clientX; y0 = e.clientY; miniSwiped = false; };
|
||||
mini.onpointerup = e => { if (x0 === null) return; const dx = e.clientX - x0, dy = e.clientY - y0; x0 = null;
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 2) { miniSwiped = true; setTimeout(() => { miniSwiped = false; }, 300); dx < 0 ? next() : prev(); } };
|
||||
mini.onpointercancel = () => { x0 = null; };
|
||||
modeUI(); syncUI();
|
||||
}
|
||||
let miniSwiped = false;
|
||||
function stopPlayer() {
|
||||
tlStop();
|
||||
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();
|
||||
}
|
||||
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||
function modeUI() {
|
||||
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
||||
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||
}
|
||||
function syncUI() {
|
||||
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||
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 = 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 = 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; }
|
||||
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
||||
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
||||
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
||||
}
|
||||
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
|
||||
function renderNpCard() {
|
||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
|
||||
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
|
||||
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
||||
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
||||
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
|
||||
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="cnfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
|
||||
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-l" id="cnv">Visuals</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
|
||||
const sk = el.querySelector('#cnk');
|
||||
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('#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('#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.
|
||||
function updateNow() {
|
||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
||||
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
|
||||
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||
modeUI();
|
||||
}
|
||||
function openNow() {
|
||||
const t = cur(); if (!t) return;
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const sk = el.querySelector('#npk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||
updateNow(); syncUI();
|
||||
}
|
||||
// Tell the page how tall the player is, so buttons that float at the bottom (Home, Ask) sit above it instead of covering it.
|
||||
if (typeof ResizeObserver === 'function' && typeof document !== 'undefined') { const pe = document.getElementById('player'); if (pe) new ResizeObserver(() => document.documentElement.style.setProperty('--playerh', (pe.hidden ? 0 : pe.offsetHeight) + 'px')).observe(pe); }
|
||||
@@ -0,0 +1,153 @@
|
||||
'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>
|
||||
${allowed('chat') ? `<button class="ibtn" data-share aria-label="Share to Chat" title="Share to Chat">${icon('send')}</button>` : ''}<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 shb = r.querySelector('[data-share]'); if (shb) shb.onclick = () => shareMedia({ kind: 'podcastep', title: pdF(e, 'title'), sub: pdF(show, 'title'), art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) });
|
||||
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">${allowed('chat') ? `<button class="row" id="pdsh"><span class="grow"><b>Share to Chat</b></span></button>` : ''}<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 sh = el.querySelector('#pdsh'); if (sh) sh.onclick = () => { closeSheet(); shareMedia({ kind: 'podcast', title: pdF(show, 'title'), sub: pdF(show, 'author') || '', art: pdImg('podcasts', show), link: '#/podcast/show/' + encodeURIComponent(id) }); };
|
||||
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); } }); };
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
'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.');
|
||||
}
|
||||
|
||||
const rdOffKey = id => `/api/read/book/${id}/file`;
|
||||
const rdSaved = async id => { try { return !!(await (await caches.open('hub-offline')).match(rdOffKey(id))); } catch { return false; } };
|
||||
/** Keep a book (and what the reader needs to open it) on this device, so it opens with no signal. */
|
||||
async function rdSaveOffline(b) {
|
||||
if (!('caches' in window)) return toast('This browser cannot save for offline');
|
||||
try {
|
||||
toast('Saving “' + b.title + '”…'); const c = await caches.open('hub-offline'), r = await fetch(rdOffKey(b.id) + '?inline=1'); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')');
|
||||
await c.put(rdOffKey(b.id), r); if (b.cover) { try { const cv = await fetch(`/api/read/book/${b.id}/cover`); if (cv.ok) await c.put(`/api/read/book/${b.id}/cover`, cv); } catch { /* the cover is a nicety */ } }
|
||||
await rdLibs(rdKind(b.ext)).catch(() => {}); // (so the reader's own scripts are in the cache too)
|
||||
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); toast('Saved on this device. It opens with no signal.');
|
||||
} catch (e) { try { await (await caches.open('hub-offline')).delete(rdOffKey(b.id)); } catch {} toast(e.message || 'Could not save'); }
|
||||
}
|
||||
async function rdRemoveOffline(b) { try { const c = await caches.open('hub-offline'); await c.delete(rdOffKey(b.id)); await c.delete(`/api/read/book/${b.id}/cover`); toast('Removed from this device'); } catch { /* nothing saved */ } }
|
||||
async function rdBookMenu(b) {
|
||||
if (!b) return;
|
||||
const saved = await rdSaved(b.id);
|
||||
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) },
|
||||
saved ? { icon: 'dl', label: 'Remove from this device', run: () => rdRemoveOffline(b) } : { icon: 'dl', label: 'Save on this device (read offline)', run: () => rdSaveOffline(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><button class="btn ghost" id="rdexf">Download</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 exf = box.querySelector('#rdexf'); if (exf) exf.onclick = () => downloadText((this.book.title || 'book').replace(/[^\w .-]+/g, '').trim().slice(0, 60) + ' - notes.md', `# ${this.book.title}${this.book.author ? '\n\n' + this.book.author : ''}\n\n` + l.map(n => n.type === 'mark' ? `- Bookmark, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}` : `> ${String(n.text || '').replace(/\n+/g, ' ')}\n${n.note ? '\n' + n.note + '\n' : ''}\n*${n.type === 'note' ? 'Note' : 'Highlight'}, ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}*`).join('\n\n') + '\n');
|
||||
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 {} }
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
'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 === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||
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();
|
||||
if (args[0] === 'plan') return await rcPlan(args[1]);
|
||||
return await rcHome();
|
||||
} catch (e) {
|
||||
if (!location.hash.startsWith('#/recipes')) return;
|
||||
v.innerHTML = `<div class="wrap"><h1>Recipes</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rcretry">Try again</button></div></div>`;
|
||||
$('#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><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||
<div class="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));
|
||||
$('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id);
|
||||
$('#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'));
|
||||
}
|
||||
|
||||
// ---- Meal plan: a week at a time, shared by the family. Pick a recipe for a day, then add the week's ingredients to the shopping list in one tap.
|
||||
const rcYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
const rcMonday = d => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; };
|
||||
const RC_MEALS = [['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner']];
|
||||
async function rcPlan(start) {
|
||||
const mon = start && /^\d{4}-\d{2}-\d{2}$/.test(start) ? rcMonday(new Date(start + 'T12:00:00')) : rcMonday(new Date()), days = [...Array(7)].map((_, i) => { const d = new Date(mon); d.setDate(d.getDate() + i); return d; });
|
||||
const from = rcYmd(days[0]), to = rcYmd(days[6]), plan = await rcApi(`/plan?from=${from}&to=${to}`); if (!location.hash.startsWith('#/recipes')) return;
|
||||
const shift = n => { const d = new Date(mon); d.setDate(d.getDate() + n); return '#/recipes/plan/' + rcYmd(d); }, today = rcYmd(new Date()), count = Object.values(plan).reduce((n, l) => n + l.length, 0);
|
||||
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${rcTabs('plan')}
|
||||
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
||||
<div class="list">${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal));
|
||||
return `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
||||
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
||||
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
||||
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button><button class="btn ghost" id="rcpantry">Pantry</button></div><div id="rcphave"></div></div>`;
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
|
||||
document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } });
|
||||
$('#rcpantry').onclick = () => rcPantry(() => rcPlan(rcYmd(mon)));
|
||||
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `<p class="sub" style="white-space:normal">You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.</p>`; }).catch(() => {});
|
||||
$('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
|
||||
}
|
||||
/** Choose the day and meal for a recipe (from a recipe page), or the recipe for a day (from the plan). */
|
||||
function rcPlanPick(slug, name, rid, date) {
|
||||
const el = openSheet(slug ? 'Plan ' + name : 'Add a meal', '', `<form id="rcpf">${slug ? '' : '<input type="search" id="rcpq" placeholder="Search your recipes" autocomplete="off" aria-label="Search recipes"><div class="list" id="rcpr" style="margin:8px 0"></div>'}
|
||||
<label for="rcpd">Day</label><input type="date" id="rcpd" value="${esc(date || rcYmd(new Date()))}" required><label for="rcpm">Meal</label><select id="rcpm">${RC_MEALS.map(m => `<option value="${m[0]}"${m[0] === 'dinner' ? ' selected' : ''}>${m[1]}</option>`).join('')}</select><div class="err" id="rcpe"></div>
|
||||
<div class="bar">${slug ? '<button class="btn" style="flex:1;justify-content:center">Add to plan</button>' : ''}</div></form>`);
|
||||
const save = async (sl, nm, id) => { try { await rcApi('/plan/add', rcOpt('POST', { date: el.querySelector('#rcpd').value, slug: sl, name: nm, rid: id, meal: el.querySelector('#rcpm').value })); closeSheet(); toast('Added to the meal plan', { label: 'Open', run: () => { location.hash = '#/recipes/plan/' + el.querySelector('#rcpd').value; } }); if (location.hash.startsWith('#/recipes/plan')) rcPlan(el.querySelector('#rcpd').value); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpf').onsubmit = e => { e.preventDefault(); if (slug) save(slug, name, rid); };
|
||||
if (slug) return;
|
||||
let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
|
||||
el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find();
|
||||
}
|
||||
|
||||
/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */
|
||||
async function rcPantry(onClose) {
|
||||
let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); }
|
||||
const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', '<div id="pyl"></div>');
|
||||
const draw = () => {
|
||||
el.querySelector('#pyl').innerHTML = `<form id="pyf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pyn" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="pyq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="pyw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
|
||||
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}</small></span>
|
||||
${x.isLow ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen.</small></span></div>'}</div>
|
||||
<div class="bar"><button class="btn" id="pydone" style="flex:1;justify-content:center">Done</button></div>`;
|
||||
const go = async (p, o) => { try { items = (await rcApi(p, rcOpt('POST', o))).items; draw(); } catch (e) { toast(e.message); } };
|
||||
el.querySelector('#pyf').onsubmit = e => { e.preventDefault(); go('/pantry/item', { name: el.querySelector('#pyn').value, qty: el.querySelector('#pyq').value, low: el.querySelector('#pyw').value }); };
|
||||
el.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/pantry/adjust', { id, delta: +d }); });
|
||||
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/pantry/delete', { id: b.dataset.x }));
|
||||
el.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
|
||||
el.querySelector('#pydone').onclick = () => { closeSheet(); if (onClose) onClose(); };
|
||||
};
|
||||
draw();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Settings -> Reminders: turn on push notifications for tasks due today and events about to start (Web Push).
|
||||
const remSupported = () => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
const b64ToBytes = s => { const p = '='.repeat((4 - s.length % 4) % 4), r = atob((s + p).replace(/-/g, '+').replace(/_/g, '/')); return Uint8Array.from([...r].map(c => c.charCodeAt(0))); };
|
||||
const myTz = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
||||
|
||||
/** Each time the app opens: if this device has a push subscription, tell the server again (it forgets a device when the push service says it is gone, and the time zone can change). */
|
||||
let pushResynced = false;
|
||||
async function pushResync() {
|
||||
if (pushResynced) return; pushResynced = true;
|
||||
try {
|
||||
if (!remSupported() || Notification.permission !== 'granted') return;
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]), s = reg && await reg.pushManager.getSubscription(); if (!s) return;
|
||||
await jfetch('/api/push/subscribe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ subscription: s.toJSON(), tz: myTz() }) });
|
||||
} catch { /* try again next time the app opens */ }
|
||||
}
|
||||
|
||||
function remindersHtml() {
|
||||
return `<div id="sec-rem"><h2>Reminders</h2><p class="sub">Get a notification for tasks due today and events about to start, even when the app is closed. Turn it on for each device you want reminders on.</p>
|
||||
<div class="list" id="remlist"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>`;
|
||||
}
|
||||
|
||||
async function remindersInit() {
|
||||
const box = document.getElementById('remlist'); if (!box) return;
|
||||
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 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>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 {
|
||||
if (on1.checked) {
|
||||
const perm = Notification.permission === 'default' ? await Notification.requestPermission() : Notification.permission;
|
||||
if (perm !== 'granted') { on1.checked = false; return toast('Notifications were not allowed'); }
|
||||
const s = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToBytes(info.publicKey) });
|
||||
await post('subscribe', { subscription: s.toJSON() }); toast('Reminders are on for this device');
|
||||
} else {
|
||||
const s = await reg.pushManager.getSubscription();
|
||||
if (s) { await post('unsubscribe', { endpoint: s.endpoint }); await s.unsubscribe(); }
|
||||
toast('Reminders are off for this device');
|
||||
}
|
||||
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('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));
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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(' · ');
|
||||
@@ -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 & 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); } };
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use strict';
|
||||
// ---- Search: Seafile files + libraries and Plex music, all best-effort (a missing backend never blocks the others)
|
||||
// Seafile: use the search index when the server has one; if it is missing OR comes back empty, scan file names library by library.
|
||||
async function searchFiles(term, libs) {
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||
let results = [], via = 'index';
|
||||
try { results = (await sf.req(`/api2/search/?q=${enc(term)}&per_page=20`)).results || []; } catch {}
|
||||
if (!results.length) { results = await walkSearch(libs, lower); via = 'scan'; }
|
||||
return { results, via, scanned: libs.filter(l => !l.encrypted).length };
|
||||
}
|
||||
|
||||
// Plex: the server-wide search first; if that finds nothing, filter the artists and albums of the chosen music library directly.
|
||||
async function searchMusic(term) {
|
||||
const enc = encodeURIComponent, lower = term.toLowerCase(), items = [];
|
||||
try {
|
||||
const mc = await px.get(`/hubs/search?query=${enc(term)}&limit=10`);
|
||||
for (const hub of mc.Hub || []) for (const m of hub.Metadata || []) if (['artist', 'album', 'track'].includes(m.type)) items.push(m);
|
||||
} catch {}
|
||||
if (!items.length) {
|
||||
const sec = await px.section(), big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
|
||||
const [artists, albums] = await Promise.all([px.get(`/library/sections/${sec}/all?type=8${big}`), px.get(`/library/sections/${sec}/all?type=9${big}`)]);
|
||||
for (const a of artists.Metadata || []) if ((a.title || '').toLowerCase().includes(lower)) items.push({ ...a, type: 'artist' });
|
||||
for (const a of albums.Metadata || []) if (((a.title || '') + ' ' + (a.parentTitle || '')).toLowerCase().includes(lower)) items.push({ ...a, type: 'album' });
|
||||
}
|
||||
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() {
|
||||
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" ${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();
|
||||
let timer, seq = 0, plexSt = null;
|
||||
if (window.pendingSearch) { q.value = window.pendingSearch; window.pendingSearch = ''; setTimeout(() => q.oninput && q.oninput(), 0); }
|
||||
// One line that says what Search covers and what each source returned, with a link to sign in where a source needs it.
|
||||
const showSources = (r = {}) => {
|
||||
const el = $('#src'); if (!el) return;
|
||||
const link = (href, t) => `<a href="${href}" style="color:var(--accent)">${t}</a>`, bad = t => `<span style="color:var(--danger)">${esc(t)}</span>`;
|
||||
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)} · 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 = () => { 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');
|
||||
if (term.length < 2) { res.innerHTML = ''; showSources(); return; }
|
||||
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||
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, 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')),
|
||||
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>` : '';
|
||||
const libHtml = libs.status === 'fulfilled' ? libs.value.filter(r => r.name.toLowerCase().includes(lower)).map(r =>
|
||||
`<a class="row" href="#/files/${enc(r.id)}"><span class="ic" style="background:#2f9e6e">${icon('files')}</span><span class="grow"><b>${esc(r.name)}</b><small>Library</small></span></a>`).join('') : '';
|
||||
const found = files.status === 'fulfilled' ? files.value.results : [];
|
||||
const fileHtml = found.map(f => {
|
||||
const parent = f.fullpath.slice(0, f.fullpath.lastIndexOf('/')) || '/';
|
||||
return f.is_dir ? `<a class="row" href="#/files/${enc(f.repo_id)}${f.fullpath.split('/').filter(Boolean).map(x => '/' + enc(x)).join('')}"><span class="ic" style="background:#e0a82b">${icon('files')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(f.fullpath)}</small></span></a>`
|
||||
: `<button class="row" data-f="${esc(JSON.stringify([f.repo_id, f.fullpath, f.name, parent]))}"><span class="ic" style="background:#6a7280">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.repo_name || '')} ${esc(parent)}</small></span></button>`;
|
||||
}).join('');
|
||||
let musicHtml = '';
|
||||
for (const m of music.status === 'fulfilled' ? music.value : []) {
|
||||
if (m.type === 'artist') musicHtml += `<a class="row" href="#/music/artist/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:50%" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Artist</small></span></a>`;
|
||||
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 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('');
|
||||
const eventHtml = (h.events || []).map(e => { const i = evStore.push(e) - 1, dt = e.allDay ? new Date(e.start + 'T00:00:00') : new Date(e.start);
|
||||
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>${esc(dt.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }))}${e.allDay ? ' · all day' : ' · ' + esc(fmtTime(e.start))} · ${esc(e.cal)}</small></span>${e.editable ? icon('chev', 'chev') : ''}</button>`; }).join('');
|
||||
// what each source did, so an empty screen always has an explanation
|
||||
showSources({
|
||||
tasks: hub.status === 'fulfilled' ? { n: (h.tasks || []).length } : { err: hub.reason && hub.reason.message },
|
||||
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 + 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'; });
|
||||
}
|
||||
}
|
||||
@@ -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); } };
|
||||
}
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user