Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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
|
||||||
@@ -22,15 +22,19 @@ jobs:
|
|||||||
- run: node tools/tasks-test.js # repeating tasks and subtasks
|
- 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/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/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/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/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/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-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/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/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
|
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||||
|
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||||
|
- run: node tools/abdirect-test.js # audiobooks straight from OpenAudible: its login, byte ranges, positions kept by the hub
|
||||||
|
|
||||||
- uses: docker/setup-qemu-action@v3
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
# Repo secrets needed (Settings -> Actions -> Secrets):
|
# Repo secrets needed (Settings -> Actions -> Secrets):
|
||||||
@@ -54,27 +58,9 @@ jobs:
|
|||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
|
cache-from: type=registry,ref=git.necloud.us/nate/hub:buildcache
|
||||||
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
|
cache-to: type=registry,ref=git.necloud.us/nate/hub:buildcache,mode=max
|
||||||
|
|
||||||
# The lyrics container (Lyrica, pinned)
|
|
||||||
- id: meta_ly
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: git.necloud.us/nate/hub-lyrica
|
|
||||||
tags: |
|
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
|
||||||
type=sha,format=short
|
|
||||||
type=ref,event=tag
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./lyrica
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta_ly.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta_ly.outputs.labels }}
|
|
||||||
@@ -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
|
docker compose up -d --build # http://localhost:8080
|
||||||
# or: node server.js
|
# 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),
|
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.
|
e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docker-compose.yml.
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
Office files open in Seafile's own OnlyOffice integration.
|
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.
|
- **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).
|
- **Sign-in**: the whole app sits behind a passkey (WebAuthn). First run: open the site and enter the setup code (`HUB_SETUP_CODE`, or the one printed in the server log) to create a passkey; add more devices from Settings. Passkeys and the session secret live in `/data` (a Docker volume).
|
||||||
|
- **Settings** is split into General, Connections, Security and (for the admin) Admin tabs; the last one used is remembered on each device.
|
||||||
- **People**: you (the admin) add people in Settings -> People, choose which tabs each may use (Files, Office, Music, Audiobooks, Podcast, Podcast Admin, Mail,
|
- **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,
|
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.
|
settings, iCloud connection, calendar links and Plex sign-in are private to them (`/data/users/<id>/`). Limits are enforced by the server, not just hidden.
|
||||||
@@ -36,23 +39,32 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders
|
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.
|
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:
|
- **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
|
- **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.
|
||||||
(Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there).
|
- **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).
|
||||||
- **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.
|
|
||||||
- **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only.
|
- **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
|
- **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
|
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.
|
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.
|
- **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.
|
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
|
- **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.
|
||||||
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
|
- **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.
|
||||||
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
|
- **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,
|
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`
|
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
|
(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.
|
`{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied.
|
||||||
|
- **Books**: an e-book reader for **EPUB, PDF, DOCX, MOBI, AZW and AZW3** (Media group; give it to people in Settings -> People as *Books*). Add files with the button or by dropping them on the page (up to 300 MB each; the server checks a file really is what its name says and does not store the same book twice). Titles, authors and covers are read out of the books in the browser; long-press or right-click a book to edit, download or delete it. Each person has their own shelf in `/data/users/<id>/books/`.
|
||||||
|
- **Reading**: EPUB uses epub.js (pages or scroll, two-page spreads on wide screens), PDF uses pdf.js (continuous scroll, zoom, selectable text, dark and sepia tints) DOCX is converted to HTML by mammoth, and MOBI, AZW and AZW3 are read by `@lingo-reader/mobi-parser`; both are shown in a frame that cannot run scripts (scroll only, with contents from the book or its headings). Contents, search inside the book, a progress slider, tap the edges or use the arrow keys, swipe on touch screens, tap the middle to hide the bars. Appearance (theme, text size, font, line spacing, margins, layout, zoom) is remembered on each device.
|
||||||
|
- **Synced**: where you got to, bookmarks, highlights (four colours) and notes are kept on the server, so a book opens where you left off on any device; two devices adding notes at once keep both (the newest edit of the same note wins). Continue reading on the library page, sorting and search, and *Copy as text* exports a book's notes.
|
||||||
|
- **Book store** (Books -> Book store): search free, DRM-free books from **Project Gutenberg** (through Gutendex), **Standard Ebooks**, 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: `GUTENDEX_URL`, `STANDARD_EBOOKS_URL` and `ARCHIVE_URL` can point at mirrors. `node tools/store-test.js` tests it against fake catalogues; it has not been run against the live sites from the build environment, so if one of them changes its feed format, 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. `node tools/radio-test.js` tests it against fake stations; 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. 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.
|
||||||
- **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
|
- **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
|
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
|
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
|
||||||
@@ -77,8 +89,9 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
|||||||
- **Recovery codes and activity log**: Settings -> Recovery codes makes 8 one-time codes; if you lose every device, the sign-in page's "Use an invite or recovery code" adds a new passkey with one (codes are stored only as hashes). Admins see an **Activity** list in Settings (sign-ins, failed sign-ins, passkeys added or removed, invites, recovery code use, people changes, backups); it is `/data/audit.log` and never contains codes or passwords.
|
- **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.
|
- **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/`.
|
- **Code layout**: `public/` holds one script per feature (`tasks.js`, `calendar.js`, `music.js`, `player.js`, `files.js`, `settings.js`, ...). They are plain scripts that share one global scope and load in the order listed in `index.html` (`boot.js` last); the versioned `?v=` addresses and the cache version are computed from every file in `public/`.
|
||||||
- **Podcast** opens `PODCAST_URL` (a home-network address) in its own tab. **Podcast Admin** is no longer a menu entry: it is a built-in link on the **Links** page (opens `VERSE_URL` in a new tab, cannot be edited or deleted) for people who have the Podcast Admin permission in Settings -> People; the old `#/verse` address goes to Links.
|
- **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.
|
||||||
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
@@ -105,3 +118,14 @@ On the server:
|
|||||||
cp .env.example .env # fill in secrets, PUBLIC_URL, MS_CLIENT_ID (for Mail), 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 pull && docker compose up -d
|
||||||
docker compose logs hub | grep "Setup code" # first sign-in: create your passkey
|
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.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Audiobooks: the library, covers, chapters, the audio itself and each person's listening position. Two ways to reach them:
|
||||||
|
// - OPENAUDIBLE_URL set: the hub talks to OpenAudible directly, signing in with the username and password saved in Settings (OpenAudible's own login;
|
||||||
|
// OPENAUDIBLE_USER / OPENAUDIBLE_PASSWORD are used when none are saved). The hub keeps each person's listening position itself.
|
||||||
|
// - otherwise: through the audiobook player (https://git.necloud.us/nate/audiobook-player) at AUDIOBOOKS_URL, signing in to it as the person
|
||||||
|
// (the account in Settings), or with the shared secret link (AUDIOBOOKS_LOGIN_LINK). The player keeps the positions.
|
||||||
|
// No password or link ever reaches the browser.
|
||||||
|
const { Readable } = require('stream');
|
||||||
|
const settings = require('./settings');
|
||||||
|
const users = require('./users');
|
||||||
|
|
||||||
|
const OA = (process.env.OPENAUDIBLE_URL || '').replace(/\/$/, ''); // direct mode
|
||||||
|
const BASE = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
|
||||||
|
const LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
|
||||||
|
const COOKIE = 'oa_session';
|
||||||
|
const sessions = new Map(); // person id -> the player's session cookie
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const cookieOf = r => { const c = (r.headers.getSetCookie ? r.headers.getSetCookie() : []).map(x => x.split(';')[0]).find(x => x.startsWith(COOKIE + '=')); return c || ''; };
|
||||||
|
|
||||||
|
// Hub path -> player path (the player itself only serves read-only calls for these)
|
||||||
|
const ROUTES = [
|
||||||
|
[/^\/books$/, () => '/api/books'],
|
||||||
|
[/^\/books\/([^/]+)\/(chapters\/flat|cover)$/, m => `/api/books/${m[1]}/${m[2]}`],
|
||||||
|
[/^\/books\/([^/]+)\/filelist$/, m => `/admin/api/books/${m[1]}/files`],
|
||||||
|
[/^\/books\/([^/]+)\/files\/([^/]+)$/, m => `/api/books/${m[1]}/files/${m[2]}`],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function signIn(me) {
|
||||||
|
const c = settings.get('ab_creds') || {};
|
||||||
|
let r;
|
||||||
|
if (c.u && c.p) r = await fetch(BASE + '/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username: c.u, password: c.p }), redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
||||||
|
else if (LINK) r = await fetch(LINK, { redirect: 'manual', signal: AbortSignal.timeout(15000) });
|
||||||
|
else throw Object.assign(new Error('Add your audiobook sign-in in Settings first'), { code: 503 });
|
||||||
|
const ck = cookieOf(r);
|
||||||
|
if (!ck) throw Object.assign(new Error(c.u ? 'The audiobook site did not accept your saved sign-in' : 'The audiobook site did not accept the shared sign-in'), { code: 502 });
|
||||||
|
sessions.set(me.id, ck); return ck;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct to OpenAudible: Basic auth with the saved login (or the server's), and a clear message when it is refused.
|
||||||
|
async function direct(path, init = {}) {
|
||||||
|
const c = settings.get('ab_creds') || {}, u = c.u || process.env.OPENAUDIBLE_USER || '', p = c.u ? c.p : process.env.OPENAUDIBLE_PASSWORD || '';
|
||||||
|
const headers = { ...init.headers }; if (u) headers.authorization = 'Basic ' + Buffer.from(u + ':' + p).toString('base64');
|
||||||
|
else throw Object.assign(new Error('Add your OpenAudible username and password in Settings first'), { code: 503 });
|
||||||
|
const r = await fetch(OA + path, { ...init, headers, redirect: 'manual' });
|
||||||
|
if (r.status === 401) { await r.body?.cancel().catch(() => {}); throw Object.assign(new Error('OpenAudible did not accept the username and password saved in Settings'), { code: 502 }); }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listening positions kept by the hub (direct mode)
|
||||||
|
const prog = require('./progress')('audiobook-progress.json'), progLoad = prog.load, progMerge = prog.merge;
|
||||||
|
|
||||||
|
async function upstream(me, path, init = {}, retry = true) {
|
||||||
|
if (OA) return direct(path, init);
|
||||||
|
const ck = sessions.get(me.id) || await signIn(me);
|
||||||
|
const r = await fetch(BASE + path, { ...init, headers: { ...init.headers, cookie: ck }, redirect: 'manual' });
|
||||||
|
if ((r.status === 401 || r.status === 302) && retry) { sessions.delete(me.id); await r.body?.cancel().catch(() => {}); return upstream(me, path, init, false); }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const me = users.current();
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/ab'.length) || '/';
|
||||||
|
const ctl = new AbortController(); res.on('close', () => ctl.abort());
|
||||||
|
try {
|
||||||
|
if (p === '/progress') { // each person's own positions are kept by the hub; with a personal sign-in on the audiobook player they are also kept in step with it
|
||||||
|
const personal = !OA && (c => !!(c && c.u && c.p))(settings.get('ab_creds'));
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const chunks = []; let n = 0; for await (const c of req) { n += c.length; if (n > 1e6) return json(res, 413, { error: 'Too large' }); chunks.push(c); }
|
||||||
|
let body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||||
|
const merged = progMerge(body);
|
||||||
|
if (personal) upstream(me, '/sync/progress', { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }).then(r => r.body?.cancel()).catch(() => {});
|
||||||
|
return json(res, 200, merged);
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
if (personal) { try { const r = await upstream(me, '/sync/progress', { signal: ctl.signal }); if (r.ok) progMerge(await r.json()); } catch { /* the hub's own copy is enough */ } }
|
||||||
|
return json(res, 200, progLoad());
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET') return json(res, 405, { error: 'Method not allowed' });
|
||||||
|
let to = null; for (const [re, f] of ROUTES) { const m = re.exec(p); if (m) { to = f(m); break; } }
|
||||||
|
if (!to) return json(res, 404, { error: 'Not found' });
|
||||||
|
const headers = req.headers.range ? { range: req.headers.range } : {};
|
||||||
|
const r = await upstream(me, to, { headers, signal: ctl.signal });
|
||||||
|
const out = { 'cache-control': p.endsWith('/cover') ? 'private, max-age=86400' : 'no-store' };
|
||||||
|
for (const k of ['content-type', 'content-length', 'content-range', 'accept-ranges']) if (r.headers.get(k)) out[k] = r.headers.get(k);
|
||||||
|
const want = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || ''), total = +r.headers.get('content-length') || 0;
|
||||||
|
if (OA && want && r.status === 200 && total && (want[1] || want[2])) { // OpenAudible sent the whole file: phones need byte ranges, so cut the piece out here
|
||||||
|
const a = want[1] === '' ? Math.max(0, total - +want[2]) : +want[1], b = want[1] === '' || want[2] === '' ? total - 1 : Math.min(+want[2], total - 1);
|
||||||
|
if (a > b || a >= total) { await r.body.cancel().catch(() => {}); res.writeHead(416, { 'content-range': 'bytes */' + total }); return res.end(); }
|
||||||
|
res.writeHead(206, { ...out, 'content-range': `bytes ${a}-${b}/${total}`, 'content-length': String(b - a + 1), 'accept-ranges': 'bytes' });
|
||||||
|
let at = 0; const rd = r.body.getReader();
|
||||||
|
try { for (;;) { const { done, value } = await rd.read(); if (done) break; const end = at + value.length; if (end > a && at <= b) { if (!res.write(value.subarray(Math.max(0, a - at), Math.min(value.length, b + 1 - at)))) await new Promise(ok => res.once('drain', ok)); } at = end; if (at > b) break; } }
|
||||||
|
finally { rd.cancel().catch(() => {}); res.end(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(r.status, out);
|
||||||
|
if (!r.body) return res.end();
|
||||||
|
Readable.fromWeb(r.body).on('error', () => res.destroy()).pipe(res);
|
||||||
|
} catch (e) {
|
||||||
|
if (res.headersSent) return res.destroy();
|
||||||
|
json(res, e.code || 502, { error: e.code ? e.message : 'The audiobook site did not answer' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// E-book library: EPUB, PDF and DOCX files kept per person under /data/users/<id>/books/. The server stores the files, covers, where each person got to in each
|
||||||
|
// book, and their bookmarks, notes and highlights; the browser does the reading (epub.js, pdf.js and mammoth, in public/vendor/reader).
|
||||||
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||||
|
const users = require('./users');
|
||||||
|
|
||||||
|
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_JSON = 2e6;
|
||||||
|
const TYPES = { epub: 'application/epub+zip', pdf: 'application/pdf', mobi: 'application/x-mobipocket-ebook', azw: 'application/vnd.amazon.ebook', azw3: 'application/vnd.amazon.mobi8-ebook', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' };
|
||||||
|
const COVERS = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/webp': 'webp' };
|
||||||
|
|
||||||
|
const dir = () => { const d = path.join(users.dir(), 'books'); fs.mkdirSync(d, { recursive: true, mode: 0o700 }); return d; };
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const readJson = (f, dflt) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return dflt; } };
|
||||||
|
const writeJson = (f, o) => { const tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
const index = () => { const l = readJson(path.join(dir(), 'index.json'), []); return Array.isArray(l) ? l : []; };
|
||||||
|
const saveIndex = l => writeJson(path.join(dir(), 'index.json'), l);
|
||||||
|
const state = () => readJson(path.join(dir(), 'state.json'), {});
|
||||||
|
const saveState = s => writeJson(path.join(dir(), 'state.json'), s);
|
||||||
|
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
||||||
|
const coverFile = id => fs.readdirSync(dir()).find(f => f.startsWith(id + '.cover.'));
|
||||||
|
|
||||||
|
async function body(req, max) {
|
||||||
|
const chunks = []; let n = 0;
|
||||||
|
for await (const c of req) { n += c.length; if (n > max) return null; chunks.push(c); }
|
||||||
|
return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
const MOBI = new Set(['mobi', 'azw', 'azw3']);
|
||||||
|
/** Kindle files with DRM have a non-zero encryption type in their first record (the PalmDOC header, 12 bytes in). Those cannot be read and are refused. */
|
||||||
|
function mobiLocked(buf) { try { const rec0 = buf.readUInt32BE(78); return buf.readUInt16BE(rec0 + 12) !== 0; } catch { return true; } }
|
||||||
|
/** A file must look like what its name says: PDFs start with %PDF, EPUB and DOCX are zip archives (and an EPUB says so in its first entry). */
|
||||||
|
function looksLike(ext, buf) {
|
||||||
|
if (ext === 'pdf') return buf.subarray(0, 1024).includes('%PDF-');
|
||||||
|
if (MOBI.has(ext)) return buf.length > 100 && buf.toString('latin1', 60, 68) === 'BOOKMOBI';
|
||||||
|
if (buf.length < 4 || buf[0] !== 0x50 || buf[1] !== 0x4b) return false;
|
||||||
|
return ext === 'epub' ? buf.subarray(0, 200).includes('mimetypeapplication/epub+zip') || buf.includes('META-INF/container.xml') : buf.includes('word/document.xml');
|
||||||
|
}
|
||||||
|
const withState = (b, st) => ({ ...b, loc: (st[b.id] || {}).loc || null, pct: (st[b.id] || {}).pct || 0, read: (st[b.id] || {}).t || 0 });
|
||||||
|
|
||||||
|
/** Merge notes by id; for each id the newest edit (or deletion) wins, so two devices can add notes without losing each other's. */
|
||||||
|
function mergeNotes(have, sent) {
|
||||||
|
const by = new Map((have || []).map(n => [n.id, n]));
|
||||||
|
for (const n of Array.isArray(sent) ? sent.slice(0, 2000) : []) {
|
||||||
|
if (!n || !/^[\w-]{1,40}$/.test(n.id || '')) continue;
|
||||||
|
const clean = { id: n.id, t: +n.t || 0, type: ['mark', 'hl', 'note'].includes(n.type) ? n.type : 'mark', loc: clip(n.loc, 3000), pct: Math.min(1, Math.max(0, +n.pct || 0)),
|
||||||
|
text: clip(n.text, 2000), note: clip(n.note, 5000), color: /^[a-z]{3,12}$/.test(n.color || '') ? n.color : 'yellow', page: Math.max(0, Math.round(+n.page || 0)), del: !!n.del };
|
||||||
|
const old = by.get(n.id);
|
||||||
|
if (!old || clean.t >= old.t) by.set(n.id, clean);
|
||||||
|
}
|
||||||
|
return [...by.values()].filter(n => !n.del || Date.now() - n.t < 30 * 864e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put a book on the signed-in person's shelf (the same checks for an upload and for a download from the store). Returns the book; `duplicate` is set when it was already there. */
|
||||||
|
function addBook(buf, ext, meta = {}, cover) {
|
||||||
|
if (!TYPES[ext]) throw new Error('Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added');
|
||||||
|
if (buf.length > MAX_BOOK) throw new Error('That file is larger than 300 MB');
|
||||||
|
if (!looksLike(ext, buf)) throw new Error('That does not look like a real ' + ext.toUpperCase() + ' file');
|
||||||
|
if (MOBI.has(ext) && mobiLocked(buf)) throw new Error('This book has DRM (copy protection), which NE Hub cannot open. Only DRM-free books work.');
|
||||||
|
const hash = crypto.createHash('sha1').update(buf).digest('hex'), list = index(), dup = list.find(b => b.hash === hash);
|
||||||
|
if (dup) return { ...dup, duplicate: true };
|
||||||
|
const id = crypto.randomBytes(8).toString('hex');
|
||||||
|
fs.writeFileSync(path.join(dir(), id + '.' + ext), buf, { mode: 0o600 });
|
||||||
|
const book = { id, ext, hash, size: buf.length, added: Date.now(), title: clip(meta.title, 200) || 'Untitled', author: clip(meta.author, 200), cover: false };
|
||||||
|
if (cover && COVERS[cover.type] && cover.data.length <= MAX_COVER) { fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[cover.type]}`), cover.data, { mode: 0o600 }); book.cover = true; }
|
||||||
|
list.push(book); saveIndex(list); return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/read'.length) || '/';
|
||||||
|
let m;
|
||||||
|
if (p.startsWith('/store/')) return require('./store').handle(req, res, p.slice('/store'.length));
|
||||||
|
if (p === '/list' && req.method === 'GET') { const st = state(); return json(res, 200, index().map(b => withState(b, st))); }
|
||||||
|
|
||||||
|
if (p === '/upload' && req.method === 'POST') {
|
||||||
|
const name = clip(u.searchParams.get('name'), 200), ext = path.extname(name).slice(1).toLowerCase();
|
||||||
|
if (!TYPES[ext]) return json(res, 400, { error: 'Only EPUB, PDF, DOCX, MOBI, AZW and AZW3 books can be added' });
|
||||||
|
const buf = await body(req, MAX_BOOK); if (!buf) return json(res, 413, { error: 'That file is larger than 300 MB' });
|
||||||
|
try { return json(res, 200, addBook(buf, ext, { title: path.basename(name, '.' + ext).replace(/[_]+/g, ' ') })); } catch (e) { return json(res, 400, { error: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p === '/state' && req.method === 'GET') return json(res, 200, state());
|
||||||
|
|
||||||
|
if ((m = /^\/book\/(\w{16})(?:\/(file|cover|pos|notes))?$/.exec(p))) {
|
||||||
|
const id = m[1], part = m[2], list = index(), book = list.find(b => b.id === id);
|
||||||
|
if (!book) return json(res, 404, { error: 'Book not found' });
|
||||||
|
|
||||||
|
if (part === 'file' && req.method === 'GET') { // the book itself, with range support so PDFs can load page by page
|
||||||
|
const f = path.join(dir(), id + '.' + book.ext), size = fs.statSync(f).size, range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '');
|
||||||
|
const h = { 'content-type': TYPES[book.ext], 'accept-ranges': 'bytes', 'cache-control': 'private, max-age=3600', 'content-disposition': 'attachment; filename*=UTF-8\'\'' + encodeURIComponent(book.title + '.' + book.ext) };
|
||||||
|
if (u.searchParams.get('inline') === '1') h['content-disposition'] = 'inline';
|
||||||
|
let a = 0, b = size - 1, code = 200;
|
||||||
|
if (range && (range[1] || range[2])) {
|
||||||
|
a = range[1] ? +range[1] : Math.max(0, size - +range[2]); b = range[1] && range[2] ? Math.min(+range[2], size - 1) : size - 1;
|
||||||
|
if (a > b || a >= size) { res.writeHead(416, { 'content-range': 'bytes */' + size }); return res.end(); }
|
||||||
|
code = 206; h['content-range'] = `bytes ${a}-${b}/${size}`;
|
||||||
|
}
|
||||||
|
h['content-length'] = b - a + 1; res.writeHead(code, h);
|
||||||
|
return fs.createReadStream(f, { start: a, end: b }).pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part === 'cover') {
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const c = coverFile(id); if (!c) return json(res, 404, { error: 'No cover' });
|
||||||
|
res.writeHead(200, { 'content-type': Object.entries(COVERS).find(([, e]) => c.endsWith('.' + e))[0], 'cache-control': 'private, max-age=86400' });
|
||||||
|
return fs.createReadStream(path.join(dir(), c)).pipe(res);
|
||||||
|
}
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
const type = String(req.headers['content-type'] || '').split(';')[0].trim();
|
||||||
|
if (!COVERS[type]) return json(res, 400, { error: 'Cover must be a JPEG, PNG, GIF or WebP image' });
|
||||||
|
const buf = await body(req, MAX_COVER); if (!buf) return json(res, 413, { error: 'Cover too large' });
|
||||||
|
const old = coverFile(id); if (old) fs.unlinkSync(path.join(dir(), old));
|
||||||
|
fs.writeFileSync(path.join(dir(), `${id}.cover.${COVERS[type]}`), buf, { mode: 0o600 });
|
||||||
|
book.cover = true; saveIndex(list); return json(res, 200, book);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part === 'pos' && req.method === 'POST') { // where you got to; the newest report wins so an old tab cannot undo a newer position
|
||||||
|
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||||
|
const st = state(), cur = st[id] || {};
|
||||||
|
if ((+o.t || 0) >= (cur.t || 0)) st[id] = { ...cur, loc: clip(o.loc, 600), pct: Math.min(1, Math.max(0, +o.pct || 0)), t: +o.t || Date.now() };
|
||||||
|
saveState(st); return json(res, 200, st[id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part === 'notes') {
|
||||||
|
const st = state(), cur = st[id] || {};
|
||||||
|
if (req.method === 'GET') return json(res, 200, (cur.notes || []).filter(n => !n.del));
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const b = await body(req, MAX_JSON); let o; try { o = JSON.parse((b || '').toString('utf8') || '[]'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||||
|
cur.notes = mergeNotes(cur.notes, o); st[id] = cur; saveState(st); return json(res, 200, cur.notes.filter(n => !n.del));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part && req.method === 'PATCH') { // title and author (the browser reads them out of the book after it is added)
|
||||||
|
const b = await body(req, 1e4); let o; try { o = JSON.parse((b || '').toString('utf8') || '{}'); } catch { return json(res, 400, { error: 'Bad request' }); }
|
||||||
|
if (o.title !== undefined) book.title = clip(o.title, 200) || book.title;
|
||||||
|
if (o.author !== undefined) book.author = clip(o.author, 200);
|
||||||
|
saveIndex(list); return json(res, 200, book);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!part && req.method === 'DELETE') {
|
||||||
|
for (const f of fs.readdirSync(dir())) if (f.startsWith(id + '.')) fs.unlinkSync(path.join(dir(), f));
|
||||||
|
saveIndex(list.filter(b => b.id !== id)); const st = state(); delete st[id]; saveState(st);
|
||||||
|
return json(res, 200, { removed: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a book's file is on disk (for the signed-in person). */
|
||||||
|
const fileOf = id => { const b = index().find(x => x.id === id); return b ? { path: path.join(dir(), id + '.' + b.ext), ext: b.ext, title: b.title, author: b.author } : null; };
|
||||||
|
|
||||||
|
module.exports = { handle, mergeNotes, looksLike, addBook, index, TYPES, fileOf };
|
||||||
@@ -222,60 +222,173 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed.
|
// ---- 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';
|
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 readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
||||||
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||||
const loadLocal = () => readJ(here(EV_FILE), []);
|
const HEX = /^#[0-9a-f]{6}$/i;
|
||||||
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
|
const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
||||||
|
const dirFor = id => users.dirOf(id);
|
||||||
|
const myId = () => users.current().id;
|
||||||
|
|
||||||
|
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
||||||
|
function calsIn(dir) {
|
||||||
|
const l = readJ(path.join(dir, NCAL_FILE), null);
|
||||||
|
return Array.isArray(l) && l.length ? l : [{ id: 'local', name: 'NE Hub', color: DEFAULT_COLOR, shares: [] }];
|
||||||
|
}
|
||||||
|
const saveCals = v => writeJ(NCAL_FILE, v);
|
||||||
|
const eventsIn = dir => readJ(path.join(dir, EV_FILE), []);
|
||||||
|
const saveEvents = (dir, v) => atomicWrite(path.join(dir, EV_FILE), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||||
|
const evCal = e => e.cal || 'local';
|
||||||
|
const colorOverrides = () => readJ(here(COLOR_FILE), {});
|
||||||
|
|
||||||
|
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
|
||||||
|
function sharedWithMe() {
|
||||||
|
const me = myId(), out = [];
|
||||||
|
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('~');
|
||||||
|
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) {
|
function feedToken(reset) {
|
||||||
let t = readJ(here(FEED_FILE), {}).token;
|
let t = readJ(here(FEED_FILE), {}).token;
|
||||||
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
|
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
|
||||||
return 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.
|
/** 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) {
|
function feed(req, res) {
|
||||||
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
|
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
|
||||||
const got = Buffer.from(m ? m[1] : '');
|
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 (m) for (const u of users.all()) {
|
||||||
if (u.disabled) continue;
|
if (u.disabled) continue;
|
||||||
const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || ''));
|
const dir = dirFor(u.id);
|
||||||
if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; }
|
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.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)
|
// ---- 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 => 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 json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
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) {
|
async function collectEvents(win) {
|
||||||
const all = [], errors = [];
|
const all = [], errors = [], over = colorOverrides();
|
||||||
await Promise.all(load().map(async c => {
|
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}`); }
|
catch (e) { errors.push(`${c.name}: ${e.message}`); }
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
for (const r of await caldav.listRaw(win.from, win.to))
|
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); }
|
} 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 }); }
|
try {
|
||||||
catch (e) { errors.push('NE Hub: ' + e.message); }
|
const mine = dirFor(myId()), evs = eventsIn(mine);
|
||||||
|
const add = (dir, evs, c, id, extra) => {
|
||||||
|
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
|
||||||
|
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, ...extra });
|
||||||
|
};
|
||||||
|
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
|
||||||
|
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
|
||||||
|
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||||
return { events: all, errors };
|
return { events: all, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const u = new URL(req.url, 'http://x');
|
const u = new URL(req.url, 'http://x');
|
||||||
try {
|
try {
|
||||||
if (u.pathname === '/api/cal/list') return json(res, 200, [{ ...LOCAL, local: true }, ...caldav.enabled().map(c => ({ ...c, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]);
|
if (u.pathname === '/api/cal/list') {
|
||||||
|
const over = colorOverrides();
|
||||||
|
return json(res, 200, [
|
||||||
|
...calsIn(users.dir()).map(c => ({ id: c.id, name: c.name, color: c.color || DEFAULT_COLOR, local: true, shares: (c.shares || []).filter(x => users.get(x.user)) })),
|
||||||
|
...sharedWithMe().map(s => ({ id: s.id, name: s.cal.name, color: over[s.id] || s.cal.color || DEFAULT_COLOR, local: true, shared: true, owner: s.ownerName, mode: s.mode })),
|
||||||
|
...caldav.enabled().map(c => ({ ...c, color: over[c.id] || c.color, icloud: true })),
|
||||||
|
...load().map(({ id, name, color }) => ({ id, name, color }))]);
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/cal/ncal' && req.method === 'POST') { // create
|
||||||
|
const { name, color } = JSON.parse(await readBody(req));
|
||||||
|
const cals = calsIn(users.dir()), nm = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
||||||
|
if (!nm) throw new Error('Give the calendar a name');
|
||||||
|
if (cals.length >= 30) throw new Error('That is plenty of calendars');
|
||||||
|
const c = { id: crypto.randomBytes(5).toString('hex'), name: nm, color: HEX.test(color) ? color : COLORS[cals.length % COLORS.length], shares: [] };
|
||||||
|
saveCals([...cals, c]);
|
||||||
|
return json(res, 200, { ok: true, id: c.id });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/cal/ncal/update' && req.method === 'POST') { // rename, recolor, share
|
||||||
|
const b = JSON.parse(await readBody(req));
|
||||||
|
const cals = calsIn(users.dir()), c = cals.find(x => x.id === b.id);
|
||||||
|
if (!c) throw new Error('Calendar not found');
|
||||||
|
if (b.name != null) { const nm = String(b.name).replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); c.name = nm; }
|
||||||
|
if (b.color != null) { if (!HEX.test(b.color)) throw new Error('Pick a color'); c.color = b.color; }
|
||||||
|
if (b.shares != null) {
|
||||||
|
if (!Array.isArray(b.shares)) throw new Error('Bad share list');
|
||||||
|
const seen = new Set();
|
||||||
|
c.shares = b.shares.filter(x => x && x.user !== myId() && users.get(x.user) && !seen.has(x.user) && seen.add(x.user)).map(x => ({ user: x.user, mode: x.mode === 'edit' ? 'edit' : 'view' }));
|
||||||
|
}
|
||||||
|
saveCals(cals);
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/cal/ncal/delete' && req.method === 'POST') {
|
||||||
|
const { id } = JSON.parse(await readBody(req));
|
||||||
|
const cals = calsIn(users.dir());
|
||||||
|
if (!cals.some(x => x.id === id)) throw new Error('Calendar not found');
|
||||||
|
if (cals.length < 2) throw new Error('Keep at least one NE Hub calendar');
|
||||||
|
saveCals(cals.filter(x => x.id !== id));
|
||||||
|
saveEvents(users.dir(), eventsIn(users.dir()).filter(e => evCal(e) !== id));
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/cal/ncal/feed' && req.method === 'POST') { // one calendar's subscribe link (reset: true makes a new one)
|
||||||
|
const b = JSON.parse(await readBody(req));
|
||||||
|
const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
|
||||||
|
return json(res, 200, { url: `${origin}/cal/feed/${calToken(b.id, !!b.reset)}.ics` });
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/cal/people') // who a calendar can be shared with
|
||||||
|
return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId()).map(x => ({ id: x.id, name: x.name })));
|
||||||
|
if (u.pathname === '/api/cal/color' && req.method === 'POST') { // your own color for iCloud, shared or link calendars
|
||||||
|
const { id, color } = JSON.parse(await readBody(req));
|
||||||
|
if (!HEX.test(color)) throw new Error('Pick a color');
|
||||||
|
const link = load(), l = link.find(x => x.id === id);
|
||||||
|
if (l) { l.color = color; save(link); } else writeJ(COLOR_FILE, { ...colorOverrides(), [id]: color });
|
||||||
|
return json(res, 200, { ok: true });
|
||||||
|
}
|
||||||
if (u.pathname === '/api/cal/add' && req.method === 'POST') {
|
if (u.pathname === '/api/cal/add' && req.method === 'POST') {
|
||||||
const { name, url } = JSON.parse(await readBody(req));
|
const { name, url } = JSON.parse(await readBody(req));
|
||||||
if (!url) throw new Error('Paste the calendar link');
|
if (!url) throw new Error('Paste the calendar link');
|
||||||
@@ -298,10 +411,11 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
|
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
|
||||||
const q = u.searchParams, tz = q.get('tz') || 'UTC';
|
const q = u.searchParams, tz = q.get('tz') || 'UTC';
|
||||||
if (q.get('cal') === 'local') {
|
const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal'));
|
||||||
const e = loadLocal().find(x => x.uid === q.get('uid'));
|
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');
|
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'))) {
|
if (caldav.isCal(q.get('cal'))) {
|
||||||
const r = await caldav.getResource(q.get('cal'), q.get('href'));
|
const r = await caldav.getResource(q.get('cal'), q.get('href'));
|
||||||
@@ -313,12 +427,14 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
if (u.pathname === '/api/cal/event' && req.method === 'POST') {
|
if (u.pathname === '/api/cal/event' && req.method === 'POST') {
|
||||||
const b = JSON.parse(await readBody(req));
|
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);
|
ics.validate(b.fields);
|
||||||
const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === 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, fields: b.fields, updated: Date.now() };
|
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() });
|
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
|
||||||
writeJ(EV_FILE, all);
|
saveEvents(r.dir, all);
|
||||||
return json(res, 200, { ok: true, uid });
|
return json(res, 200, { ok: true, uid });
|
||||||
}
|
}
|
||||||
if (caldav.isCal(b.cal)) {
|
if (caldav.isCal(b.cal)) {
|
||||||
@@ -336,7 +452,12 @@ async function handle(req, res) {
|
|||||||
}
|
}
|
||||||
if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') {
|
if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') {
|
||||||
const b = JSON.parse(await readBody(req));
|
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 }); }
|
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');
|
throw new Error('Unknown calendar');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ services:
|
|||||||
MS_CLIENT_ID: ${MS_CLIENT_ID:-} # Outlook.com mail (Microsoft app registration, see README -> Mail); leave empty to switch Mail off
|
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:-}
|
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
|
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||||
|
TRANSCRIBE_API_URL: http://127.0.0.1:8000 # the bundled speech-to-text service (whisper, below): makes a transcript for a podcast or audiobook on request. Remove this line and the service to skip it
|
||||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
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/}
|
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
|
||||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
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)
|
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
|
HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log
|
||||||
@@ -46,6 +49,17 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- signal-data:/home/.local/share/signal-cli
|
- 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);
|
# 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.
|
# 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:
|
lyrica:
|
||||||
@@ -63,3 +77,5 @@ volumes:
|
|||||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||||
signal-data:
|
signal-data:
|
||||||
name: nehub-signal
|
name: nehub-signal
|
||||||
|
whisper-models:
|
||||||
|
name: nehub-whisper
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
// 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');
|
||||||
|
const prog = require('./progress')('librivox-progress.json'); // keyed lv:<book id>: seconds into the whole book
|
||||||
|
|
||||||
|
const BASE = () => (process.env.LIBRIVOX_URL || 'https://librivox.org').replace(/\/+$/, '');
|
||||||
|
const HOSTS = () => ['librivox.org', 'archive.org', new URL(BASE()).hostname];
|
||||||
|
const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().some(h => x.hostname === h || x.hostname.endsWith('.' + h)); } catch { return false; } };
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const plain = s => String(s || '').replace(/<[^>]+>/g, ' ').replace(/ /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 || '' });
|
||||||
|
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 === '/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 itself is on the Internet Archive; the browser is sent straight there (it supports seeking)
|
||||||
|
const b = await book(m[1]), c = b.chapters[+m[2]]; if (!c) throw fail(404, 'No such chapter');
|
||||||
|
res.writeHead(302, { location: c.url, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
|
}
|
||||||
|
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 };
|
||||||
+30
-28
@@ -39,28 +39,30 @@ function friendly(a, e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- connections: one per account, kept for a minute after the last use
|
// ---- 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 pool = new Map(); // account id -> { client, timer, key }
|
||||||
const forget = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
|
const drop = id => { const e = pool.get(id); if (e) { clearTimeout(e.timer); try { e.client.close(); } catch {} pool.delete(id); } };
|
||||||
|
const forget = id => { folderCache.delete(id); boxCache.delete(id); drop(id); drop(id + ':bg'); };
|
||||||
const keyOf = a => a.address + '\n' + a.pass;
|
const keyOf = a => a.address + '\n' + a.pass;
|
||||||
async function open(a) {
|
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 e = pool.get(a.id);
|
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(() => forget(a.id), 60e3).unref(); return e.client; }
|
if (e && e.key === keyOf(a) && e.client.usable) { clearTimeout(e.timer); e.timer = setTimeout(() => drop(pid), IDLE).unref(); return e.client; }
|
||||||
forget(a.id);
|
drop(pid);
|
||||||
const c = cfgOf(a);
|
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 });
|
const client = new ImapFlow({ host: c.host, port: c.port, secure: c.secure, ...(c.test ? { doSTARTTLS: false, tls: { rejectUnauthorized: false } } : {}), auth: { user: a.address, pass: a.pass }, logger: false, emitLogs: false, socketTimeout: 60e3, greetingTimeout: 20e3, connectionTimeout: 20e3 });
|
||||||
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(a.id); if (cur && cur.client === client) pool.delete(a.id); });
|
client.on('error', () => {}); client.on('close', () => { const cur = pool.get(pid); if (cur && cur.client === client) pool.delete(a.id); });
|
||||||
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
|
try { await client.connect(); } catch (err) { throw fail(err.authenticationFailed ? 401 : 502, friendly(a, err)); }
|
||||||
pool.set(a.id, { client, key: keyOf(a), timer: setTimeout(() => forget(a.id), 60e3).unref() });
|
pool.set(pid, { client, key: keyOf(a), timer: setTimeout(() => drop(pid), IDLE).unref() });
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
|
/** Run `fn(client)`; if the connection had gone stale, reconnect once. */
|
||||||
async function withClient(a, fn) {
|
async function withClient(a, fn, slot) {
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
const client = await open(a);
|
const client = await open(a, slot);
|
||||||
try { return await fn(client); }
|
try { return await fn(client); }
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err.status) throw err;
|
if (err.status) throw err;
|
||||||
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { forget(a.id); continue; }
|
if (attempt === 0 && (!client.usable || /closed|ECONNRESET|socket|EPIPE|Connection not available/i.test(err.message || ''))) { drop(slot ? a.id + ':' + slot : a.id); continue; }
|
||||||
throw fail(502, friendly(a, err));
|
throw fail(502, friendly(a, err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,20 +94,20 @@ async function pathOf(a, client, folder) {
|
|||||||
const addrOf = x => x ? { name: x.name || '', address: x.address || '' } : { name: '', address: '' };
|
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 hasAttachment = n => !!n && (((n.disposition || '').toLowerCase() === 'attachment') || (n.childNodes || []).some(hasAttachment));
|
||||||
|
|
||||||
|
const folderCache = new Map(); // account id -> { t, v }
|
||||||
async function folders(a) {
|
async function folders(a) {
|
||||||
return withClient(a, async client => {
|
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 { 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 skip = b => b.flags.has('\\Noselect') || ['\\Flagged', '\\Important'].includes(b.specialUse);
|
||||||
const rows = [];
|
const keep = list.filter(b => !skip(b)), wellFirst = [...keep.filter(b => byPath[b.path]), ...keep.filter(b => !byPath[b.path])];
|
||||||
for (const b of list) {
|
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)
|
||||||
if (skip(b)) continue;
|
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 {} }
|
||||||
let st = {}; try { st = 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 k = byPath[b.path];
|
|
||||||
rows.push({ 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);
|
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));
|
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 }) {
|
async function list(a, { folder, skip, q }) {
|
||||||
@@ -140,11 +142,10 @@ async function source(a, mid) {
|
|||||||
return withClient(a, async client => {
|
return withClient(a, async client => {
|
||||||
const lock = await client.getMailboxLock(path);
|
const lock = await client.getMailboxLock(path);
|
||||||
try {
|
try {
|
||||||
const dl = await client.download(String(uid), undefined, { uid: true });
|
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 (!dl || !dl.content) throw fail(404, 'That message is gone');
|
if (!m || !m.source) throw fail(404, 'That message is gone');
|
||||||
const chunks = []; let n = 0;
|
if (m.source.length > MAX_SOURCE) throw fail(400, 'That message is too large to open here');
|
||||||
for await (const ch of dl.content) { n += ch.length; if (n > MAX_SOURCE) throw fail(400, 'That message is too large to open here'); chunks.push(ch); }
|
return { parsed: await simpleParser(m.source), path, uid, flags: m.flags || new Set() };
|
||||||
return { parsed: await simpleParser(Buffer.concat(chunks)), path, uid };
|
|
||||||
} finally { lock.release(); }
|
} finally { lock.release(); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,11 +153,10 @@ 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 || '' }));
|
const addrs = v => (v && v.value ? v.value : []).map(x => ({ name: x.name || '', address: x.address || '' }));
|
||||||
|
|
||||||
async function message(a, mid) {
|
async function message(a, mid) {
|
||||||
const { parsed: p, path, uid } = await source(a, mid);
|
const { parsed: p, flags } = await source(a, mid);
|
||||||
let html = p.html || (p.textAsHtml ? p.textAsHtml : textToHtml(p.text || ''));
|
let html = p.html || (p.textAsHtml ? p.textAsHtml : textToHtml(p.text || ''));
|
||||||
const atts = p.attachments || [];
|
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')}`);
|
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')}`);
|
||||||
const flags = await withClient(a, async client => { const lock = await client.getMailboxLock(path); try { const m = await client.fetchOne(String(uid), { flags: true }, { uid: true }); return m ? m.flags : new Set(); } finally { lock.release(); } });
|
|
||||||
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'),
|
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 })) };
|
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 })) };
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,9 @@ async function del(a, mid, permanent) {
|
|||||||
const [path, uid] = dec(mid);
|
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(); } });
|
return withClient(a, async client => { const lock = await client.getMailboxLock(path); try { await client.messageDelete(String(uid), { uid: true }); } finally { lock.release(); } });
|
||||||
}
|
}
|
||||||
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0); }
|
/** Counts shown in the folder list are kept 30 s; reading, moving, deleting or sending changes them, so the next request must ask again. */
|
||||||
|
const dropFolders = id => { if (id) folderCache.delete(id); else folderCache.clear(); };
|
||||||
|
async function unread(a) { return withClient(a, async client => (await client.status('INBOX', { unseen: true })).unseen || 0, 'bg'); }
|
||||||
|
|
||||||
// ---- sending
|
// ---- sending
|
||||||
function transport(a) {
|
function transport(a) {
|
||||||
@@ -250,4 +252,4 @@ async function connectNew({ type, address, password }) {
|
|||||||
return { type, address: addr, pass, name: '' };
|
return { type, address: addr, pass, name: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
|
module.exports = { dropFolders, PRESETS: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, { name: v.name }])), folders, list, message, attachment, update, move, del, unread, send, reply, connectNew, forget };
|
||||||
@@ -36,7 +36,13 @@ function secretStatus() {
|
|||||||
|
|
||||||
const MAX_BODY = 6 * 1024 * 1024, MAX_ATTACH = 3 * 1024 * 1024;
|
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 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) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(obj)); };
|
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 });
|
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||||
|
|
||||||
// ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
|
// ---- each person's accounts (encrypted): [{ id, type: 'outlook' | 'gmail' | 'icloud', address, name, ...credentials }]
|
||||||
@@ -149,16 +155,19 @@ const safeName = n => String(n || 'attachment').replace(/[\r\n"\\/]+/g, '_').sli
|
|||||||
const cleanAttachments = list => { let total = 0; const out = (Array.isArray(list) ? list : []).slice(0, 10).map(a => { const data = String(a.data || ''); total += Buffer.from(data, 'base64').length; return { name: safeName(a.name), type: String(a.type || 'application/octet-stream').slice(0, 100), data }; }); if (total > MAX_ATTACH) throw fail(400, 'Attachments can total about 3 MB'); return out; };
|
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 }
|
let folderCache = new Map(); // account id -> { t, v }
|
||||||
const dropFolderCache = id => id ? folderCache.delete(id) : folderCache.clear();
|
const dropFolderCache = id => { if (id) { folderCache.delete(id); unreadCache.delete(id); } else { folderCache.clear(); unreadCache.clear(); } imap.dropFolders(id); };
|
||||||
|
|
||||||
// ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
|
// ---- Outlook.com through Microsoft Graph. Every function takes the account and Graph's own ids.
|
||||||
const outlook = {
|
const outlook = {
|
||||||
async folders(a) {
|
async folders(a) {
|
||||||
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 15e3) return c.v;
|
const c = folderCache.get(a.id); if (c && Date.now() - c.t < 15e3) return c.v;
|
||||||
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount';
|
const sel = '?$select=id,displayName,unreadItemCount,totalItemCount', keys = Object.keys(WELL);
|
||||||
const wk = await Promise.all(Object.keys(WELL).map(k => graph(a, `/me/mailFolders/${k}${sel}`).then(f => ({ id: k, realId: f.id, name: WELL[k], unread: f.unreadItemCount || 0, total: f.totalItemCount || 0, well: true })).catch(() => null)));
|
// 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 known = new Set(wk.filter(Boolean).map(f => f.realId));
|
||||||
const rest = (await graph(a, `/me/mailFolders${sel}&$top=100`).catch(() => ({ value: [] }))).value || [];
|
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 }))];
|
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;
|
folderCache.set(a.id, { t: Date.now(), v }); return v;
|
||||||
},
|
},
|
||||||
@@ -205,12 +214,8 @@ const providerOf = a => a.type === 'outlook' ? outlook : imap;
|
|||||||
async function withInline(a, html, attachments, mid) {
|
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)
|
// (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 inl = (attachments || []).filter(x => x.isInline && /^image\//i.test(x.contentType || '') && (x.size || 0) < 1.5 * 1024 * 1024).slice(0, 15);
|
||||||
for (const x of inl) {
|
const fulls = await Promise.all(inl.map(x => graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`).catch(() => null))); // all at once
|
||||||
try {
|
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}`); });
|
||||||
const full = await graph(a, `/me/messages/${id(mid)}/attachments/${id(x.id)}`);
|
|
||||||
if (full && full.contentBytes && full.contentId) html = html.split('cid:' + String(full.contentId).replace(/^<|>$/g, '')).join(`data:${full.contentType || x.contentType};base64,${full.contentBytes}`);
|
|
||||||
} catch { /* leave that picture out */ }
|
|
||||||
}
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,12 +251,17 @@ async function adminHandle(req, res, p) {
|
|||||||
json(res, 404, { error: 'Not found' });
|
json(res, 404, { error: 'Not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every unread count in one go (an account that cannot be reached is just left out of the total). */
|
const unreadCache = new Map(); // account id -> { t, n }: counts are shown at once from here and refreshed when older than 30 s
|
||||||
async function overview() {
|
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 list = accounts();
|
||||||
const rows = await Promise.all(list.map(async a => {
|
const rows = await Promise.all(list.map(async a => {
|
||||||
try { return { ...publicAcct(a), unread: await providerOf(a).unread(a) }; }
|
const c = unreadCache.get(a.id);
|
||||||
catch (e) { return { ...publicAcct(a), unread: null, error: e.message, expired: e.status === 409 }; }
|
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 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 shown = rows.filter(r => live.some(x => x.id === r.id) || !r.expired);
|
||||||
@@ -263,7 +273,7 @@ async function handle(req, res) {
|
|||||||
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
const u = new URL(req.url, 'http://x'), p = u.pathname, q = u.searchParams;
|
||||||
try {
|
try {
|
||||||
if (p === '/api/mail/admin' || p.startsWith('/api/mail/admin/')) return await adminHandle(req, res, p);
|
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()), outlook: outlookOn(), providers: imap.PRESETS });
|
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/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') {
|
if (p === '/api/mail/folders') {
|
||||||
const a = findAccount(q.get('acct') || undefined);
|
const a = findAccount(q.get('acct') || undefined);
|
||||||
@@ -319,4 +329,14 @@ async function handle(req, res) {
|
|||||||
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
} catch (e) { json(res, e.status || 400, { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
/** The newest messages in each connected account's Inbox (for the push alerts). One account that fails does not stop the others. */
|
||||||
|
async function newest() {
|
||||||
|
const out = [];
|
||||||
|
for (const a of accounts()) { try { const r = await withTimeout(providerOf(a).list(a, { folder: 'inbox', skip: 0, q: '' }), 15000); for (const m of r.items.slice(0, 10)) out.push({ id: a.id + ':' + m.id, subject: m.subject, from: (m.from && (m.from.name || m.from.address)) || '', read: !!m.read }); } catch { /* skipped this round */ } }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** For the admin health page: can each connected account be reached right now? */
|
||||||
|
async function health() {
|
||||||
|
return Promise.all(accounts().map(async a => { const t = Date.now(); try { await withTimeout(providerOf(a).unread(a), 8000); return { address: a.address, type: a.type, ok: true, ms: Date.now() - t }; } catch (e) { return { address: a.address, type: a.type, ok: false, error: e.message }; } }));
|
||||||
|
}
|
||||||
|
module.exports = { health, newest, enabled, outlookOn, handle, callback, secretStatus, recipients, textToHtml };
|
||||||
Generated
+25
-59
@@ -309,13 +309,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@zone-eu/mailsplit": {
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
"version": "5.4.27",
|
"version": "5.4.29",
|
||||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.27.tgz",
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.29.tgz",
|
||||||
"integrity": "sha512-9008ddFqqKk7lMF2S1sLU7ExfJZP9zPw5tkZy24EVt45Ryme2x85f/5vJZZqQ+vCJkKcCY78dJ5yuEVG3ZrVZA==",
|
"integrity": "sha512-CCUNycmUjrV5M0wNf23waWAt9eOHczhjLbUQS7k0FNJvR0py9VSqHFt+tSl/D0+XI73VLG0U+PwYtq9R0zsm9Q==",
|
||||||
"license": "(MIT OR EUPL-1.1+)",
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libbase64": "1.4.1",
|
"libbase64": "1.4.1",
|
||||||
"libmime": "5.5.2",
|
"libmime": "5.6.3",
|
||||||
"libqp": "3.0.1"
|
"libqp": "3.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -650,16 +650,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.9.1.tgz",
|
||||||
"integrity": "sha512-8drBcDmYhDNkMHzoXVd46/XnZg3krcZyLsyOyVuAxTQPPKUc0u/bpb3ocl36u2b9ZD/8J6rz2YdUbzHyaj1Pmw==",
|
"integrity": "sha512-hOq2/+T+WnQzRy7D40gYlio25nG+c9bhtrxowokOQM57SwZFqRbqAtxFQcIpx82lhYBPQEEVVjmkYpd9Fplj7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.27",
|
"@zone-eu/mailsplit": "5.4.29",
|
||||||
"encoding-japanese": "2.4.0",
|
"encoding-japanese": "2.4.0",
|
||||||
"iconv-lite": "0.7.3",
|
"iconv-lite": "0.7.3",
|
||||||
"libbase64": "1.4.1",
|
"libbase64": "1.4.1",
|
||||||
"libmime": "5.5.2",
|
"libmime": "5.6.3",
|
||||||
"libqp": "3.0.1",
|
"libqp": "3.0.1",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"socks": "2.8.10"
|
"socks": "2.8.10"
|
||||||
@@ -730,9 +730,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/libmime": {
|
"node_modules/libmime": {
|
||||||
"version": "5.5.2",
|
"version": "5.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.3.tgz",
|
||||||
"integrity": "sha512-Byw3CchK4eSqbgzpttsUoC4hx2VPP+hnveAuYAYZNipEc+0WlDFjVb1ISNscjq/idvyDP/Xjg8mnBcdgbsgvzg==",
|
"integrity": "sha512-cuTUW/Jybvh5O4/U9p61gyutHOjDtZyGQYuY99+bfnaFwqcDf2Nb+R6hMHY6DDen2H9wLKQSMpofWUgn2TI5JQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encoding-japanese": "2.4.0",
|
"encoding-japanese": "2.4.0",
|
||||||
@@ -773,17 +773,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser": {
|
"node_modules/mailparser": {
|
||||||
"version": "3.12.2",
|
"version": "3.12.4",
|
||||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.12.4.tgz",
|
||||||
"integrity": "sha512-JbOt+AjJIr7+bhMp8g7ILevleoDAD0bXVlAJmikrz0sbQqmt88YELKDwYQL5JNugkV9lcN+DFv32dyhPsvqpHQ==",
|
"integrity": "sha512-FdoMF+S89/AQaJHY7StwTfABPHZ8Vey+CDyUno5j+TO0jF0UxM+la4Pe9MeqKqBO4Y9ZMB2eomdyg5i+jzpfZw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.27",
|
"@zone-eu/mailsplit": "5.4.29",
|
||||||
"he": "1.2.0",
|
"he": "1.2.0",
|
||||||
"html-to-text": "10.0.1",
|
"html-to-text": "10.0.1",
|
||||||
"libmime": "5.6.0",
|
"libmime": "5.6.3",
|
||||||
"linkify-it": "5.0.2",
|
"linkify-it": "5.0.2",
|
||||||
"nodemailer": "10.1.7",
|
"nodemailer": "10.2.1",
|
||||||
"punycode.js": "2.3.1",
|
"punycode.js": "2.3.1",
|
||||||
"tlds": "1.261.0"
|
"tlds": "1.261.0"
|
||||||
},
|
},
|
||||||
@@ -791,30 +791,6 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mailparser/node_modules/libmime": {
|
|
||||||
"version": "5.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.6.0.tgz",
|
|
||||||
"integrity": "sha512-b5HR41QUt1whlvLnY3AhAYwba56DDiNv1xVTH5viVq86/m+wvW+HeooLx6x+MdNapho9ki7Lo/fTxuqwhEs/sQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"encoding-japanese": "2.4.0",
|
|
||||||
"iconv-lite": "0.7.3",
|
|
||||||
"libbase64": "1.4.1",
|
|
||||||
"libqp": "3.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mailparser/node_modules/nodemailer": {
|
|
||||||
"version": "10.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
|
|
||||||
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimalistic-assert": {
|
"node_modules/minimalistic-assert": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
@@ -837,9 +813,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.2.1.tgz",
|
||||||
"integrity": "sha512-/XyGw6kX2stMdKGUJdAQmYAeY1FscWfdRydfd2NtK3A3KDfUUXCqZzExSNAt2xhh90X2qLCR+o2Gd48YPJcJwA==",
|
"integrity": "sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -1035,30 +1011,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smtp-server": {
|
"node_modules/smtp-server": {
|
||||||
"version": "3.21.0",
|
"version": "3.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.21.1.tgz",
|
||||||
"integrity": "sha512-vhJTg1JR6+mM5vlCD/TzUvFcQ5JQiCqP0FY3TgMJ0u3MPuvxawKD/6PA+TacPYEFVAgZIjLaCOSf87L4jcN0+w==",
|
"integrity": "sha512-gj95f6YhcL0uu3m3FvRnQ6LSotursugAuVjK5V4f0f2C/4OSVpn/+h28F4pPJFob/myli9V+F7/Hxx9DJVdwvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ipv6-normalize": "1.0.1",
|
"ipv6-normalize": "1.0.1",
|
||||||
"nodemailer": "10.1.7",
|
"nodemailer": "10.2.1",
|
||||||
"punycode.js": "2.3.1"
|
"punycode.js": "2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/smtp-server/node_modules/nodemailer": {
|
|
||||||
"version": "10.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.7.tgz",
|
|
||||||
"integrity": "sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT-0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/socks": {
|
"node_modules/socks": {
|
||||||
"version": "2.8.10",
|
"version": "2.8.10",
|
||||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"test": "for t in smoke tasks push users plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
|
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles; do node tools/$t-test.js || exit 1; done",
|
||||||
"audit": "npm audit --omit=dev --audit-level=high"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
+145
@@ -0,0 +1,145 @@
|
|||||||
|
// Podcasts from Podgrab (https://github.com/akhilrex/podgrab). The hub talks to Podgrab's own web API on the person's behalf, so the
|
||||||
|
// Podgrab password (PODGRAB_PASSWORD, user "podgrab") stays on the server and the browser only ever talks to the hub.
|
||||||
|
const fs = require('fs'), path = require('path');
|
||||||
|
const users = require('./users');
|
||||||
|
const prog = require('./progress')('podcast-progress.json'); // where each person got to in each episode
|
||||||
|
const BASE = (process.env.PODCAST_URL || 'https://podcast.necloud.us/').replace(/\/$/, '');
|
||||||
|
const AUTH = process.env.PODGRAB_PASSWORD ? 'Basic ' + Buffer.from('podgrab:' + process.env.PODGRAB_PASSWORD).toString('base64') : '';
|
||||||
|
|
||||||
|
// Only these Podgrab calls are passed through: [method, path pattern]. Podgrab's page routes and its settings, backup and OPML routes are not.
|
||||||
|
const ALLOWED = [
|
||||||
|
['GET', /^\/podcasts$/], ['POST', /^\/podcasts$/],
|
||||||
|
['GET', /^\/podcasts\/[\w-]+$/], ['DELETE', /^\/podcasts\/[\w-]+$/],
|
||||||
|
['GET', /^\/podcasts\/[\w-]+\/(items|image|download|pause|unpause)$/], ['DELETE', /^\/podcasts\/[\w-]+\/items$/],
|
||||||
|
['GET', /^\/podcastitems$/], ['GET', /^\/podcastitems\/[\w-]+$/], ['PATCH', /^\/podcastitems\/[\w-]+$/],
|
||||||
|
['GET', /^\/podcastitems\/[\w-]+\/(image|file|markPlayed|markUnplayed|bookmark|unbookmark|download|delete)$/],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Podgrab holds every show in one shared library; which of them are on a person's own list is kept here, per person. A new person starts with an empty
|
||||||
|
// list (the admin starts with everything already in Podgrab). Taking a show off your list leaves Podgrab alone unless nobody else follows it.
|
||||||
|
const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json');
|
||||||
|
const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } };
|
||||||
|
const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
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 in Apple's free podcast directory (the search words go to Apple; nothing else does)
|
||||||
|
const q = (u.searchParams.get('q') || '').trim().slice(0, 100);
|
||||||
|
if (q.length < 2) return json(res, 200, []);
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://itunes.apple.com/search?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) });
|
||||||
|
if (!r.ok) throw new Error('status ' + r.status);
|
||||||
|
const out = ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 }));
|
||||||
|
return json(res, 200, out);
|
||||||
|
} catch { return json(res, 502, { error: 'The podcast directory did not answer' }); }
|
||||||
|
}
|
||||||
|
if (p === '/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;
|
||||||
|
}
|
||||||
|
module.exports = { handle, enabled, feedInfo, recentEpisodes, episodeAudio };
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
// Where each person has got to in each thing they listen to (audiobooks, podcast episodes), kept on the server so every device agrees.
|
||||||
|
// One file per person; for each item the newest report wins, so an old device cannot overwrite a newer position.
|
||||||
|
const fs = require('fs'), path = require('path');
|
||||||
|
const users = require('./users');
|
||||||
|
|
||||||
|
module.exports = name => {
|
||||||
|
const file = () => path.join(users.dir(), name);
|
||||||
|
const load = () => { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch { return {}; } };
|
||||||
|
const merge = body => {
|
||||||
|
const mine = load();
|
||||||
|
for (const [k, v] of Object.entries(body || {})) if (/^[\w.:-]{1,80}$/.test(k) && v && typeof v === 'object' && (v.t || 0) >= ((mine[k] || {}).t || 0)) mine[k] = { pos: +v.pos || 0, dur: +v.dur || 0, t: +v.t || 0 };
|
||||||
|
const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(mine), { mode: 0o600 }); fs.renameSync(tmp, f); return mine;
|
||||||
|
};
|
||||||
|
return { load, merge };
|
||||||
|
};
|
||||||
+33
-12
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
|||||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||||
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
||||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||||
const outbox = new Map(); let outTimer = null, warned = false;
|
const outbox = new Map(); let outTimer = null, warned = false;
|
||||||
function flushSettings() {
|
function flushSettings() {
|
||||||
outTimer = null;
|
outTimer = null;
|
||||||
@@ -51,6 +51,7 @@ async function loadSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
|
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
||||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||||
@@ -75,6 +76,11 @@ const ICONS = {
|
|||||||
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
||||||
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
||||||
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
||||||
|
rew: '<path d="M12 5V2L7 6l5 4V7a6 6 0 11-6 6"/>',
|
||||||
|
fwd: '<path d="M12 5V2l5 4-5 4V7a6 6 0 106 6"/>',
|
||||||
|
vol: '<path d="M4 9v6h4l5 4V5L8 9H4zM16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"/>',
|
||||||
|
mute: '<path d="M4 9v6h4l5 4V5L8 9H4zM17 9l4 6M21 9l-4 6"/>',
|
||||||
|
pip: '<rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"/>',
|
||||||
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
||||||
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
||||||
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
||||||
@@ -92,6 +98,11 @@ const ICONS = {
|
|||||||
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
|
link: '<path d="M10 14a4 4 0 005.7 0l3-3a4 4 0 00-5.7-5.7l-1 1M14 10a4 4 0 00-5.7 0l-3 3a4 4 0 005.7 5.7l1-1"/>',
|
||||||
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
check: '<circle cx="12" cy="12" r="9"/><path d="M8 12.5l3 3 5-6"/>',
|
||||||
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
trash: '<path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13"/>',
|
||||||
|
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
|
||||||
|
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
|
||||||
|
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
||||||
|
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
||||||
|
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
||||||
};
|
};
|
||||||
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
const icon = (n, cls = '') => `<svg class="${cls}" viewBox="0 0 24 24">${ICONS[n]}</svg>`;
|
||||||
|
|
||||||
@@ -146,12 +157,12 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
|||||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
|
||||||
// ---- Navigation
|
// ---- Navigation
|
||||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', music: 'music', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
||||||
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
||||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'audio', 'podcast']], ['', ['settings']]];
|
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'radio', 'read']], ['', ['settings']]];
|
||||||
const navGroups = (tabs = navTabs()) => {
|
const navGroups = (tabs = navTabs()) => {
|
||||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||||
@@ -189,7 +200,7 @@ function openMore() {
|
|||||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||||
}
|
}
|
||||||
|
|
||||||
const routesOf = () => ({ planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: viewPodcast, music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||||
let editor;
|
let editor;
|
||||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||||
@@ -251,10 +262,13 @@ function homeApps() {
|
|||||||
const T = {
|
const T = {
|
||||||
files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'),
|
files: () => tile('#/files', '#2f9e6e', 'files', 'Files', 'Seafile'),
|
||||||
music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'),
|
music: () => tile('#/music', '#e5a00d', 'music', 'Music', 'Plex'),
|
||||||
|
video: () => tile('#/video', '#c0392b', 'film', 'Movies & TV', 'Plex'),
|
||||||
planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'),
|
planner: () => tile('#/planner', '#d9453a', 'cal', 'Planner', allowed('calendar') && allowed('tasks') ? 'Calendar & tasks' : allowed('tasks') ? 'To-do lists' : 'Agenda'),
|
||||||
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'),
|
||||||
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'),
|
||||||
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'),
|
||||||
|
radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'),
|
||||||
|
read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'),
|
||||||
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
|
signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'),
|
||||||
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
||||||
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
||||||
@@ -264,18 +278,25 @@ function homeApps() {
|
|||||||
|
|
||||||
function viewHome() {
|
function viewHome() {
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
|
const evening = h >= 17 || h < 5; // after work the media comes first; during the day the agenda and tasks do
|
||||||
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
const greet = h < 5 ? 'Good night' : h < 12 ? 'Good morning' : h < 18 ? 'Good afternoon' : 'Good evening';
|
||||||
const recents = store.get('recents', []).slice(0, 5);
|
const recents = store.get('recents', []).slice(0, 5);
|
||||||
const t = typeof cur === 'function' ? cur() : null;
|
const t = typeof cur === 'function' ? cur() : null;
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
|
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||||
|
$('#view').innerHTML = `<div class="wrap home">
|
||||||
|
<header class="homehead"><div><h1>${greet}</h1><p class="sub">${today}</p></div>
|
||||||
|
<div class="homeact"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div></header>
|
||||||
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
|
||||||
<div class="bar" style="margin-top:8px"><button class="btn ghost" id="qadd">${icon('plus')}Quick add</button><button class="btn ghost" id="qpal">${icon('search')}Command palette <small style="opacity:.7;margin-left:4px">${/Mac|iPhone|iPad/.test(navigator.platform) ? '⌘K' : 'Ctrl K'}</small></button></div>
|
|
||||||
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
${allowed('music') ? '<div id="npslot"></div>' : ''}
|
||||||
<div id="tcard"></div>
|
${evening ? '<div id="resume"></div>' : ''}
|
||||||
<div id="agenda"></div>
|
<div class="homecols">
|
||||||
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
|
<div class="hcol"><div id="tcard"></div><div id="agenda"></div></div>
|
||||||
${homeApps()}
|
<div class="hcol">${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}
|
||||||
${allowed('mail') && cfg.mail ? '<div id="mcard"></div>' : ''}</div>`;
|
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}</div>
|
||||||
|
</div>
|
||||||
|
${evening ? '' : '<div id="resume"></div>'}
|
||||||
|
<div class="homeapps">${homeApps()}</div></div>`;
|
||||||
|
if (typeof homeResume === 'function') homeResume($('#resume'));
|
||||||
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
|
||||||
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
|
||||||
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Audiobooks: your library from the audiobook player, through the hub (the hub signs in for you). Books play in the same player as Music,
|
||||||
|
// and where you got to is kept by the audiobook player, so it follows you between this app, the player's own page and your other devices.
|
||||||
|
const abApi = (p, opts) => jfetch('/api/ab' + p, opts);
|
||||||
|
const abPick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
|
||||||
|
const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id'));
|
||||||
|
const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)';
|
||||||
|
const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; };
|
||||||
|
const abSeries = b => abPick(b, 'series_name', 'series') || '';
|
||||||
|
const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`;
|
||||||
|
const abFmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
|
||||||
|
const abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
|
||||||
|
let abBooks = null, abProg = {};
|
||||||
|
const abIsPlaying = () => { const t = cur(); return t && String(t.key).startsWith('ab:') ? t.key.slice(3) : ''; };
|
||||||
|
|
||||||
|
async function abFile(id) {
|
||||||
|
const data = await abApi(`/books/${encodeURIComponent(id)}/filelist`);
|
||||||
|
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
|
||||||
|
const names = arr.map(f => typeof f === 'string' ? f : abPick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
|
||||||
|
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i.test(n));
|
||||||
|
}
|
||||||
|
async function abChapters(id) {
|
||||||
|
try {
|
||||||
|
const data = await abApi(`/books/${encodeURIComponent(id)}/chapters/flat`), arr = Array.isArray(data) ? data : (data.chapters || []);
|
||||||
|
return arr.map(c => { let s = abPick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
|
||||||
|
if (s == null) { const ms = abPick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
|
||||||
|
return { title: abPick(c, 'title', 'name') || 'Chapter', start: Number(s) }; }).filter(c => !isNaN(c.start));
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start (or carry on with) a book: from where you got to, unless that was the very end. */
|
||||||
|
async function abPlay(b, at) {
|
||||||
|
const id = abId(b);
|
||||||
|
if (abIsPlaying() === id) { if (at != null) pl.a.currentTime = at; else if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||||
|
const file = await abFile(id);
|
||||||
|
if (!file) throw new Error('No playable MP3 or M4B for this book. Convert it in OpenAudible first.');
|
||||||
|
const pr = abProg[id], start = at != null ? at : pr && pr.pos > 10 && (!pr.dur || pr.pos < pr.dur - 30) ? pr.pos : 0;
|
||||||
|
playQueue([{ key: 'ab:' + id, title: abTitle(b), artist: abAuthor(b), album: abSeries(b), thumb: abCover(b), src: `/api/ab/books/${encodeURIComponent(id)}/files/${encodeURIComponent(file)}`, dur: 0, nocache: true }], 0);
|
||||||
|
if (start) pl.a.addEventListener('loadedmetadata', () => { if (abIsPlaying() === id) pl.a.currentTime = start; }, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where you got to goes back to the audiobook player every few seconds while listening, and when you pause or leave.
|
||||||
|
let abLast = 0, abQueued = {};
|
||||||
|
const abPush = keepalive => {
|
||||||
|
if (!Object.keys(abQueued).length) return;
|
||||||
|
const body = JSON.stringify(abQueued); abQueued = {};
|
||||||
|
fetch('/api/ab/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
|
||||||
|
.then(r => { if (!r.ok) throw 0; }).catch(() => { abQueued = { ...JSON.parse(body), ...abQueued }; });
|
||||||
|
};
|
||||||
|
const abSave = keepalive => {
|
||||||
|
const id = abIsPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
|
||||||
|
abProg[id] = abQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; abPush(keepalive);
|
||||||
|
};
|
||||||
|
pl.a.addEventListener('timeupdate', () => { if (Date.now() - abLast > 8000) { abLast = Date.now(); abSave(); } });
|
||||||
|
pl.a.addEventListener('pause', () => abSave());
|
||||||
|
pl.a.addEventListener('ended', () => abSave());
|
||||||
|
addEventListener('pagehide', () => abSave(true));
|
||||||
|
|
||||||
|
/** Fetch where you got to in each book (from the audiobook player) and fold it into what this page already knows. */
|
||||||
|
const abLoadProg = async () => { try { for (const [id, p] of Object.entries(await abApi('/progress') || {})) if (!abProg[id] || p.t > abProg[id].t) abProg[id] = p; } catch {} };
|
||||||
|
|
||||||
|
async function viewBooks(args = []) {
|
||||||
|
const v = $('#view');
|
||||||
|
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try {
|
||||||
|
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
||||||
|
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||||
|
} catch (e) {
|
||||||
|
if (!location.hash.startsWith('#/audio')) return;
|
||||||
|
await lvPrime(); if (!location.hash.startsWith('#/audio')) return;
|
||||||
|
v.innerHTML = `<div class="wrap"><h1>Audiobooks</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="abretry">Try again</button><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div>${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}</div>`;
|
||||||
|
$('#abretry').onclick = () => route(); lvBind(v, () => route()); return;
|
||||||
|
}
|
||||||
|
if (!location.hash.startsWith('#/audio')) return;
|
||||||
|
const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; };
|
||||||
|
const card = (b, wide) => `<button class="vcard${wide ? ' wide' : ''}" data-id="${esc(abId(b))}" style="text-align:left;background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;${wide ? 'width:140px;flex:none' : ''}">
|
||||||
|
<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pct(b) > 1 ? `<i class="vprog"><b style="width:${pct(b)}%"></b></i>` : ''}</span>
|
||||||
|
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></button>`;
|
||||||
|
const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10);
|
||||||
|
const contFree = lvShelf.filter(b => lvLeft(b.id)).sort((a, b) => lvProg['lv:' + b.id].t - lvProg['lv:' + a.id].t).slice(0, 10);
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
|
||||||
|
${cont.length || contFree.length ? `<h2>Continue listening</h2><div class="vrow">${[...cont.map(b => ({ t: abProg[abId(b)].t, h: card(b, true) })), ...contFree.map(b => ({ t: lvProg['lv:' + b.id].t, h: lvCard(b, true) }))].sort((a, b) => b.t - a.t).map(x => x.h).join('')}</div>` : ''}
|
||||||
|
${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}
|
||||||
|
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
|
||||||
|
<h2>Library</h2><div class="vgrid" id="abgrid"></div></div>`;
|
||||||
|
const draw = () => {
|
||||||
|
const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q))
|
||||||
|
.sort((a, b) => abTitle(a).localeCompare(abTitle(b)));
|
||||||
|
$('#abgrid').innerHTML = list.map(b => card(b)).join('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
|
||||||
|
};
|
||||||
|
draw(); $('#abq').oninput = draw;
|
||||||
|
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
||||||
|
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
||||||
|
lvBind(v, () => route());
|
||||||
|
}
|
||||||
|
|
||||||
|
function abSheet(b) {
|
||||||
|
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
||||||
|
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
||||||
|
<div style="min-width:0"><p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
||||||
|
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button></div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
||||||
|
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
||||||
|
el.querySelector('#abp').onclick = () => go();
|
||||||
|
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
||||||
|
el.querySelector('#abc').onclick = async () => {
|
||||||
|
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||||
|
const ch = await abChapters(id);
|
||||||
|
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
|
||||||
|
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,18 +19,6 @@ function showFrame(key, url, title, allow) {
|
|||||||
wrap.className = 'on'; document.body.classList.add('onaudio');
|
wrap.className = 'on'; document.body.classList.add('onaudio');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Podcast: opens in a frame when it has a secure (https) address; a plain-http address cannot be framed by a secure page.
|
|
||||||
// Podcast: a plain-http address on the home network. A secure page cannot frame http content, and the address is
|
|
||||||
// private to the LAN, so this opens it directly in the browser (which works on the home network or over a VPN).
|
|
||||||
function viewPodcast() {
|
|
||||||
const url = cfg.podcast, u = new URL(url), canFrame = u.protocol === 'https:';
|
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Podcast</h1><p class="sub">${esc(u.host)}</p>
|
|
||||||
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a></div>
|
|
||||||
${canFrame ? '<p class="sub" style="margin:8px 0 0">If the page below stays blank, the app does not allow being shown inside another app. Use Open in new tab.</p>'
|
|
||||||
: '<div class="card" style="max-width:none;margin-top:12px"><p style="margin:0">This is a plain <b>http</b> address, which a secure page cannot show inside itself. It opens in its own tab, and works at home or on your VPN.</p></div>'}</div>`;
|
|
||||||
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The frame is created once and kept alive, so playback carries on while you use other tabs.
|
// 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).
|
// 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';
|
const ENTER = '/api/audiobooks/enter';
|
||||||
|
|||||||
+7
-2
@@ -22,14 +22,19 @@
|
|||||||
addEventListener('hashchange', route);
|
addEventListener('hashchange', route);
|
||||||
restorePlayer();
|
restorePlayer();
|
||||||
route();
|
route();
|
||||||
mailPoll(); setInterval(mailPoll, 2 * 60e3);
|
shareCheck();
|
||||||
|
// Unread mail and tasks due (the badge): checked on a timer only while the app is on screen, and again the moment you come back to it.
|
||||||
|
const badgePoll = () => { mailPoll(); taskPoll(); };
|
||||||
|
let lastBadge = Date.now(); badgePoll();
|
||||||
|
setInterval(() => { if (document.visibilityState === 'visible') { lastBadge = Date.now(); badgePoll(); } }, 2 * 60e3);
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && Date.now() - lastBadge > 30e3) { lastBadge = Date.now(); badgePoll(); } });
|
||||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
|
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
|
||||||
addEventListener('keydown', e => {
|
addEventListener('keydown', e => {
|
||||||
const tag = e.target.tagName || '', k = e.key;
|
const tag = e.target.tagName || '', k = e.key;
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
|
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(); }
|
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 === '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();
|
else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
|
||||||
|
|||||||
+67
-9
@@ -128,7 +128,7 @@ function openEvent(e) {
|
|||||||
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
|
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>
|
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>
|
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
|
||||||
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
<p class="sub">${e.shared ? esc(e.owner) + ' shared this calendar with you to view only.' : 'This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.'}</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,11 +142,12 @@ async function editEvent(e) {
|
|||||||
|
|
||||||
async function newEvent(day) {
|
async function newEvent(day) {
|
||||||
try {
|
try {
|
||||||
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
|
const cals = (await jfetch('/api/cal/list')).filter(c => (c.local && c.mode !== 'view') || c.icloud);
|
||||||
|
if (!cals.length) return toast('No calendar here can take new events');
|
||||||
const d = new Date(day + 'T00:00:00'), now = new Date();
|
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);
|
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);
|
const end = new Date(d.getTime() + 36e5);
|
||||||
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
|
eventForm({ mode: 'new', cals, calId: (cals.find(c => c.id === store.get('cal_default', 'local')) || cals[0]).id, fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
|
||||||
} catch (err) { toast(err.message); }
|
} catch (err) { toast(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +156,7 @@ function eventForm(o) {
|
|||||||
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
|
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)']);
|
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>`
|
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
|
||||||
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
|
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : c.shared ? ' (' + esc(c.owner) + ')' : ''}</option>`).join('')}</select>`;
|
||||||
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
|
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>
|
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
|
||||||
<div id="fdt"></div>
|
<div id="fdt"></div>
|
||||||
@@ -219,16 +220,70 @@ function addCalendar() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280'];
|
||||||
|
const swatches = cur => `<div class="swatches">${CAL_COLORS.map(c => `<button type="button" class="sw ${c.toLowerCase() === String(cur).toLowerCase() ? 'on' : ''}" data-c="${c}" aria-label="${c}" style="background:${c}"></button>`).join('')}<label class="sw custom" title="Any color"><input type="color" value="${/^#[0-9a-f]{6}$/i.test(cur) ? cur : '#4f7cff'}"></label></div>`;
|
||||||
|
// Wires swatches() inside root; calls onPick(hex) whenever the color changes.
|
||||||
|
function wireSwatches(root, onPick) {
|
||||||
|
root.querySelectorAll('.sw[data-c]').forEach(b => b.onclick = () => { root.querySelectorAll('.sw').forEach(x => x.classList.toggle('on', x === b)); onPick(b.dataset.c); });
|
||||||
|
const cu = root.querySelector('.sw.custom input'); if (cu) cu.oninput = () => { root.querySelectorAll('.sw').forEach(x => x.classList.remove('on')); onPick(cu.value); };
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorSheet(c) {
|
||||||
|
const el = openSheet(c.name, 'Calendar color', `${swatches(c.color)}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
|
||||||
|
el.querySelector('#sx').onclick = () => { closeSheet(); viewCalendar(); };
|
||||||
|
wireSwatches(el, async hex => { try { await jfetch('/api/cal/color', { method: 'POST', body: JSON.stringify({ id: c.id, color: hex }) }); } catch (e) { toast(e.message); } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create (c = null) or edit one of your NE Hub calendars: name, color, who it is shared with (view or edit), subscribe link, delete.
|
||||||
|
async function calendarSheet(c) {
|
||||||
|
const people = c ? await jfetch('/api/cal/people').catch(() => []) : [];
|
||||||
|
const st = { name: c ? c.name : '', color: c ? c.color : CAL_COLORS[Math.floor(Math.random() * CAL_COLORS.length)], shares: Object.fromEntries(((c && c.shares) || []).map(x => [x.user, x.mode])) };
|
||||||
|
const el = openSheet(c ? 'Edit calendar' : 'New calendar', '', `<form id="cf"><label for="cn">Name</label><input type="text" id="cn" value="${esc(st.name)}" placeholder="Family" autocomplete="off" required>
|
||||||
|
<label>Color</label>${swatches(st.color)}
|
||||||
|
${c ? `<h2 style="margin-top:14px">Sharing</h2><p class="sub" style="margin-top:0">Private unless you share it. For each person you choose whether they can only look, or add and change events too.</p>
|
||||||
|
${people.length ? `<div class="list" style="background:var(--panel2);border:0">${people.map(p => `<div class="row"><span class="grow"><b>${esc(p.name)}</b></span><select data-sh="${esc(p.id)}" style="width:auto;min-height:36px">${[['', 'Not shared'], ['view', 'Can view'], ['edit', 'Can edit']].map(([v, l]) => `<option value="${v}" ${(st.shares[p.id] || '') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></div>`).join('')}</div>` : '<p class="sub">Nobody else has an account on this hub yet.</p>'}
|
||||||
|
<h2 style="margin-top:14px">Subscribe link</h2><p class="sub" style="margin-top:0">Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.</p>
|
||||||
|
<div class="bar"><button class="btn ghost" type="button" id="cl" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" type="button" id="cr" style="flex:1;justify-content:center">Make a new link</button></div>` : ''}
|
||||||
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${c ? 'Save' : 'Create'}</button></div>
|
||||||
|
${c ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="cd" style="flex:1;justify-content:center">Delete calendar</button></div>' : ''}
|
||||||
|
<div class="err" id="ce"></div></form>`);
|
||||||
|
el.querySelector('.sheet').classList.add('wide');
|
||||||
|
wireSwatches(el, hex => { st.color = hex; });
|
||||||
|
el.querySelectorAll('[data-sh]').forEach(sel => sel.onchange = () => { if (sel.value) st.shares[sel.dataset.sh] = sel.value; else delete st.shares[sel.dataset.sh]; });
|
||||||
|
el.querySelector('#sx').onclick = () => { closeSheet(); manageCalendars(); };
|
||||||
|
el.querySelector('#cf').onsubmit = async ev => {
|
||||||
|
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
|
||||||
|
const name = el.querySelector('#cn').value.trim();
|
||||||
|
try {
|
||||||
|
if (c) await jfetch('/api/cal/ncal/update', { method: 'POST', body: JSON.stringify({ id: c.id, name, color: st.color, shares: Object.entries(st.shares).map(([user, mode]) => ({ user, mode })) }) });
|
||||||
|
else await jfetch('/api/cal/ncal', { method: 'POST', body: JSON.stringify({ name, color: st.color }) });
|
||||||
|
closeSheet(); toast(c ? 'Calendar saved' : 'Calendar created'); viewCalendar();
|
||||||
|
} catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
||||||
|
};
|
||||||
|
if (!c) return;
|
||||||
|
const link = async reset => (await jfetch('/api/cal/ncal/feed', { method: 'POST', body: JSON.stringify({ id: c.id, reset }) })).url;
|
||||||
|
el.querySelector('#cl').onclick = async () => { try { const u = await link(false); await navigator.clipboard.writeText(u); toast('Link copied'); } catch (e) { toast(e.message || 'Could not copy'); } };
|
||||||
|
el.querySelector('#cr').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { try { await navigator.clipboard.writeText(await link(true)); toast('New link copied'); } catch (e) { toast(e.message); } });
|
||||||
|
el.querySelector('#cd').onclick = () => confirmSheet('Delete ' + c.name + '?', 'Every event in it is deleted too' + (Object.keys(st.shares).length ? ', and the people it is shared with lose it' : '') + '. This cannot be undone.', 'Delete', async () => {
|
||||||
|
try { await jfetch('/api/cal/ncal/delete', { method: 'POST', body: JSON.stringify({ id: c.id }) }); toast('Calendar deleted'); viewCalendar(); } catch (e) { toast(e.message); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function manageCalendars() {
|
async function manageCalendars() {
|
||||||
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
|
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
|
||||||
const links = cals.filter(c => !c.local && !c.icloud);
|
const 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 el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
|
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>`;
|
||||||
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
|
const el = openSheet('Calendars', '', `<h2>NE Hub calendars</h2><div class="list" style="background:var(--panel2);border:0">
|
||||||
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
|
${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>
|
||||||
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></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>
|
<h2 style="margin-top:14px">iCloud calendars</h2>
|
||||||
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
|
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<div class="row">${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}<span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></div>`).join('')}</div>
|
||||||
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
|
<p class="sub">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>
|
: `<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>
|
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
|
||||||
@@ -240,6 +295,9 @@ async function manageCalendars() {
|
|||||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</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('.sheet').classList.add('wide');
|
||||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
|
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-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); } });
|
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 icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
|
||||||
|
|||||||
@@ -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')); }
|
||||||
|
}
|
||||||
@@ -62,11 +62,21 @@
|
|||||||
<script src="/calendar-views.js"></script>
|
<script src="/calendar-views.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/search-view.js"></script>
|
<script src="/search-view.js"></script>
|
||||||
|
<script src="/today.js"></script>
|
||||||
<script src="/calendar.js"></script>
|
<script src="/calendar.js"></script>
|
||||||
<script src="/tasks.js"></script>
|
<script src="/tasks.js"></script>
|
||||||
<script src="/audiobooks.js"></script>
|
<script src="/audiobooks.js"></script>
|
||||||
<script src="/music.js"></script>
|
<script src="/music.js"></script>
|
||||||
|
<script src="/video.js"></script>
|
||||||
|
<script src="/office.js"></script>
|
||||||
<script src="/player.js"></script>
|
<script src="/player.js"></script>
|
||||||
|
<script src="/podcast-view.js"></script>
|
||||||
|
<script src="/reader.js"></script>
|
||||||
|
<script src="/audiobooks-view.js"></script>
|
||||||
|
<script src="/librivox-view.js"></script>
|
||||||
|
<script src="/radio-view.js"></script>
|
||||||
|
<script src="/save-to-files.js"></script>
|
||||||
|
<script src="/vbv-view.js"></script>
|
||||||
<script src="/lyrics-view.js"></script>
|
<script src="/lyrics-view.js"></script>
|
||||||
<script src="/settings.js"></script>
|
<script src="/settings.js"></script>
|
||||||
<script src="/files.js"></script>
|
<script src="/files.js"></script>
|
||||||
@@ -75,6 +85,10 @@
|
|||||||
<script src="/file-actions.js"></script>
|
<script src="/file-actions.js"></script>
|
||||||
<script src="/signal-view.js"></script>
|
<script src="/signal-view.js"></script>
|
||||||
<script src="/mail.js"></script>
|
<script src="/mail.js"></script>
|
||||||
|
<script src="/share.js"></script>
|
||||||
|
<script src="/cast.js"></script>
|
||||||
|
<script src="/rows.js"></script>
|
||||||
|
<script src="/keys.js"></script>
|
||||||
<script src="/boot.js"></script>
|
<script src="/boot.js"></script>
|
||||||
<script src="/shell.js"></script>
|
<script src="/shell.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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,94 @@
|
|||||||
|
'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() {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Free audiobooks</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (!location.hash.startsWith('#/audio/free')) return;
|
||||||
|
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
|
||||||
|
const card = b => lvCard(b);
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/audio">${icon('back')}Audiobooks</a><h1 style="margin:0">Free audiobooks</h1></div>
|
||||||
|
<p class="sub">Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.</p>
|
||||||
|
<input type="search" id="lvq" placeholder="Search by title or author" autocomplete="off" autocapitalize="off" style="width:100%"><div id="lvnote"></div><div id="lvbody"></div><div id="lvmore" class="bar" style="justify-content:center"></div></div>`;
|
||||||
|
const known = new Map(lvShelf.map(b => [b.id, b]));
|
||||||
|
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)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); }
|
||||||
|
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
|
if (my === S.seq) { S.busy = false; draw(); }
|
||||||
|
};
|
||||||
|
let t; $('#lvq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); };
|
||||||
|
$('#lvq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } };
|
||||||
|
draw(); run(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lvSheet(id, after, known = new Map()) {
|
||||||
|
const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' };
|
||||||
|
const el = openSheet(sum.title, sum.author || '', '<p class="empty">Loading…</p>');
|
||||||
|
let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; return; }
|
||||||
|
if (!document.body.contains(el)) return;
|
||||||
|
const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id);
|
||||||
|
el.querySelector('.empty').outerHTML = `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(b.title)} 55% 40%),hsl(${abHue(b.title) + 40} 55% 28%))"><img alt="" referrerpolicy="no-referrer" src="${esc(b.cover || '')}" onerror="this.remove()"></span>
|
||||||
|
<div style="min-width:0"><p class="sub" style="margin:0">${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}</p><p class="sub" style="margin:4px 0 0">${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}</p></div></div>
|
||||||
|
${b.description ? `<p style="margin:10px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:5;-webkit-box-orient:vertical;overflow:hidden">${esc(b.description)}</p>` : ''}
|
||||||
|
<div class="bar"><button class="btn" id="lvp">${icon('play')}${p ? 'Resume' : 'Play'}</button>${p ? '<button class="btn ghost" id="lvs">From the start</button>' : ''}<button class="btn ghost" id="lvc">Chapters</button>${allowed('files') ? '<button class="btn ghost" id="lvf">Save to Files</button>' : ''}<button class="btn ghost" id="lvk">${onShelf() ? 'Remove from shelf' : 'Save to shelf'}</button></div><div class="list" id="lvch" style="max-height:40vh;overflow:auto"></div>`;
|
||||||
|
const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); };
|
||||||
|
el.querySelector('#lvp').onclick = () => go();
|
||||||
|
if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0);
|
||||||
|
if (el.querySelector('#lvf')) el.querySelector('#lvf').onclick = () => { closeSheet(); saveToFiles(b.title, { kind: 'librivox-book', id }); };
|
||||||
|
el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${esc([abFmt(c.secs), c.reader].filter(Boolean).join(' · '))}</small></span></button>`).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); };
|
||||||
|
el.querySelector('#lvk').onclick = async e => { const btn = e.currentTarget; try { if (onShelf()) { await lvApi('/shelf/' + id, lvOpt('DELETE')); lvShelf = lvShelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); } btn.textContent = onShelf() ? 'Remove from shelf' : 'Save to shelf'; if (after) after(); } catch (x) { toast(x.message); } };
|
||||||
|
}
|
||||||
+42
-3
@@ -19,6 +19,8 @@ function lyrParseLrc(text) {
|
|||||||
lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
|
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;
|
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 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 lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
||||||
@@ -38,6 +40,22 @@ async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc
|
|||||||
const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null;
|
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; }
|
} 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 || '' });
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
async function lyrLoad(t, force) {
|
async function lyrLoad(t, force) {
|
||||||
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
|
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
|
||||||
let res = force ? null : await lyrFromPlex(t);
|
let res = force ? null : await lyrFromPlex(t);
|
||||||
@@ -64,7 +82,7 @@ function lyrClose() {
|
|||||||
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||||
function lyrShell() {
|
function lyrShell() {
|
||||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
||||||
lyr.el.className = pref.karaoke ? 'kar' : '';
|
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
|
||||||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
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>
|
<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>
|
||||||
@@ -76,6 +94,7 @@ function lyrShell() {
|
|||||||
<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>
|
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
<button class="ibtn" 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);
|
const $l = q => lyr.el.querySelector(q);
|
||||||
|
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
||||||
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
||||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = 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'); };
|
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
||||||
@@ -88,14 +107,21 @@ function lyrShell() {
|
|||||||
async function lyrReload(force) {
|
async function lyrReload(force) {
|
||||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
|
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
|
||||||
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
|
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
|
||||||
const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return;
|
const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== t.key) return;
|
||||||
lyr.data = res; lyrDraw();
|
lyr.data = res; lyrDraw();
|
||||||
}
|
}
|
||||||
function lyrDraw() {
|
function lyrDraw() {
|
||||||
const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return;
|
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');
|
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';
|
nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s';
|
||||||
if (!d || !d.found) { src.textContent = ''; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return; }
|
if (!d || !d.found) {
|
||||||
|
src.textContent = '';
|
||||||
|
if (lyrSpokenKey(t)) { // no transcript yet: offer to make one
|
||||||
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
||||||
|
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
||||||
|
}
|
||||||
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
||||||
|
}
|
||||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
||||||
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
|
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
|
||||||
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.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('');
|
||||||
@@ -124,3 +150,16 @@ function lyrTick() { // follows the song: which line is current, and how far int
|
|||||||
if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); });
|
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)
|
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 }), r = await jfetch('/api/transcript?' + q);
|
||||||
|
if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' });
|
||||||
|
} catch { lyrAsked.delete(t.key); }
|
||||||
|
});
|
||||||
+278
-36
@@ -4,6 +4,20 @@
|
|||||||
let mailUnread = 0;
|
let mailUnread = 0;
|
||||||
const mail = { st: null, accts: [], acct: '', folders: [], folder: 'inbox', items: [], more: false, next: 0, q: '', msg: null, images: false, loading: false };
|
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 mailFrom = f => (f && (f.name || f.address)) || '';
|
||||||
const mailWhen = iso => {
|
const mailWhen = iso => {
|
||||||
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
|
const d = new Date(iso), now = new Date(); if (isNaN(d)) return '';
|
||||||
@@ -24,7 +38,7 @@ const mailHasRemote = html => /<img[^>]+src=["']?https?:|url\(\s*["']?https?:/i.
|
|||||||
// ---- unread badge and Home card
|
// ---- unread badge and Home card
|
||||||
async function mailPoll() {
|
async function mailPoll() {
|
||||||
if (!(allowed('mail') && cfg.mail)) return;
|
if (!(allowed('mail') && cfg.mail)) return;
|
||||||
try { const s = await jfetch('/api/mail/status'); mail.st = s; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } } catch {}
|
try { const s = await jfetch('/api/mail/status'); mail.st = s; mail.accts = s.accounts || mail.accts; const n = s.connected && s.unread ? s.unread : 0; if (n !== mailUnread) { mailUnread = n; renderNav(); } if (location.hash.startsWith('#/mail') && typeof mailDrawChips === 'function') mailDrawChips(); } catch {}
|
||||||
}
|
}
|
||||||
async function renderMailCard(el) {
|
async function renderMailCard(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -45,7 +59,7 @@ function mailSettings() {
|
|||||||
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>`)
|
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>`;
|
+ `<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);
|
$('#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; mail.items = []; mail.folders = []; mail.acct = ''; await mailPoll(); mailSettings(); } catch (e) { toast(e.message); } }); });
|
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>'; });
|
}).catch(() => { box.innerHTML = '<div class="row"><span class="grow"><small>Could not check</small></span></div>'; });
|
||||||
else box.innerHTML = '';
|
else box.innerHTML = '';
|
||||||
if (cfg.mailAdmin) mailAdminCard();
|
if (cfg.mailAdmin) mailAdminCard();
|
||||||
@@ -125,23 +139,57 @@ async function mailAdminCard() {
|
|||||||
async function viewMail(args = []) {
|
async function viewMail(args = []) {
|
||||||
const v = $('#view');
|
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; }
|
if (!cfg.mail) { v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Mail is not available to you.</p></div>'; return; }
|
||||||
v.innerHTML = '<div class="wrap"><h1>Mail</h1><p class="empty">Loading…</p></div>';
|
// 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,
|
||||||
let st; try { st = await jfetch('/api/mail/status'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Mail</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
// 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;
|
if (!location.hash.startsWith('#/mail')) return;
|
||||||
mail.st = st; mail.accts = st.accounts || [];
|
mail.st = st; mail.accts = st.accounts || [];
|
||||||
if (!st.connected) return mailConnectScreen(st);
|
if (!st.connected) { mail.st = null; mcClear(); return mailConnectScreen(st); }
|
||||||
if (typeof st.unread === 'number' && st.unread !== mailUnread) { mailUnread = st.unread; renderNav(); }
|
|
||||||
const [kind, key] = args;
|
const [kind, key] = args;
|
||||||
const idAcct = x => String(x || '').includes('~') ? String(x).split('~')[0] : '';
|
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 want = idAcct(kind === 'f' ? key : kind === 'm' ? key : '') || (mail.accts.some(a => a.id === mail.acct) ? mail.acct : mail.accts[0].id);
|
||||||
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
|
if (want !== mail.acct) { mail.acct = want; mail.folders = []; mail.items = []; if (!(kind === 'f' && idAcct(key))) mail.folder = want + '~inbox'; }
|
||||||
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
|
if (kind === 'f' && key) mail.folder = idAcct(key) ? key : mail.acct + '~' + key;
|
||||||
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
|
else if (!String(mail.folder).startsWith(mail.acct + '~')) mail.folder = mail.acct + '~inbox';
|
||||||
if (!mail.folders.length) { try { mail.folders = await jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)); } catch {} }
|
if (!mail.folders.length) { const f = mcGet('f:' + mail.acct); if (f) mail.folders = f.folders; }
|
||||||
mailShell(kind === 'm' && key ? 'read' : 'list');
|
mailShell(kind === 'm' && key ? 'read' : 'list');
|
||||||
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true);
|
const acct = mail.acct;
|
||||||
else mailDrawList();
|
if (!mail.items.length || kind === 'f' || kind === undefined) mailLoadList(true); else mailDrawList();
|
||||||
if (kind === 'm' && key) mailOpen(key); else { mail.msg = null; mailDrawReader(); }
|
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.map(a => `<button type="button" role="tab" data-a="${esc(a.id)}" aria-selected="${a.id === mail.acct}" class="${a.id === mail.acct ? 'on' : ''}${a.unread ? ' new' : ''}"><span class="mal">${esc(a.name || a.address)}</span><small>${esc(a.name ? a.address : '')}</small>${a.unread ? `<i class="nbadge" style="position:static">${a.unread > 99 ? '99+' : a.unread}</i>` : ''}</button>`).join('')}</div>`;
|
||||||
|
function mailBindChips() { document.querySelectorAll('.maccts button').forEach(b => b.onclick = () => { if (b.dataset.a === mail.acct) return; mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(b.dataset.a + '~inbox'); }); }
|
||||||
|
function mailDrawChips() { if (mail.accts.length < 2) return; document.querySelectorAll('.maccts').forEach(el => el.outerHTML = mailChips()); mailBindChips(); }
|
||||||
|
/** Redraw the folder pickers (names and unread counts) without touching the list or the open message. */
|
||||||
|
function mailRefreshFolders() {
|
||||||
|
mailDrawChips();
|
||||||
|
const fl = mail.folders; if (!fl.length) return;
|
||||||
|
const f = $('#mfsel'); if (f) f.innerHTML = fl.map(x => `<option value="${esc(x.id)}" ${x.id === mail.folder ? 'selected' : ''}>${esc(x.name)}${x.unread ? ' (' + x.unread + ')' : ''}</option>`).join('');
|
||||||
|
const side = document.querySelector('.mfold'); if (side) { const sel = side.querySelector('select'); side.innerHTML = (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) {
|
function mailConnectScreen(st) {
|
||||||
@@ -158,57 +206,243 @@ function mailConnectScreen(st) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mailShell(mode) {
|
function mailShell(mode) {
|
||||||
|
mail.sel = null;
|
||||||
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
|
const fl = mail.folders.length ? mail.folders : [{ id: 'inbox', name: 'Inbox', unread: mailUnread }];
|
||||||
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
|
$('#view').innerHTML = `<div class="mailapp" data-mode="${mode}">
|
||||||
<aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? `<select id="macct2" aria-label="Account" style="margin-bottom:8px">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}</option>`).join('')}</select>` : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
|
<aside class="mfold" aria-label="Folders">${mail.accts.length > 1 ? mailChips() : ''}${fl.map(f => `<a href="#/mail/f/${encodeURIComponent(f.id)}" class="${f.id === mail.folder ? 'on' : ''}"><span class="grow">${esc(f.name)}</span>${f.unread ? `<i class="nbadge" style="position:static">${f.unread}</i>` : ''}</a>`).join('')}</aside>
|
||||||
<section class="mlist" aria-label="Messages">
|
<section class="mlist" aria-label="Messages">
|
||||||
${mail.accts.length > 1 ? `<div class="mtool" style="margin-bottom:4px"><select id="macct" aria-label="Account" style="max-width:100%;flex:1">${mail.accts.map(a => `<option value="${esc(a.id)}" ${a.id === mail.acct ? 'selected' : ''}>${esc(a.address)}${a.unread ? ' (' + a.unread + ')' : ''}</option>`).join('')}</select></div>` : ''}
|
${mail.accts.length > 1 ? mailChips() : ''}
|
||||||
<div class="mtool"><select id="mfsel" aria-label="Folder">${fl.map(f => `<option value="${esc(f.id)}" ${f.id === mail.folder ? 'selected' : ''}>${esc(f.name)}${f.unread ? ' (' + f.unread + ')' : ''}</option>`).join('')}</select>
|
<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">
|
<input type="search" id="mq" placeholder="Search mail" value="${esc(mail.q)}" autocomplete="off" aria-label="Search mail">
|
||||||
<button class="ibtn" id="mrf" aria-label="Refresh">${icon('refresh')}</button><button class="btn" id="mnew">${icon('edit')}New</button></div>
|
<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="mrows" class="list"></div><div id="mmore"></div><div id="mselbar" class="mselbar" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="mread" id="mread" aria-label="Message"></section></div>`;
|
<section class="mread" id="mread" aria-label="Message"></section></div>`;
|
||||||
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
|
$('#mfsel').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value); };
|
||||||
if ($('#macct2')) $('#macct2').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
|
mailBindChips();
|
||||||
if ($('#macct')) $('#macct').onchange = e => { mail.q = ''; location.hash = '#/mail/f/' + encodeURIComponent(e.target.value + '~inbox'); };
|
|
||||||
$('#mnew').onclick = () => mailCompose();
|
$('#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(() => {}); };
|
$('#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); };
|
let t; $('#mq').oninput = e => { clearTimeout(t); t = setTimeout(() => { mail.q = e.target.value.trim(); mailLoadList(true); }, 400); };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mailLoadList(reset) {
|
async function mailLoadList(reset) {
|
||||||
if (reset) { mail.items = []; mail.next = 0; mail.more = false; }
|
const folder = mail.folder, first = reset && !mail.q;
|
||||||
const rows = $('#mrows'); if (rows && reset) rows.innerHTML = '<p class="empty">Loading…</p>';
|
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 {
|
try {
|
||||||
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(mail.folder)}&skip=${mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
|
const r = await jfetch(`/api/mail/messages?folder=${encodeURIComponent(folder)}&skip=${showing ? 0 : mail.next}${mail.q ? '&q=' + encodeURIComponent(mail.q) : ''}`);
|
||||||
if (!$('#mrows')) return;
|
if (!$('#mrows') || mail.folder !== folder) return;
|
||||||
mail.items = mail.items.concat(r.items); mail.more = r.more; mail.next = r.next;
|
mail.items = showing ? mailApplyMine(r.items) : mail.items.concat(mailApplyMine(r.items)); mail.more = r.more; mail.next = r.next;
|
||||||
mailDrawList();
|
if (first) mcPut('l:' + folder, { items: r.items, more: r.more, next: r.next });
|
||||||
} catch (e) { 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; setTimeout(route, 1200); } }
|
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() {
|
function mailDrawList() {
|
||||||
const rows = $('#mrows'); if (!rows) return;
|
const rows = $('#mrows'); if (!rows) return; mailSwipeInit(rows);
|
||||||
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
const sel = (location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1];
|
||||||
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">
|
rows.innerHTML = mail.items.length ? mail.items.map(m => `<a class="row mrow${m.read ? '' : ' unread'}${sel && !mail.sel && decodeURIComponent(sel) === m.id ? ' sel' : ''}${mail.sel && mail.sel.has(m.id) ? ' picked' : ''}" href="#/mail/m/${encodeURIComponent(m.id)}">${mail.sel ? `<span class="mchk${mail.sel.has(m.id) ? ' on' : ''}"></span>` : ''}
|
||||||
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
<span class="grow"><b>${esc(mailFrom(m.from))}</b><span class="msub">${esc(m.subject)}</span><small>${esc(m.preview)}</small></span>
|
||||||
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? icon('check') : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
|
<span class="mmeta"><small>${esc(mailWhen(m.date))}</small>${m.flagged ? `<span class="mflag" title="Flagged">${icon('bookmark')}</span>` : ''}${m.attach ? icon('clip') : ''}</span></a>`).join('') : `<p class="empty" style="padding:12px">${mail.q ? 'Nothing matches.' : 'No messages here.'}</p>`;
|
||||||
const more = $('#mmore'); more.innerHTML = mail.more ? '<div class="bar" style="justify-content:center"><button class="btn ghost" id="mmb">Load more</button></div>' : '';
|
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);
|
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$/.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) {
|
async function mailOpen(id) {
|
||||||
const rd = $('#mread'); if (rd) rd.innerHTML = '<p class="empty" style="padding:16px">Opening…</p>';
|
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;
|
mail.images = false;
|
||||||
try {
|
try {
|
||||||
const m = await jfetch('/api/mail/message?id=' + encodeURIComponent(id));
|
const m = await mailFetch(id);
|
||||||
if (!(location.hash.startsWith('#/mail/m/'))) return;
|
if (decodeURIComponent((location.hash.match(/^#\/mail\/m\/([^/]+)/) || [])[1] || '') !== id) return; // you moved on to another message while this one loaded
|
||||||
mail.msg = m; mailDrawReader(); mailDrawList();
|
mail.msg = m; mailDrawReader(); mailDrawList();
|
||||||
if (!m.read) { // opening a message marks it read
|
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(() => {
|
jfetch('/api/mail/update', { method: 'POST', body: JSON.stringify({ id, read: true }) }).then(() => {
|
||||||
const it = mail.items.find(x => x.id === id); if (it) it.read = true; m.read = true; mailDrawList();
|
m.read = true; mailSetRead(id, true); mailDrawList();
|
||||||
mailUnread = Math.max(0, mailUnread - 1); renderNav(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {});
|
if (inbox) { mailUnread = Math.max(0, mailUnread - 1); renderNav(); }
|
||||||
}).catch(() => {});
|
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>`; }
|
} catch (e) { if ($('#mread')) $('#mread').innerHTML = `<p class="err" style="padding:16px">${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
@@ -236,15 +470,23 @@ function mailDrawReader() {
|
|||||||
async function mailAction(a) {
|
async function mailAction(a) {
|
||||||
const m = mail.msg; if (!m) return;
|
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 post = (p, body) => jfetch('/api/mail/' + p, { method: 'POST', body: JSON.stringify({ id: m.id, ...body }) });
|
||||||
const back = () => { mail.items = mail.items.filter(x => x.id !== m.id); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; }).catch(() => {}); };
|
const refresh = () => { mailPoll(); jfetch('/api/mail/folders?acct=' + encodeURIComponent(mail.acct)).then(f => { mail.folders = f; mailRefreshFolders(); }).catch(() => {}); };
|
||||||
|
const gone = () => { mail.items = mail.items.filter(x => x.id !== m.id); const sv = mcGet('l:' + mail.folder); if (sv && sv.items) mcPut('l:' + mail.folder, { items: sv.items.filter(x => x.id !== m.id), more: sv.more, next: sv.next }); };
|
||||||
|
const back = keep => { if (!keep) gone(); mail.msg = null; location.hash = '#/mail/f/' + encodeURIComponent(mail.folder); refresh(); };
|
||||||
|
// Archive and delete leave at once and finish in the background; if the server refuses, the message comes back
|
||||||
|
const away = (p, body, said) => {
|
||||||
|
const had = mail.items.find(x => x.id === m.id), at = mail.items.findIndex(x => x.id === m.id);
|
||||||
|
toast(said); back();
|
||||||
|
post(p, body).catch(e => { toast(e.message); if (had) { mail.items.splice(Math.min(at < 0 ? 0 : at, mail.items.length), 0, had); mailDrawList(); } refresh(); });
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
|
if (a === 'reply' || a === 'replyAll' || a === 'forward') return mailCompose({ mode: a, msg: m });
|
||||||
if (a === 'archive') { await post('move', { to: 'archive' }); toast('Archived'); return back(); }
|
if (a === 'archive') return away('move', { to: 'archive' }, 'Archived');
|
||||||
if (a === 'unread') { await post('update', { read: false }); const it = mail.items.find(x => x.id === m.id); if (it) it.read = false; toast('Marked unread'); mailUnread++; renderNav(); return back(); }
|
if (a === '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 === '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 (a === 'delete') {
|
||||||
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', async () => { try { await post('delete', { permanent: true }); toast('Deleted'); back(); } catch (e) { toast(e.message); } });
|
if (/(^|~)deleteditems$/.test(mail.folder)) return confirmSheet('Delete for good?', m.subject, 'Delete', () => away('delete', { permanent: true }, 'Deleted'));
|
||||||
await post('delete', {}); toast('Moved to Deleted'); return back();
|
return away('delete', {}, 'Moved to Deleted');
|
||||||
}
|
}
|
||||||
} catch (e) { toast(e.message); }
|
} catch (e) { toast(e.message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
"*/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -82,10 +82,10 @@ async function plexHomeBadge(st) {
|
|||||||
function plexSwitchSheet(users) {
|
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.',
|
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
||||||
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
||||||
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected && !u.admin ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
<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>`);
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected && !u.admin) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
||||||
}
|
}
|
||||||
function plexPinSheet(u) {
|
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>
|
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
|
||||||
@@ -97,7 +97,7 @@ function plexPinSheet(u) {
|
|||||||
async function plexSwitchTo(u, pin, onError) {
|
async function plexSwitchTo(u, pin, onError) {
|
||||||
try {
|
try {
|
||||||
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
|
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
|
||||||
closeSheet(); plexHome.data = null; px.secs = null;
|
closeSheet(); plexHome.data = null; px.secs = null; wl.id = ''; try { for (const k of Object.keys(localStorage)) if (k.startsWith('vc:')) localStorage.removeItem(k); } catch {} // (what the last person had on their home screen)
|
||||||
toast('Now using Plex as ' + u.title); route();
|
toast('Now using Plex as ' + u.title); route();
|
||||||
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
|
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
|
||||||
}
|
}
|
||||||
+18
-8
@@ -13,7 +13,7 @@ const px = {
|
|||||||
if (!r.ok) throw new Error('Plex error ' + r.status);
|
if (!r.ok) throw new Error('Plex error ' + r.status);
|
||||||
return (await r.json()).MediaContainer || {};
|
return (await r.json()).MediaContainer || {};
|
||||||
},
|
},
|
||||||
art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab|radio|vbv)\//.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.
|
// All music libraries on the server; the chosen one is remembered per server.
|
||||||
async sections() {
|
async sections() {
|
||||||
if (px.secs) return px.secs;
|
if (px.secs) return px.secs;
|
||||||
@@ -52,22 +52,32 @@ async function viewMusic(args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 = '') {
|
function plexSignIn(msg = '') {
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
|
$('#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="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>`;
|
<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 () => {
|
$('#pl').onclick = async () => {
|
||||||
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
||||||
try {
|
try {
|
||||||
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
|
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>`;
|
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>`;
|
||||||
for (let i = 0; i < 150; i++) {
|
await plexWaitPin(pin, w);
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
|
||||||
if (!location.hash.startsWith('#/music')) return;
|
|
||||||
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
|
||||||
if (r.ok) { try { w?.close(); } catch {} return route(); }
|
|
||||||
}
|
|
||||||
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
|
|
||||||
} catch (e) { $('#pe').textContent = e.message; }
|
} catch (e) { $('#pe').textContent = e.message; }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(); };
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ function openPalette() {
|
|||||||
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
|
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('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('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' });
|
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: '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' });
|
out.push({ label: 'Refresh the app', hint: 'App', ic: 'refresh', run: () => location.reload(), kw: 'reload update' });
|
||||||
|
|||||||
+8
-6
@@ -22,7 +22,7 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
|
|||||||
// so changing songs is instant and a slow or lost connection does not stop the music.
|
// 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 sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||||
const tc = { m: new Map(), ctl: null, busy: false };
|
const tc = { m: new Map(), ctl: null, busy: false };
|
||||||
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
|
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
|
||||||
function tcTrim() {
|
function tcTrim() {
|
||||||
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
|
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), 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); }
|
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
|
||||||
@@ -143,7 +143,7 @@ const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cu
|
|||||||
pl.a.preload = 'auto';
|
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).
|
// 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;
|
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
|
||||||
const tlSend = (state, key, pos, dur) => fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
|
const tlSend = (state, key, pos, dur) => /^(pod|radio|lv|vbv):/.test(String(key)) ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
|
||||||
function tlReport(state) {
|
function tlReport(state) {
|
||||||
const t = cur(); if (!t) return;
|
const t = cur(); if (!t) return;
|
||||||
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
|
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
|
||||||
@@ -157,7 +157,8 @@ pl.a.addEventListener('timeupdate', () => {
|
|||||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
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'); }
|
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
|
||||||
const t = cur(), d = pl.a.duration;
|
const t = cur(), d = pl.a.duration;
|
||||||
if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
|
if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end
|
||||||
|
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // 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 {} }
|
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);
|
addEventListener('pagehide', saveState);
|
||||||
@@ -178,6 +179,7 @@ function markPlaying() {
|
|||||||
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
|
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() {
|
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);
|
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
|
||||||
el.hidden = false;
|
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)}">
|
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)}">
|
||||||
@@ -245,7 +247,7 @@ function renderNpCard() {
|
|||||||
<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-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||||
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
|
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="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="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
|
||||||
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
|
<button class="btn ghost npc-l" id="cnl">${lyrLabel()}</button><button class="btn ghost npc-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');
|
const sk = el.querySelector('#cnk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
|
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; };
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||||
@@ -269,11 +271,11 @@ function openNow() {
|
|||||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
<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>
|
<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>
|
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">${lyrLabel()}</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');
|
const sk = el.querySelector('#npk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
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; };
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); };
|
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
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;
|
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
'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" 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>`;
|
||||||
|
$('#pdadd').onclick = () => {
|
||||||
|
const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', `<input type="search" id="pdq" placeholder="Search podcasts, or paste https://…/feed.xml" autocomplete="off" autocapitalize="off">
|
||||||
|
<div class="err" id="pde"></div><div id="pdres" class="list" style="margin-top:8px;max-height:50vh;overflow:auto"></div>
|
||||||
|
<p class="sub" style="margin:8px 0 0">Searching uses Apple's public podcast directory, so the words you type are sent to Apple.</p>`);
|
||||||
|
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 run = async () => {
|
||||||
|
const term = q.value.trim(), my = ++seq; err.textContent = '';
|
||||||
|
if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = `<button class="row" id="pdfeed"><span class="grow"><b>Add this feed</b><small style="white-space:normal">${esc(term)}</small></span></button>`; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; }
|
||||||
|
if (term.length < 2) { res.innerHTML = ''; return; }
|
||||||
|
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||||
|
try {
|
||||||
|
found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return;
|
||||||
|
res.innerHTML = found.length ? found.map((f, i) => `<div class="row"><img class="art" alt="" style="width:48px;height:48px;border-radius:8px;flex:none" src="${esc(f.image || '')}" referrerpolicy="no-referrer" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(f.title)}</b><small>${esc(f.author || '')}</small></span><button class="btn" data-i="${i}" style="min-height:36px">Add</button></div>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||||
|
res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b));
|
||||||
|
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
|
};
|
||||||
|
q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pdShow(id) {
|
||||||
|
const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]);
|
||||||
|
if (!location.hash.startsWith('#/podcast')) return;
|
||||||
|
const items = (Array.isArray(items0) ? items0 : []).sort((a, b) => pdTime(pdF(b, 'pubDate')) - pdTime(pdF(a, 'pubDate')));
|
||||||
|
const paused = !!pdF(show, 'isPaused'), ep = e => `<div class="row pdep" data-id="${esc(pdId(e))}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play">${icon('play')}</button>
|
||||||
|
<span class="grow"><b${pdF(e, 'isPlayed') ? ' style="color:var(--muted)"' : ''}>${esc(pdF(e, 'title'))}</b><small>${esc([pdDate(pdF(e, 'pubDate')), pdDur(pdF(e, 'duration')), pdF(e, 'isPlayed') ? 'played' : pdLeft(e) ? pdDur(pdLeft(e).dur - pdLeft(e).pos) + ' left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||||
|
<button class="ibtn" data-seen aria-label="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" title="${pdF(e, 'isPlayed') ? 'Mark unplayed' : 'Mark played'}" style="${pdF(e, 'isPlayed') ? 'color:var(--accent)' : ''}">${icon('check')}</button></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);
|
||||||
|
r.querySelector('[data-seen]').onclick = async () => {
|
||||||
|
try { await pdApi('/podcastitems/' + encodeURIComponent(pdId(e)) + (pdF(e, 'isPlayed') ? '/markUnplayed' : '/markPlayed')); route(); } catch (err) { toast(err.message); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if ($('#pdlatest')) $('#pdlatest').onclick = () => playFrom(items[0]);
|
||||||
|
$('#pdmore').onclick = () => {
|
||||||
|
const el = openSheet(pdF(show, 'title'), '', `<div class="list"><button class="row" id="pda"><span class="grow"><b>Download all episodes</b><small>Podgrab keeps a copy of each one</small></span></button>
|
||||||
|
<button class="row" id="pdp"><span class="grow"><b>${paused ? 'Resume checking for new episodes' : 'Pause checking for new episodes'}</b></span></button>
|
||||||
|
<button class="row" id="pdd"><span class="grow"><b style="color:var(--danger)">Remove from my podcasts</b><small>Only from your list; it is deleted from Podgrab when nobody else follows it</small></span></button></div>`);
|
||||||
|
const act = (sel, fn, msg) => el.querySelector(sel).onclick = async () => { try { await fn(); closeSheet(); toast(msg); route(); } catch (e) { toast(e.message); } };
|
||||||
|
act('#pda', () => pdApi('/podcasts/' + encodeURIComponent(id) + '/download'), 'Downloading in the background');
|
||||||
|
act('#pdp', () => pdApi('/podcasts/' + encodeURIComponent(id) + (paused ? '/unpause' : '/pause')), paused ? 'Resumed' : 'Paused');
|
||||||
|
el.querySelector('#pdd').onclick = () => { closeSheet(); confirmSheet('Remove from your podcasts?', 'It leaves your list. Its downloaded episodes are only deleted when nobody else follows it.', 'Remove', async () => {
|
||||||
|
try { await pdApi('/podcasts/' + encodeURIComponent(id), { method: 'DELETE' }); toast('Removed'); location.hash = '#/podcast'; } catch (e) { toast(e.message); } }); };
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'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 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(', '), thumb: rdoIcon(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; t.title = n.title || t.station; t.artist = n.title ? (n.artist || t.station) : 'Live radio'; t.album = n.title ? t.station : t.album;
|
||||||
|
if (typeof updateNow === 'function') updateNow(); const el = document.getElementById('rdonow'); if (el && rdoPlaying()) el.dataset.id === id && rdoDrawNow(el, n);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
setInterval(rdoPoll, 20000); pl.a.addEventListener('playing', () => { if (rdoPlaying()) setTimeout(rdoPoll, 1500); });
|
||||||
|
function rdoDrawNow(el, n) { el.innerHTML = n && n.title ? `<small>Now playing</small><b>${esc(n.title)}</b>${n.artist ? '<span> · ' + esc(n.artist) + '</span>' : ''}` : '<small>Live</small>'; }
|
||||||
|
|
||||||
|
async function viewRadio() {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Radio</h1><p class="empty">Loading…</p></div>';
|
||||||
|
let feat, mine;
|
||||||
|
try { ({ featured: feat, mine } = await rdoApi('/stations')); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Radio</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (!location.hash.startsWith('#/radio')) return;
|
||||||
|
rdoSaved = mine; const S = { q: '', tag: 'christian', items: [], more: false, busy: false, seq: 0, off: 0 };
|
||||||
|
const saved = id => rdoSaved.some(x => x.id === id);
|
||||||
|
const row = (s, fav) => `<div class="row rdorow${rdoPlaying() && rdoPlaying().key === 'radio:' + s.id ? ' playing' : ''}" data-id="${esc(s.id)}"><button class="rdoplay" data-play aria-label="Play ${esc(s.name)}"><span class="art">${s.favicon ? `<img alt="" loading="lazy" src="${esc(rdoIcon(s))}" onerror="this.remove()">` : ''}</span>
|
||||||
|
<span class="grow"><b>${esc(s.name)}</b><small>${esc(rdoMeta(s))}</small></span>${icon('play')}</button>${s.featured ? '' : `<button class="ibtn" data-fav aria-label="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" title="${saved(s.id) ? 'Remove from my stations' : 'Save to my stations'}" style="${saved(s.id) ? 'color:var(--danger)' : ''}">${icon('heart')}</button>`}</div>`;
|
||||||
|
const j = feat[0];
|
||||||
|
v.innerHTML = `<div class="wrap"><h1>Radio</h1>
|
||||||
|
<div class="card rdofeat" style="max-width:none"><div class="sub" style="margin:0">Featured</div><h2 style="margin:2px 0 0;font-size:22px;text-transform:none;letter-spacing:0;color:var(--text)">${esc(j.name)}</h2>
|
||||||
|
<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><a class="btn ghost" href="${esc(j.homepage)}" target="_blank" rel="noopener">${icon('ext')}Website</a><button class="btn ghost" id="rdojr">Recently played</button></div></div>
|
||||||
|
<div id="rdomine"></div><h2>Find stations</h2><input type="search" id="rdoq" placeholder="Search stations by name" autocomplete="off" autocapitalize="off" style="width:100%">
|
||||||
|
<div class="seg" id="rdochips">${rdoChips.map(([l, t]) => `<a href="#" data-t="${t}" class="${t === S.tag ? 'on' : ''}">${l}</a>`).join('')}</div><div id="rdores" class="list"></div><div id="rdomore" class="bar" style="justify-content:center"></div></div>`;
|
||||||
|
const playStation = s => { playQueue([rdoTrack(s)], 0); toast('Tuning in to ' + s.name); drawAll(); setTimeout(rdoPoll, 2500); };
|
||||||
|
const bindRows = (root, byId) => { root.querySelectorAll('.rdorow').forEach(r => { const s = byId(r.dataset.id); r.querySelector('[data-play]').onclick = () => playStation(s);
|
||||||
|
const f = r.querySelector('[data-fav]'); if (f) f.onclick = async () => { try { if (saved(s.id)) { await rdoApi('/stations/' + encodeURIComponent(s.id), rdoOpt('DELETE')); rdoSaved = rdoSaved.filter(x => x.id !== s.id); } else { await rdoApi('/stations', rdoOpt('POST', { id: s.id })); rdoSaved.push(s); } drawAll(); } catch (e) { toast(e.message); } }; }); };
|
||||||
|
const drawMine = () => { $('#rdomine').innerHTML = rdoSaved.length ? `<h2>My stations</h2><div class="list">${rdoSaved.map(s => row(s)).join('')}</div>` : ''; bindRows($('#rdomine'), id => rdoSaved.find(x => x.id === id)); };
|
||||||
|
const drawRes = () => { $('#rdores').innerHTML = S.items.length ? S.items.map(s => row(s)).join('') : S.busy ? '<p class="empty">Searching…</p>' : '<p class="empty">No stations found.</p>'; bindRows($('#rdores'), id => S.items.find(x => x.id === id));
|
||||||
|
$('#rdomore').innerHTML = S.more && !S.busy ? '<button class="btn ghost" id="rdomb">Show more</button>' : ''; if ($('#rdomb')) $('#rdomb').onclick = () => run(false); };
|
||||||
|
const drawAll = () => { drawMine(); drawRes(); };
|
||||||
|
const run = async reset => {
|
||||||
|
const my = ++S.seq; if (reset) { S.off = 0; S.items = []; } S.busy = true; if (reset) drawRes();
|
||||||
|
try { const r = await rdoApi(`/search?q=${encodeURIComponent(S.q)}&tag=${encodeURIComponent(S.q ? '' : S.tag)}&offset=${S.off}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; S.off += 30; }
|
||||||
|
catch (e) { if (my === S.seq) { $('#rdores').innerHTML = `<p class="err" style="padding:12px">${esc(e.message)}</p>`; S.busy = false; return; } }
|
||||||
|
if (my === S.seq) { S.busy = false; drawRes(); }
|
||||||
|
};
|
||||||
|
$('#rdojp').onclick = () => playStation(j);
|
||||||
|
$('#rdojr').onclick = async () => { const el = openSheet('Recently played', j.name, '<p class="empty">Loading…</p>'); try { const n = await rdoApi('/now/journeyfm'); el.querySelector('.empty').outerHTML = n.recent.length ? `<div class="list">${n.recent.map(x => `<div class="row"><span class="grow"><b>${esc(x.title)}</b><small>${esc(x.artist)}</small></span></div>`).join('')}</div>` : '<p class="empty">Nothing to show right now.</p>'; } catch (e) { el.querySelector('.empty').outerHTML = `<p class="err">${esc(e.message)}</p>`; } };
|
||||||
|
$('#rdochips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.tag = a.dataset.t; S.q = ''; $('#rdoq').value = ''; $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); run(true); });
|
||||||
|
let t; $('#rdoq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); $('#rdochips').querySelectorAll('a').forEach(x => x.classList.toggle('on', !S.q && x.dataset.t === S.tag)); run(true); }, 450); };
|
||||||
|
drawMine(); run(true);
|
||||||
|
rdoApi('/now/journeyfm').then(n => { const el = $('#rdonow'); if (el) { rdoNow.journeyfm = n; rdoDrawNow(el, n); } }).catch(() => {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
'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 rdStore();
|
||||||
|
const v = $('#view');
|
||||||
|
v.innerHTML = '<div class="wrap"><h1>Books</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try { await rdLibrary(); } catch (e) {
|
||||||
|
if (!location.hash.startsWith('#/read')) return;
|
||||||
|
v.innerHTML = `<div class="wrap"><h1>Books</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rdretry">Try again</button></div></div>`;
|
||||||
|
$('#rdretry').onclick = () => route();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rdLibrary() {
|
||||||
|
const books = await rdApi('/list');
|
||||||
|
if (!location.hash.startsWith('#/read')) return;
|
||||||
|
let q = '', sort = localStorage.getItem('rd-sort') || 'recent';
|
||||||
|
const v = $('#view');
|
||||||
|
const cover = b => `<span class="vimg rdcover" style="aspect-ratio:2/3">${b.cover ? `<img loading="lazy" alt="" src="/api/read/book/${b.id}/cover" onerror="this.remove()">` : ''}<em>${esc(b.title)}</em><span class="rdext">${esc(b.ext)}</span>${b.pct > 0 ? `<u class="rdprog"><s style="width:${Math.round(b.pct * 100)}%"></s></u>` : ''}</span>`;
|
||||||
|
const card = b => `<a class="vcard" href="#/read/open/${b.id}" data-id="${b.id}">${cover(b)}<b>${esc(b.title)}</b><small>${esc(b.author || b.ext.toUpperCase())}${b.pct > 0 ? ' · ' + Math.round(b.pct * 100) + '%' : ''}</small></a>`;
|
||||||
|
v.innerHTML = `<div class="wrap" id="rdlib"><div class="ptop"><h1>Books</h1><span class="pspace grow"></span><a class="btn ghost" href="#/read/store">${icon('search')}Book store</a><button class="btn" id="rdadd">${icon('plus')}Add books</button></div>
|
||||||
|
<input type="file" id="rdfile" accept=".epub,.pdf,.docx,.mobi,.azw,.azw3" multiple hidden>
|
||||||
|
<div id="rdbody"></div><p class="sub" id="rdhint" style="margin-top:16px">EPUB, PDF, DOCX, MOBI and AZW3 (without DRM). Add files with the button or drop them on this page. Your place, bookmarks and highlights follow you to every device.</p></div>`;
|
||||||
|
const draw = () => {
|
||||||
|
const f = q.trim().toLowerCase(), mine = books.filter(b => !f || (b.title + ' ' + b.author).toLowerCase().includes(f));
|
||||||
|
const sorted = [...mine].sort(sort === 'title' ? (a, b) => a.title.localeCompare(b.title) : sort === 'author' ? (a, b) => (a.author || '~').localeCompare(b.author || '~') || a.title.localeCompare(b.title) : sort === 'added' ? (a, b) => b.added - a.added : (a, b) => (b.read || b.added) - (a.read || a.added));
|
||||||
|
const cont = !f ? books.filter(b => b.read && b.pct < 0.98).sort((a, b) => b.read - a.read).slice(0, 8) : [];
|
||||||
|
$('#rdbody').innerHTML = !books.length ? `<p class="empty">No books yet. Add an EPUB, PDF or DOCX file to start reading.</p>` :
|
||||||
|
`<div class="bar" style="align-items:center"><input type="search" id="rdq" placeholder="Search your books" value="${esc(q)}" style="flex:1 1 200px;width:auto" autocomplete="off">
|
||||||
|
<select id="rdsort" aria-label="Sort"><option value="recent">Recently read</option><option value="added">Recently added</option><option value="title">Title</option><option value="author">Author</option></select></div>
|
||||||
|
${cont.length ? `<h2>Continue reading</h2><div class="vgrid">${cont.map(card).join('')}</div>` : ''}
|
||||||
|
<h2>${f ? 'Results' : 'Library'} <small style="text-transform:none;letter-spacing:0">(${sorted.length})</small></h2>
|
||||||
|
${sorted.length ? `<div class="vgrid">${sorted.map(card).join('')}</div>` : '<p class="empty">Nothing matches.</p>'}`;
|
||||||
|
const qi = $('#rdq'); if (qi) { qi.oninput = () => { q = qi.value; const pos = qi.selectionStart; draw(); const n = $('#rdq'); n.focus(); n.setSelectionRange(pos, pos); }; $('#rdsort').value = sort; $('#rdsort').onchange = e => { sort = e.target.value; try { localStorage.setItem('rd-sort', sort); } catch {} draw(); }; }
|
||||||
|
document.querySelectorAll('#rdbody .vcard').forEach(a => {
|
||||||
|
const open = e => { e.preventDefault(); rdBookMenu(books.find(b => b.id === a.dataset.id)); };
|
||||||
|
a.addEventListener('contextmenu', open);
|
||||||
|
let t; a.addEventListener('touchstart', () => { t = setTimeout(() => { t = 0; a.dataset.lp = '1'; open({ preventDefault() {} }); }, 600); }, { passive: true });
|
||||||
|
a.addEventListener('touchend', e => { clearTimeout(t); if (a.dataset.lp) { delete a.dataset.lp; e.preventDefault(); } });
|
||||||
|
a.addEventListener('touchmove', () => clearTimeout(t), { passive: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
const input = $('#rdfile');
|
||||||
|
$('#rdadd').onclick = () => input.click();
|
||||||
|
input.onchange = () => { rdAddAll([...input.files]); input.value = ''; };
|
||||||
|
const wrap = $('#rdlib');
|
||||||
|
wrap.ondragover = e => { if ([...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); wrap.classList.add('drop'); } };
|
||||||
|
wrap.ondragleave = () => wrap.classList.remove('drop');
|
||||||
|
wrap.ondrop = e => { e.preventDefault(); wrap.classList.remove('drop'); rdAddAll([...e.dataTransfer.files]); };
|
||||||
|
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rdBookMenu(b) {
|
||||||
|
if (!b) return;
|
||||||
|
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
|
||||||
|
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
|
||||||
|
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
|
||||||
|
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
|
||||||
|
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
|
||||||
|
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
|
||||||
|
try { await rdApi('/book/' + b.id, { method: 'DELETE' }); toast('Deleted'); route(); } catch (e) { toast(e.message); } }) },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function rdEditBook(b) {
|
||||||
|
const el = openSheet('Edit book', '', `<form><label>Title</label><input id="rdt" value="${esc(b.title)}" maxlength="200"><label>Author</label><input id="rda" value="${esc(b.author || '')}" maxlength="200">
|
||||||
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||||||
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
el.querySelector('form').onsubmit = async e => { e.preventDefault(); try { await rdApi('/book/' + b.id, rdJson('PATCH', { title: el.querySelector('#rdt').value, author: el.querySelector('#rda').value })); closeSheet(); route(); } catch (err) { toast(err.message); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Adding books: the file goes up as it is; the browser reads the title, author and cover out of it afterwards.
|
||||||
|
async function rdAddAll(files) {
|
||||||
|
files = files.filter(f => /\.(epub|pdf|docx|mobi|azw3?)$/i.test(f.name));
|
||||||
|
if (!files.length) return toast('Choose EPUB, PDF, DOCX, MOBI or AZW3 files');
|
||||||
|
let added = 0, failed = 0, why = '';
|
||||||
|
for (const [i, f] of files.entries()) {
|
||||||
|
toast(`Adding ${i + 1} of ${files.length}: ${f.name}`);
|
||||||
|
try { if (!(await rdAddOne(f)).duplicate) added++; } catch (e) { failed++; why = f.name + ': ' + e.message; toast(why); await new Promise(r => setTimeout(r, 1800)); }
|
||||||
|
}
|
||||||
|
toast(added ? `Added ${added} book${added > 1 ? 's' : ''}${failed ? ', ' + failed + ' failed' : ''}` : failed ? (files.length === 1 ? why : 'Nothing was added') : 'Already in your library');
|
||||||
|
if (location.hash.startsWith('#/read') && !location.hash.startsWith('#/read/open')) route();
|
||||||
|
}
|
||||||
|
async function rdAddOne(file) {
|
||||||
|
const buf = await file.arrayBuffer(), ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
const book = await rdApi('/upload?name=' + encodeURIComponent(file.name), { method: 'POST', headers: { 'content-type': 'application/octet-stream' }, body: buf });
|
||||||
|
if (book.duplicate) return book;
|
||||||
|
try {
|
||||||
|
const meta = await rdMeta(ext, buf);
|
||||||
|
if (meta.title || meta.author) await rdApi('/book/' + book.id, rdJson('PATCH', { title: meta.title || undefined, author: meta.author || undefined }));
|
||||||
|
if (meta.cover) await rdApi('/book/' + book.id + '/cover', { method: 'PUT', headers: { 'content-type': meta.cover.type }, body: meta.cover });
|
||||||
|
} catch { /* the book is added anyway, with its file name as the title */ }
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
async function rdMeta(ext, buf) {
|
||||||
|
if (ext === 'epub') {
|
||||||
|
await rdLibs('epub'); const b = ePub(buf.slice(0)); const out = {};
|
||||||
|
try {
|
||||||
|
const md = await b.loaded.metadata; out.title = md.title; out.author = md.creator;
|
||||||
|
const cu = await b.coverUrl(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; }
|
||||||
|
} finally { try { b.destroy(); } catch {} }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (ext === 'pdf') {
|
||||||
|
await rdLibs('pdf'); const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buf.slice(0)) }).promise, out = {};
|
||||||
|
try {
|
||||||
|
const info = (await pdf.getMetadata().catch(() => ({}))).info || {}; out.title = info.Title; out.author = info.Author;
|
||||||
|
const page = await pdf.getPage(1), vp0 = page.getViewport({ scale: 1 }), vp = page.getViewport({ scale: 360 / vp0.width }), c = document.createElement('canvas');
|
||||||
|
c.width = vp.width; c.height = vp.height; await page.render({ canvasContext: c.getContext('2d'), viewport: vp, background: '#fff' }).promise;
|
||||||
|
out.cover = await new Promise(r => c.toBlob(r, 'image/jpeg', 0.8));
|
||||||
|
} finally { pdf.destroy(); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (rdKind(ext) === 'mobi') {
|
||||||
|
await rdLibs('mobi'); const m = await rdMobiOpen(new Uint8Array(buf.slice(0))), out = {};
|
||||||
|
try { const md = m.getMetadata(); out.title = md.title; out.author = (md.author || []).join(', '); const cu = m.getCoverImage(); if (cu) { const blob = await (await fetch(cu)).blob(); if (/^image\/(jpeg|png|gif|webp)$/.test(blob.type)) out.cover = blob; } } finally { try { m.destroy(); } catch {} }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
/** MOBI 6 and KF8 (AZW3) need different readers: try the newer format first, since the older reader can "open" a KF8 file and show nonsense. */
|
||||||
|
async function rdMobiOpen(u8) {
|
||||||
|
try { const k = await MobiParser.initKf8File(u8); if (k.getSpine().length) return k; try { k.destroy(); } catch {} } catch {}
|
||||||
|
return MobiParser.initMobiFile(u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================================= Book store: free, DRM-free books from Project Gutenberg, Standard Ebooks, the Internet Archive and any OPDS catalogue
|
||||||
|
async function rdStore() {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Book store</h1><p class="empty">Loading…</p></div>';
|
||||||
|
let sources, mine;
|
||||||
|
try { [sources, mine] = await Promise.all([rdApi('/store/sources'), rdApi('/list')]); } catch (e) { v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/read">${icon('back')}Books</a><p class="err">${esc(e.message)}</p></div>`; return; }
|
||||||
|
if (!location.hash.startsWith('#/read/store')) return;
|
||||||
|
const key = b => (b.title + '|' + (b.author || '')).toLowerCase().replace(/\s+/g, ' ').trim(), have = new Map(mine.map(b => [key(b), b.id]));
|
||||||
|
const S = { q: '', source: 'all', page: 1, items: [], more: false, seq: 0 };
|
||||||
|
v.innerHTML = `<div class="wrap" id="rdst"><div class="ptop"><a class="btn ghost" href="#/read">${icon('back')}Books</a><h1 style="margin:0">Book store</h1><span class="pspace grow"></span>${ME && ME.role === 'admin' ? `<button class="btn ghost" id="rdcat">${icon('cog')}Catalogues</button>` : ''}</div>
|
||||||
|
<p class="sub">Free books without DRM, from public sources. Search them all at once, then add a book to your library in one tap.</p>
|
||||||
|
<input type="search" id="rdsq" placeholder="Search titles and authors" autocomplete="off" autocapitalize="off" style="width:100%">
|
||||||
|
<div class="seg" id="rdchips"></div><div id="rdnote"></div><div id="rdres"></div><div id="rdmore" class="bar" style="justify-content:center"></div></div>`;
|
||||||
|
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)}&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(); el.querySelector('#rdcl').innerHTML = `<div class="list">${sources.map(x => `<div class="row"><span class="grow"><b>${esc(x.name)}</b><small>${x.builtin ? 'Built in' : 'Added by you'}</small></span>${x.builtin ? '' : `<button class="btn danger" data-d="${esc(x.id)}" style="min-height:36px">Remove</button>`}</div>`).join('')}</div>
|
||||||
|
<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>`;
|
||||||
|
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();
|
||||||
|
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;
|
||||||
|
new RdReader(book, notes).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
class RdReader {
|
||||||
|
constructor(book, notes) {
|
||||||
|
this.book = book; this.notes = notes || []; this.P = rdPrefs(); this.loc = book.loc; this.pct = book.pct || 0; this.bars = true; this.q = [];
|
||||||
|
rdCur?.destroy(); rdCur = this;
|
||||||
|
}
|
||||||
|
$(s) { return this.root.querySelector(s); }
|
||||||
|
async start() {
|
||||||
|
const b = this.book, v = $('#view');
|
||||||
|
v.innerHTML = `<div class="rd" id="rdroot"><header class="rdbar"><a class="ibtn" href="#/read" aria-label="Back to library" title="Back to library">${icon('back')}</a><span class="rdtitle grow"><b>${esc(b.title)}</b><small>${esc(b.author || '')}</small></span>
|
||||||
|
<button class="ibtn" id="rdtoc" aria-label="Contents" title="Contents">${icon('list')}</button><button class="ibtn" id="rdsrch" aria-label="Search in book" title="Search in book">${icon('search')}</button>
|
||||||
|
<button class="ibtn" id="rdbm" aria-label="Bookmark" title="Bookmark this place">${icon('bookmark')}</button><button class="ibtn" id="rdnotes" aria-label="Bookmarks and notes" title="Bookmarks, notes and highlights">${icon('edit')}</button>
|
||||||
|
<button class="ibtn" id="rdaa" aria-label="Appearance" title="Appearance">${icon('font')}</button></header>
|
||||||
|
<div class="rdmain"><div id="rdstage" class="rdstage ${b.ext}"></div><button class="rdzone l" id="rdprev" aria-label="Previous page"></button><button class="rdzone r" id="rdnext" aria-label="Next page"></button><div class="rdsel" id="rdsel" hidden></div><p class="rdload" id="rdload">Opening…</p></div>
|
||||||
|
<footer class="rdfoot"><span class="rdlabel" id="rdlabel"></span><input type="range" id="rdseek" min="0" max="1000" value="${Math.round(this.pct * 1000)}" aria-label="Position in book"><span class="rdpct" id="rdpct">${Math.round(this.pct * 100)}%</span></footer></div>`;
|
||||||
|
this.root = $('#rdroot'); this.applyChrome();
|
||||||
|
this.hookUi();
|
||||||
|
try {
|
||||||
|
await rdLibs(rdKind(b.ext));
|
||||||
|
this.A = b.ext === 'epub' ? new RdEpub(this) : b.ext === 'pdf' ? new RdPdf(this) : rdKind(b.ext) === 'mobi' ? new RdMobi(this) : new RdDocx(this);
|
||||||
|
await this.A.open(this.$('#rdstage'), this.loc);
|
||||||
|
this.$('#rdload').remove();
|
||||||
|
} catch (e) { if (this.dead) return; this.$('#rdload').innerHTML = `<span class="err">${esc(e.message || 'This book could not be opened')}</span><br><a class="btn ghost" href="#/read" style="margin-top:12px">Back to library</a>`; }
|
||||||
|
}
|
||||||
|
destroy() { if (this.dead) return; this.dead = true; this.save(true); try { this.A?.destroy(); } catch {} removeEventListener('keydown', this.keys, true); removeEventListener('pagehide', this.hide); removeEventListener('resize', this.onResize); if (rdCur === this) rdCur = null; }
|
||||||
|
applyChrome() { const t = rdTheme(this.P); this.root.style.setProperty('--rd-bg', t.bg); this.root.style.setProperty('--rd-fg', t.fg); this.root.style.setProperty('--rd-link', t.link); this.root.classList.toggle('dark', t === RD_THEMES.dark); this.root.classList.toggle('hidebars', !this.bars); }
|
||||||
|
hookUi() {
|
||||||
|
const A = () => this.A;
|
||||||
|
this.$('#rdprev').onclick = () => A()?.prev(); this.$('#rdnext').onclick = () => A()?.next();
|
||||||
|
this.$('#rdseek').oninput = e => { this.$('#rdpct').textContent = Math.round(e.target.value / 10) + '%'; clearTimeout(this.seekT); this.seekT = setTimeout(() => A()?.seek(e.target.value / 1000), 250); };
|
||||||
|
this.$('#rdtoc').onclick = () => this.showToc(); this.$('#rdsrch').onclick = () => this.showSearch(); this.$('#rdnotes').onclick = () => this.showNotes(); this.$('#rdaa').onclick = () => this.showAppearance();
|
||||||
|
this.$('#rdbm').onclick = () => this.toggleBookmark();
|
||||||
|
this.keys = e => {
|
||||||
|
if ((e.target.closest && e.target.closest('input,textarea,select,#sheet')) || document.getElementById('sheet')) return;
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'PageDown' || (e.key === ' ' && !e.shiftKey && this.book.ext !== 'pdf' && this.book.ext !== 'docx')) { A()?.next(); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { A()?.prev(); e.preventDefault(); }
|
||||||
|
else if (e.key === 'Escape') location.hash = '#/read';
|
||||||
|
else if (e.key === 't') this.showToc(); else if (e.key === 'b') this.toggleBookmark(); else if (e.key === '/' ) { e.preventDefault(); this.showSearch(); }
|
||||||
|
};
|
||||||
|
addEventListener('keydown', this.keys, true);
|
||||||
|
this.hide = () => this.save(true); addEventListener('pagehide', this.hide);
|
||||||
|
this.onResize = () => this.A?.resize?.(); addEventListener('resize', this.onResize);
|
||||||
|
document.addEventListener('mousedown', e => { if (!e.target.closest('#rdsel')) this.hideSel(); }, { once: false, capture: true });
|
||||||
|
}
|
||||||
|
toggleBars() { this.bars = !this.bars; this.root.classList.toggle('hidebars', !this.bars); setTimeout(() => this.A?.resize?.(), 60); }
|
||||||
|
// ---- where you are: reported by the format reader, saved to the server a moment later
|
||||||
|
relocated({ loc, pct, label }) {
|
||||||
|
this.loc = loc; this.pct = Math.min(1, Math.max(0, pct || 0)); this.label = label || '';
|
||||||
|
if (this.root.isConnected) { this.$('#rdseek').value = Math.round(this.pct * 1000); this.$('#rdpct').textContent = Math.round(this.pct * 100) + '%'; this.$('#rdlabel').textContent = this.label; }
|
||||||
|
clearTimeout(this.saveT); this.saveT = setTimeout(() => this.save(), 1500);
|
||||||
|
}
|
||||||
|
save(keepalive) {
|
||||||
|
clearTimeout(this.saveT); if (!this.loc || this.savedLoc === this.loc + this.pct) return; this.savedLoc = this.loc + this.pct;
|
||||||
|
fetch(`/api/read/book/${this.book.id}/pos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ loc: this.loc, pct: this.pct, t: Date.now() }), keepalive: !!keepalive }).catch(() => { this.savedLoc = ''; });
|
||||||
|
}
|
||||||
|
// ---- bookmarks, notes and highlights
|
||||||
|
async pushNote(n) {
|
||||||
|
n.t = Date.now(); const i = this.notes.findIndex(x => x.id === n.id); if (i >= 0) this.notes[i] = n; else this.notes.push(n);
|
||||||
|
try { this.notes = await rdApi(`/book/${this.book.id}/notes`, rdJson('POST', [n])); } catch (e) { toast('Could not save: ' + e.message); }
|
||||||
|
}
|
||||||
|
isMarked() { return this.notes.find(n => n.type === 'mark' && !n.del && this.A?.sameSpot?.(n)); }
|
||||||
|
async toggleBookmark() {
|
||||||
|
if (!this.A) return;
|
||||||
|
const have = this.isMarked();
|
||||||
|
if (have) { await this.pushNote({ ...have, del: true }); this.notes = this.notes.filter(n => !n.del); toast('Bookmark removed'); }
|
||||||
|
else { const here = this.A.here(); await this.pushNote({ id: rdUid(), type: 'mark', loc: here.loc, pct: this.pct, text: here.text || '', page: here.page || 0, color: 'yellow' }); toast('Bookmarked'); }
|
||||||
|
this.$('#rdbm').classList.toggle('on', !!this.isMarked());
|
||||||
|
}
|
||||||
|
// The selection bar: pick a colour to highlight, add a note, or copy.
|
||||||
|
showSel(sel) { // sel = { loc, text, page, clear() }
|
||||||
|
this.sel = sel; const el = this.$('#rdsel');
|
||||||
|
el.innerHTML = Object.entries(RD_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') + `<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
|
||||||
|
el.hidden = false;
|
||||||
|
el.querySelectorAll('.rdcol').forEach(b => b.onclick = async () => { await this.addHighlight(b.dataset.c, ''); });
|
||||||
|
el.querySelector('[data-a=note]').onclick = () => this.noteDialog(null);
|
||||||
|
el.querySelector('[data-a=copy]').onclick = () => { navigator.clipboard?.writeText(sel.text).then(() => toast('Copied'), () => toast('Could not copy')); this.hideSel(true); };
|
||||||
|
}
|
||||||
|
hideSel(clear) { const el = this.$('#rdsel'); if (el) el.hidden = true; if (clear && this.sel) { try { this.sel.clear(); } catch {} } if (clear) this.sel = null; }
|
||||||
|
async addHighlight(color, note) {
|
||||||
|
const s = this.sel; if (!s) return; this.hideSel(true);
|
||||||
|
const n = { id: rdUid(), type: note ? 'note' : 'hl', loc: s.loc, pct: this.pct, text: s.text.slice(0, 2000), note, color, page: s.page || 0 };
|
||||||
|
await this.pushNote(n); this.A.drawNote?.(n);
|
||||||
|
}
|
||||||
|
noteDialog(existing) { // a new note on the current selection, or edit one that exists
|
||||||
|
const s = this.sel, n = existing, color = n ? n.color : 'yellow';
|
||||||
|
const el = openSheet(n ? 'Highlight' : 'Add a note', '', `${(n || s).text ? `<blockquote class="rdq">${esc(((n || s).text || '').slice(0, 400))}</blockquote>` : ''}<label>Note</label><textarea id="rdnt" rows="4" maxlength="5000" placeholder="Write a note">${esc(n ? n.note : '')}</textarea>
|
||||||
|
<div class="rdcolors" id="rdcols">${Object.entries(RD_COLORS).map(([c, hex]) => `<button type="button" class="rdcol${c === color ? ' on' : ''}" data-c="${c}" style="background:${hex}" aria-label="${c}"></button>`).join('')}</div>
|
||||||
|
<div class="bar">${n ? '<button class="btn danger" id="rdnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="rdns" style="flex:1;justify-content:center">Save</button></div>`);
|
||||||
|
let col = color; el.querySelectorAll('#rdcols .rdcol').forEach(b => b.onclick = () => { col = b.dataset.c; el.querySelectorAll('#rdcols .rdcol').forEach(x => x.classList.toggle('on', x === b)); });
|
||||||
|
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
el.querySelector('#rdns').onclick = async () => {
|
||||||
|
const text = el.querySelector('#rdnt').value.trim(); closeSheet();
|
||||||
|
if (n) { const upd = { ...n, note: text, color: col, type: text ? 'note' : 'hl' }; this.A.eraseNote?.(n); await this.pushNote(upd); this.A.drawNote?.(upd); this.refreshNotes?.(); }
|
||||||
|
else if (this.sel) await this.addHighlight(col, text);
|
||||||
|
};
|
||||||
|
if (n) el.querySelector('#rdnd').onclick = () => { closeSheet(); this.deleteNote(n); };
|
||||||
|
}
|
||||||
|
async deleteNote(n) { this.A.eraseNote?.(n); await this.pushNote({ ...n, del: true }); this.notes = this.notes.filter(x => !x.del); this.$('#rdbm').classList.toggle('on', !!this.isMarked()); this.refreshNotes?.(); }
|
||||||
|
// ---- panels
|
||||||
|
async showToc() {
|
||||||
|
const el = openSheet('Contents', '', '<p class="empty">Loading…</p>'); let items = [];
|
||||||
|
try { items = await this.A.toc(); } catch {}
|
||||||
|
el.querySelector('.empty').outerHTML = items.length ? `<div class="list rdlist">${items.map((t, i) => `<button class="row" data-i="${i}" style="padding-left:${16 + t.depth * 18}px"><span class="grow"><b>${esc(t.label)}</b></span></button>`).join('')}</div>` : '<p class="empty">This book has no table of contents.</p>';
|
||||||
|
el.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(items[b.dataset.i].loc); });
|
||||||
|
}
|
||||||
|
showSearch() {
|
||||||
|
const el = openSheet('Search in book', '', `<form><input type="search" id="rdq" placeholder="Search for a word or phrase" autocomplete="off" value="${esc(this.lastQ || '')}"></form><div id="rdres" class="list rdlist" style="margin-top:10px"></div>`);
|
||||||
|
const res = el.querySelector('#rdres'), inp = el.querySelector('#rdq'); let seq = 0;
|
||||||
|
const run = async () => {
|
||||||
|
const term = inp.value.trim(), my = ++seq; this.lastQ = term; if (term.length < 2) { res.innerHTML = ''; return; }
|
||||||
|
res.innerHTML = '<p class="empty">Searching…</p>';
|
||||||
|
try {
|
||||||
|
const out = await this.A.search(term, () => my !== seq); if (my !== seq) return;
|
||||||
|
res.innerHTML = out.length ? out.map((r, i) => `<button class="row" data-i="${i}"><span class="grow"><b style="white-space:normal;font-weight:400">${r.excerpt}</b><small>${esc(r.where || '')}</small></span></button>`).join('') : '<p class="empty">Nothing found.</p>';
|
||||||
|
res.querySelectorAll('button.row').forEach(b => b.onclick = () => { closeSheet(); this.A.goto(out[b.dataset.i].loc, term); });
|
||||||
|
} catch (e) { if (my === seq) res.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
|
};
|
||||||
|
el.querySelector('form').onsubmit = e => { e.preventDefault(); run(); }; let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 450); }; inp.focus(); if (inp.value) run();
|
||||||
|
}
|
||||||
|
showNotes() {
|
||||||
|
const el = openSheet('Bookmarks and notes', '', '<div id="rdnl"></div>');
|
||||||
|
const draw = () => {
|
||||||
|
const l = this.notes.filter(n => !n.del).sort((a, b) => a.pct - b.pct), box = el.querySelector('#rdnl');
|
||||||
|
box.innerHTML = l.length ? `<div class="list rdlist">${l.map(n => `<div class="row rdnote" data-id="${n.id}"><button class="grow rdnj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0">
|
||||||
|
<b style="white-space:normal">${n.type === 'mark' ? icon('bookmark') + ' Bookmark' : `<span class="rddot" style="background:${RD_COLORS[n.color] || RD_COLORS.yellow}"></span>${n.type === 'note' ? 'Note' : 'Highlight'}`} <small style="display:inline">· ${n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%'}</small></b>
|
||||||
|
${n.text ? `<small style="white-space:normal;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden">${esc(n.text)}</small>` : ''}${n.note ? `<span style="white-space:pre-wrap;display:block;margin-top:2px">${esc(n.note)}</span>` : ''}</button>
|
||||||
|
${n.type === 'mark' ? '' : `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>`}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>
|
||||||
|
<div class="bar"><button class="btn ghost" id="rdexp">${icon('copy')}Copy as text</button></div>` : '<p class="empty">Nothing yet. Select text to highlight it or add a note; tap the bookmark button to mark a place.</p>';
|
||||||
|
box.querySelectorAll('.rdnote').forEach(r => { const n = this.notes.find(x => x.id === r.dataset.id);
|
||||||
|
r.querySelector('.rdnj').onclick = () => { closeSheet(); this.A.goto(n.loc, null, n); };
|
||||||
|
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => { closeSheet(); this.noteDialog(n); };
|
||||||
|
r.querySelector('[data-d]').onclick = async () => { await this.deleteNote(n); draw(); }; });
|
||||||
|
const ex = box.querySelector('#rdexp'); if (ex) ex.onclick = () => {
|
||||||
|
const txt = `${this.book.title}${this.book.author ? ' - ' + this.book.author : ''}\n\n` + l.map(n => (n.type === 'mark' ? '[Bookmark] ' + (n.page ? 'page ' + n.page : Math.round(n.pct * 100) + '%') : `"${n.text}"${n.note ? '\n Note: ' + n.note : ''}`)).join('\n\n');
|
||||||
|
navigator.clipboard?.writeText(txt).then(() => toast('Copied'), () => toast('Could not copy'));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
this.refreshNotes = () => { if (document.body.contains(el)) draw(); }; draw();
|
||||||
|
}
|
||||||
|
showAppearance() {
|
||||||
|
const P = this.P, pdf = this.book.ext === 'pdf', docx = this.book.ext === 'docx';
|
||||||
|
const seg = (name, opts, cur) => `<div class="rdseg" data-k="${name}">${opts.map(([v, l]) => `<button type="button" data-v="${v}" class="${String(v) === String(cur) ? 'on' : ''}">${l}</button>`).join('')}</div>`;
|
||||||
|
const el = openSheet('Appearance', '', `<label>Theme</label>${seg('theme', [['auto', 'Auto'], ['light', 'Light'], ['sepia', 'Sepia'], ['dark', 'Dark']], P.theme)}
|
||||||
|
${pdf ? `<label>Zoom</label><div class="rdstep"><button type="button" class="btn ghost" data-z="-0.15">−</button><span id="rdzv">${Math.round(P.zoom * 100)}%</span><button type="button" class="btn ghost" data-z="0.15">+</button></div>` : `
|
||||||
|
<label>Text size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="rdsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
|
||||||
|
<label>Font</label>${seg('font', Object.entries(RD_FONTS).filter(([k]) => !(docx && !k)).map(([k, l]) => [k, l]), P.font)}
|
||||||
|
<label>Line spacing</label>${seg('lh', [[1.3, 'Tight'], [1.55, 'Normal'], [1.85, 'Loose']], P.lh)}
|
||||||
|
<label>Margins</label>${seg('margin', [[0, 'Narrow'], [2, 'Normal'], [4, 'Wide']], P.margin)}
|
||||||
|
${docx ? '' : `<label>Layout</label>${seg('flow', [['paged', 'Pages'], ['scroll', 'Scroll']], P.flow)}`}`}
|
||||||
|
<div class="bar"><button class="btn" id="rdok" style="flex:1;justify-content:center">Done</button></div>`);
|
||||||
|
const apply = (k, v) => { P[k] = v; rdSavePrefs(P); this.applyChrome(); this.A?.restyle(k); };
|
||||||
|
el.querySelectorAll('.rdseg').forEach(g => g.querySelectorAll('button').forEach(b => b.onclick = () => { g.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); const k = g.dataset.k; apply(k, ['lh', 'margin'].includes(k) ? +b.dataset.v : b.dataset.v); }));
|
||||||
|
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { apply('size', Math.min(40, Math.max(12, P.size + +b.dataset.s))); el.querySelector('#rdsv').textContent = P.size; });
|
||||||
|
el.querySelectorAll('[data-z]').forEach(b => b.onclick = () => { apply('zoom', Math.min(3, Math.max(0.5, Math.round((P.zoom + +b.dataset.z) * 100) / 100))); el.querySelector('#rdzv').textContent = Math.round(P.zoom * 100) + '%'; });
|
||||||
|
el.querySelector('#rdok').onclick = closeSheet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared helpers for the format readers
|
||||||
|
const rdEsc = esc;
|
||||||
|
function rdExcerpt(text, i, len) { const a = Math.max(0, i - 40), b = Math.min(text.length, i + len + 60); return (a ? '…' : '') + esc(text.slice(a, i)) + '<mark>' + esc(text.slice(i, i + len)) + '</mark>' + esc(text.slice(i + len, b)) + (b < text.length ? '…' : ''); }
|
||||||
|
const rdFetchBook = async (id, ext) => { const r = await fetch(`/api/read/book/${id}/file?inline=1`); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')'); return r.arrayBuffer(); };
|
||||||
|
const rdCss = (P, t) => { const fam = RD_FAMILY[P.font]; return `html,body{background:${t.bg}!important;color:${t.fg}!important}body{${fam ? `font-family:${fam}!important;` : ''}font-size:${P.size}px;line-height:${P.lh};padding:0 ${P.margin * 3}%!important}a{color:${t.link}!important}p,li,div,span,td,h1,h2,h3,h4,h5,h6,blockquote{color:inherit!important;${fam ? 'font-family:inherit!important;' : ''}}p,li{line-height:${P.lh}!important}img,svg{max-width:100%!important;height:auto}${P.theme === 'dark' || t === RD_THEMES.dark ? 'img{filter:brightness(.85)}' : ''}`; };
|
||||||
|
|
||||||
|
// ======================================================= EPUB
|
||||||
|
class RdEpub {
|
||||||
|
constructor(R) { this.R = R; this.hl = new Map(); }
|
||||||
|
async open(stage, loc) {
|
||||||
|
const R = this.R; this.stage = stage;
|
||||||
|
this.b = ePub(await rdFetchBook(R.book.id, 'epub'));
|
||||||
|
await this.b.ready;
|
||||||
|
const cached = (() => { try { return localStorage.getItem('rd-loc-' + R.book.id); } catch {} })();
|
||||||
|
this.locs = new Promise(ok => { const done = () => { this.locsReady = true; ok(); this.refreshPct(); }; if (cached) { try { this.b.locations.load(cached); return done(); } catch {} } this.b.locations.generate(1200).then(() => { try { localStorage.setItem('rd-loc-' + R.book.id, this.b.locations.save()); } catch {} done(); }).catch(ok); });
|
||||||
|
await this.build(loc);
|
||||||
|
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
|
||||||
|
}
|
||||||
|
async build(loc) {
|
||||||
|
const R = this.R, P = R.P; if (this.rend) { try { this.rend.destroy(); } catch {} this.stage.innerHTML = ''; this.hl.clear(); }
|
||||||
|
const scroll = P.flow === 'scroll';
|
||||||
|
this.rend = this.b.renderTo(this.stage, scroll ? { width: '100%', height: '100%', flow: 'scrolled', manager: 'continuous', allowScriptedContent: false } : { width: '100%', height: '100%', flow: 'paginated', spread: 'auto', minSpreadWidth: 900, allowScriptedContent: false });
|
||||||
|
this.restyle();
|
||||||
|
this.rend.on('relocated', l => { this.at = l; this.refreshPct(); R.$('#rdbm').classList.toggle('on', !!R.isMarked()); });
|
||||||
|
this.rend.on('rendered', (sec) => { const c = this.rend.getContents()[0]; if (!c || c.__rd) return; });
|
||||||
|
this.rend.on('selected', async (range, contents) => {
|
||||||
|
try {
|
||||||
|
const r = await this.b.getRange(range), text = r.toString().trim(); if (!text) return;
|
||||||
|
R.showSel({ loc: range, text, clear: () => { for (const c of this.rend.getContents()) c.window.getSelection().removeAllRanges(); } });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
this.rend.hooks.content.register(contents => {
|
||||||
|
const doc = contents.document; let x0 = 0, y0 = 0, t0 = 0;
|
||||||
|
doc.addEventListener('keydown', e => R.keys(e));
|
||||||
|
doc.addEventListener('touchstart', e => { x0 = e.changedTouches[0].screenX; y0 = e.changedTouches[0].screenY; t0 = Date.now(); }, { passive: true });
|
||||||
|
doc.addEventListener('touchend', e => { const dx = e.changedTouches[0].screenX - x0, dy = e.changedTouches[0].screenY - y0; if (this.R.P.flow === 'paged' && Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5 && Date.now() - t0 < 700) dx < 0 ? this.next() : this.prev(); });
|
||||||
|
doc.addEventListener('click', e => { if (e.target.closest && e.target.closest('a[href]')) return; const sel = contents.window.getSelection(); if (sel && !sel.isCollapsed) return; if (this.R.P.flow !== 'paged') return R.toggleBars(); const x = this.tapX(contents, e.clientX); if (x < 0.22) this.prev(); else if (x > 0.78) this.next(); else R.toggleBars(); });
|
||||||
|
doc.addEventListener('mousedown', () => R.hideSel());
|
||||||
|
});
|
||||||
|
const start = loc && !/^\d+$/.test(loc) ? loc : undefined;
|
||||||
|
try { await this.rend.display(start); } catch { await this.rend.display(); }
|
||||||
|
for (const n of R.notes) this.drawNote(n);
|
||||||
|
}
|
||||||
|
/** Where a tap landed as a fraction of the reading area. The page frame is wider than the screen (the pages sit side by side inside it), so its own coordinates say nothing about the screen. */
|
||||||
|
tapX(contents, clientX) { const f = contents.window.frameElement.getBoundingClientRect(), s = this.stage.getBoundingClientRect(); return (f.left + clientX - s.left) / s.width; }
|
||||||
|
restyle(k) {
|
||||||
|
const R = this.R, P = R.P, t = rdTheme(P);
|
||||||
|
if (k === 'flow') { const loc = this.at && this.at.start.cfi; return this.build(loc); }
|
||||||
|
const th = this.rend.themes; th.override('color', t.fg); th.override('background', t.bg);
|
||||||
|
th.fontSize(P.size + 'px'); th.override('line-height', String(P.lh)); th.override('padding', `0 ${P.margin * 3}%`);
|
||||||
|
if (P.font) th.font(RD_FAMILY[P.font]); else th.override('font-family', 'inherit');
|
||||||
|
th.default({ a: { color: t.link + ' !important' }, 'p,li,div,span,td,h1,h2,h3,h4,blockquote': { color: 'inherit !important' }, img: { 'max-width': '100% !important' } });
|
||||||
|
R.root.style.setProperty('--rd-bg', t.bg);
|
||||||
|
if (k === 'theme') this.R.applyChrome();
|
||||||
|
if (this.at && ['size', 'font', 'lh', 'margin'].includes(k)) setTimeout(() => { try { this.rend.display(this.at.start.cfi); } catch {} }, 80);
|
||||||
|
}
|
||||||
|
refreshPct() {
|
||||||
|
const l = this.at; if (!l) return; let pct;
|
||||||
|
if (this.locsReady) pct = this.b.locations.percentageFromCfi(l.start.cfi); else pct = this.b.spine.length ? (l.start.index || 0) / this.b.spine.length : 0;
|
||||||
|
if (l.atEnd) pct = 1;
|
||||||
|
const sec = this.b.navigation && this.b.navigation.get && l.start.href ? this.b.navigation.get(l.start.href) : null;
|
||||||
|
this.R.relocated({ loc: l.start.cfi, pct, label: sec && sec.label ? sec.label.trim() : '' });
|
||||||
|
}
|
||||||
|
next() { return this.rend.next(); } prev() { return this.rend.prev(); }
|
||||||
|
async seek(f) { if (!this.locsReady) await this.locs; const cfi = this.b.locations.cfiFromPercentage(Math.min(0.9999, f)); if (cfi) this.rend.display(cfi); }
|
||||||
|
here() { const l = this.at; return { loc: l ? l.start.cfi : '', text: '' }; }
|
||||||
|
sameSpot(n) { const l = this.at; if (!l) return false; try { const c = new ePub.CFI(); return c.compare(n.loc, l.start.cfi) >= 0 && c.compare(n.loc, l.end.cfi) <= 0; } catch { return n.loc === l.start.cfi; } }
|
||||||
|
async toc() { const nav = await this.b.loaded.navigation, out = []; const walk = (items, d) => items.forEach(i => { out.push({ label: (i.label || '').trim() || 'Untitled', loc: i.href, depth: d }); if (i.subitems) walk(i.subitems, d + 1); }); walk(nav.toc, 0); return out; }
|
||||||
|
async goto(loc, term, note) {
|
||||||
|
try { await this.rend.display(loc); } catch { return toast('Could not go there'); }
|
||||||
|
const flash = note && note.type !== 'mark' ? note.loc : term ? loc : null;
|
||||||
|
if (flash && /^epubcfi/.test(flash) && !note) { try { this.rend.annotations.highlight(flash, {}, null, 'rd-flash', { fill: '#ffd43b', 'fill-opacity': '.5' }); setTimeout(() => { try { this.rend.annotations.remove(flash, 'highlight'); } catch {} }, 2500); } catch {} }
|
||||||
|
}
|
||||||
|
async search(q, stale) {
|
||||||
|
const out = [], re = q.toLowerCase();
|
||||||
|
for (const item of this.b.spine.spineItems) {
|
||||||
|
if (stale()) return out;
|
||||||
|
try {
|
||||||
|
await item.load(this.b.load.bind(this.b));
|
||||||
|
const found = item.find(q) || []; // [{cfi, excerpt}]
|
||||||
|
const nav = this.b.navigation && this.b.navigation.get ? this.b.navigation.get(item.href) : null;
|
||||||
|
for (const f of found) { const ex = String(f.excerpt || ''), i = ex.toLowerCase().indexOf(re); out.push({ loc: f.cfi, excerpt: i >= 0 ? rdExcerpt(ex, i, q.length) : esc(ex), where: nav && nav.label ? nav.label.trim() : '' }); if (out.length >= 150) { item.unload(); return out; } }
|
||||||
|
item.unload();
|
||||||
|
} catch { try { item.unload(); } catch {} }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
drawNote(n) {
|
||||||
|
if (n.type === 'mark' || n.del || !/^epubcfi/.test(n.loc || '') || this.hl.has(n.id)) return;
|
||||||
|
const hex = RD_COLORS[n.color] || RD_COLORS.yellow;
|
||||||
|
try { this.rend.annotations.highlight(n.loc, { id: n.id }, () => { const cur = this.R.notes.find(x => x.id === n.id); if (cur) this.R.noteDialog(cur); }, 'rd-hl', { fill: hex, 'fill-opacity': '.4', 'mix-blend-mode': 'multiply', stroke: 'none' }); this.hl.set(n.id, n.loc); } catch {}
|
||||||
|
}
|
||||||
|
eraseNote(n) { const loc = this.hl.get(n.id); if (loc) { try { this.rend.annotations.remove(loc, 'highlight'); } catch {} this.hl.delete(n.id); } }
|
||||||
|
resize() { try { this.rend.resize(); } catch {} }
|
||||||
|
destroy() { try { this.b.destroy(); } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================================= PDF
|
||||||
|
class RdPdf {
|
||||||
|
constructor(R) { this.R = R; this.texts = {}; this.pages = []; }
|
||||||
|
async open(stage, loc) {
|
||||||
|
const R = this.R; this.stage = stage;
|
||||||
|
this.pdf = await pdfjsLib.getDocument({ url: `/api/read/book/${R.book.id}/file?inline=1` }).promise;
|
||||||
|
this.n = this.pdf.numPages; const p1 = await this.pdf.getPage(1); this.v1 = p1.getViewport({ scale: 1 });
|
||||||
|
stage.innerHTML = '<div class="rdpages"></div>'; this.box = stage.firstChild;
|
||||||
|
this.layout();
|
||||||
|
stage.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.onScroll(); }); });
|
||||||
|
this.io = new IntersectionObserver(es => es.forEach(e => e.isIntersecting ? this.render(+e.target.dataset.n) : this.release(+e.target.dataset.n)), { root: stage, rootMargin: '150% 0px' });
|
||||||
|
this.pages.forEach(p => this.io.observe(p.el));
|
||||||
|
const sel = () => setTimeout(() => this.checkSel(), 30);
|
||||||
|
stage.addEventListener('mouseup', sel); stage.addEventListener('touchend', sel); stage.addEventListener('keyup', sel);
|
||||||
|
stage.addEventListener('click', e => { const s = getSelection(); if (s && !s.isCollapsed) return; const r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width; if (x < 0.15 && this.R.P.zoom <= 1.01) this.prev(); else if (x > 0.85 && this.R.P.zoom <= 1.01) this.next(); else R.toggleBars(); });
|
||||||
|
const page = /^pg:(\d+)/.exec(loc || '');
|
||||||
|
this.goto(page ? 'pg:' + page[1] : 'pg:1'); this.onScroll();
|
||||||
|
R.$('#rdbm').classList.toggle('on', !!R.isMarked());
|
||||||
|
}
|
||||||
|
scale() { return Math.max(0.3, (this.stage.clientWidth - 16) / this.v1.width * this.R.P.zoom); }
|
||||||
|
layout() {
|
||||||
|
const s = this.scale(), cur = this.page || 1; this.s = s; this.box.innerHTML = ''; this.pages = [];
|
||||||
|
for (let i = 1; i <= this.n; i++) {
|
||||||
|
const el = document.createElement('div'); el.className = 'pg'; el.dataset.n = i; el.style.width = this.v1.width * s + 'px'; el.style.height = this.v1.height * s + 'px'; this.box.appendChild(el); this.pages.push({ el, rendered: false });
|
||||||
|
}
|
||||||
|
if (this.io) this.pages.forEach(p => this.io.observe(p.el));
|
||||||
|
this.stage.scrollTop = this.pages[cur - 1].el.offsetTop - 8;
|
||||||
|
this.restyle('theme');
|
||||||
|
}
|
||||||
|
async render(n) {
|
||||||
|
const p = this.pages[n - 1]; if (!p || p.rendered || p.busy) return; p.busy = true;
|
||||||
|
try {
|
||||||
|
const page = await this.pdf.getPage(n), vp = page.getViewport({ scale: this.s }), dpr = Math.min(2, devicePixelRatio || 1);
|
||||||
|
if (p.gone) { p.busy = false; return; }
|
||||||
|
const c = document.createElement('canvas'); c.width = Math.floor(vp.width * dpr); c.height = Math.floor(vp.height * dpr); c.style.width = vp.width + 'px'; c.style.height = vp.height + 'px';
|
||||||
|
p.el.style.width = vp.width + 'px'; p.el.style.height = vp.height + 'px';
|
||||||
|
await page.render({ canvasContext: c.getContext('2d'), viewport: vp, transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null, background: 'rgba(255,255,255,1)' }).promise;
|
||||||
|
const tl = document.createElement('div'); tl.className = 'textLayer'; tl.style.setProperty('--scale-factor', vp.scale);
|
||||||
|
const hl = document.createElement('div'); hl.className = 'rdhls';
|
||||||
|
p.el.replaceChildren(c, hl, tl); p.rendered = true; p.vp = vp;
|
||||||
|
pdfjsLib.renderTextLayer({ textContentSource: page.streamTextContent(), container: tl, viewport: vp, textDivs: [] });
|
||||||
|
this.R.notes.forEach(nt => { if (nt.page === n) this.drawNote(nt); });
|
||||||
|
} catch { /* a page that fails to draw stays blank */ }
|
||||||
|
p.busy = false;
|
||||||
|
}
|
||||||
|
release(n) { const p = this.pages[n - 1]; if (!p) return; p.gone = true; if (p.rendered) { p.el.replaceChildren(); p.rendered = false; } setTimeout(() => { p.gone = false; }, 0); }
|
||||||
|
onScroll() {
|
||||||
|
const top = this.stage.scrollTop + this.stage.clientHeight * 0.3; let lo = 0, hi = this.n - 1;
|
||||||
|
while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (this.pages[mid].el.offsetTop <= top) lo = mid; else hi = mid - 1; }
|
||||||
|
const n = lo + 1; this.page = n; this.R.relocated({ loc: 'pg:' + n, pct: this.n > 1 ? n / this.n : 1, label: `Page ${n} of ${this.n}` });
|
||||||
|
this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked());
|
||||||
|
}
|
||||||
|
goto(loc, term, note) {
|
||||||
|
const m = /^pg:(\d+)/.exec(String(loc)) || /^r:(\d+):/.exec(String(loc)); if (!m) return;
|
||||||
|
const n = Math.min(this.n, Math.max(1, +m[1])), el = this.pages[n - 1].el; this.stage.scrollTop = el.offsetTop - 8; this.render(n);
|
||||||
|
if (note && note.loc.startsWith('r:')) { const l = el.querySelector('.rdhls'); if (l) setTimeout(() => { el.querySelectorAll(`[data-nid="${note.id}"]`).forEach(d => { d.classList.add('flash'); setTimeout(() => d.classList.remove('flash'), 2000); }); }, 400); }
|
||||||
|
if (term) this.flashTerm = { n, term };
|
||||||
|
}
|
||||||
|
next() { this.stage.scrollBy({ top: this.stage.clientHeight * 0.9, behavior: 'smooth' }); } prev() { this.stage.scrollBy({ top: -this.stage.clientHeight * 0.9, behavior: 'smooth' }); }
|
||||||
|
seek(f) { this.goto('pg:' + Math.max(1, Math.min(this.n, Math.round(f * this.n) || 1))); }
|
||||||
|
here() { return { loc: 'pg:' + (this.page || 1), page: this.page || 1, text: '' }; }
|
||||||
|
sameSpot(n) { return n.type === 'mark' && n.loc === 'pg:' + this.page; }
|
||||||
|
restyle(k) {
|
||||||
|
if (k === 'zoom') { this.layout(); this.pages.forEach(p => this.io.observe(p.el)); return; }
|
||||||
|
const R = this.R, t = rdTheme(R.P), dark = t === RD_THEMES.dark, sepia = t === RD_THEMES.sepia;
|
||||||
|
this.stage.style.background = t.bg; this.box.style.filter = ''; this.box.classList.toggle('pdark', dark); this.box.classList.toggle('psepia', sepia);
|
||||||
|
}
|
||||||
|
resize() { const w = this.stage.clientWidth; if (this.lastW && Math.abs(w - this.lastW) < 4) return; this.lastW = w; this.layout(); }
|
||||||
|
async toc() {
|
||||||
|
const out = [], ol = await this.pdf.getOutline().catch(() => null); if (!ol) return out;
|
||||||
|
const walk = async (items, d) => { for (const i of items) {
|
||||||
|
let page = 0; try { let dest = i.dest; if (typeof dest === 'string') dest = await this.pdf.getDestination(dest); if (Array.isArray(dest)) page = (await this.pdf.getPageIndex(dest[0])) + 1; } catch {}
|
||||||
|
if (page) out.push({ label: i.title.trim() || 'Untitled', loc: 'pg:' + page, depth: d }); if (i.items && i.items.length) await walk(i.items, d + 1); } };
|
||||||
|
await walk(ol, 0); return out;
|
||||||
|
}
|
||||||
|
async pageText(n) { if (this.texts[n] == null) { const tc = await (await this.pdf.getPage(n)).getTextContent(); this.texts[n] = tc.items.map(i => i.str).join(' ').replace(/\s+/g, ' '); } return this.texts[n]; }
|
||||||
|
async search(q, stale) {
|
||||||
|
const out = [], lq = q.toLowerCase();
|
||||||
|
for (let n = 1; n <= this.n; n++) {
|
||||||
|
if (stale()) return out; const t = await this.pageText(n), low = t.toLowerCase(); let i = -1, c = 0;
|
||||||
|
while ((i = low.indexOf(lq, i + 1)) >= 0 && c++ < 5) { out.push({ loc: 'pg:' + n, excerpt: rdExcerpt(t, i, q.length), where: 'Page ' + n }); if (out.length >= 200) return out; }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// ---- highlights are drawn as rectangles on the page; they are stored as fractions of the page size so they hold at any zoom
|
||||||
|
checkSel() {
|
||||||
|
const s = getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return;
|
||||||
|
const r = s.getRangeAt(0), host = (r.commonAncestorContainer.nodeType === 1 ? r.commonAncestorContainer : r.commonAncestorContainer.parentElement);
|
||||||
|
if (!host || !host.closest || !host.closest('.textLayer') && !host.closest('.pg')) return;
|
||||||
|
const pg = (host.closest('.pg') || r.startContainer.parentElement.closest('.pg')); if (!pg) return;
|
||||||
|
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text) return;
|
||||||
|
const n = +pg.dataset.n, box = pg.getBoundingClientRect();
|
||||||
|
let rects = [...r.getClientRects()].filter(x => x.width > 1 && x.height > 1).map(x => ({ x: (x.left - box.left) / box.width, y: (x.top - box.top) / box.height, w: x.width / box.width, h: x.height / box.height }));
|
||||||
|
rects.sort((a, b) => a.y - b.y || a.x - b.x); const merged = [];
|
||||||
|
for (const q of rects) { const l = merged[merged.length - 1]; if (l && Math.abs(l.y - q.y) < 0.006 && q.x <= l.x + l.w + 0.01) { const right = Math.max(l.x + l.w, q.x + q.w); l.x = Math.min(l.x, q.x); l.w = right - l.x; l.h = Math.max(l.h, q.h); } else merged.push({ ...q }); }
|
||||||
|
let loc = ''; for (const q of merged) { const part = [q.x, q.y, q.w, q.h].map(v => Math.round(v * 10000)).join(','); if ((loc + part).length > 2900) break; loc += (loc ? ';' : '') + part; }
|
||||||
|
this.R.showSel({ loc: `r:${n}:` + loc, text, page: n, clear: () => s.removeAllRanges() });
|
||||||
|
}
|
||||||
|
drawNote(n) {
|
||||||
|
const m = /^r:(\d+):(.+)$/.exec(n.loc || ''); if (!m || n.del) return; const p = this.pages[+m[1] - 1]; if (!p || !p.rendered) return;
|
||||||
|
const layer = p.el.querySelector('.rdhls'); if (!layer || layer.querySelector(`[data-nid="${n.id}"]`)) return;
|
||||||
|
for (const part of m[2].split(';')) { const [x, y, w, h] = part.split(',').map(v => +v / 10000); const d = document.createElement('div'); d.className = 'rdhl'; d.dataset.nid = n.id; d.style.cssText = `left:${x * 100}%;top:${y * 100}%;width:${w * 100}%;height:${h * 100}%;background:${RD_COLORS[n.color] || RD_COLORS.yellow}`; layer.appendChild(d); }
|
||||||
|
}
|
||||||
|
eraseNote(n) { this.box.querySelectorAll(`[data-nid="${n.id}"]`).forEach(d => d.remove()); }
|
||||||
|
destroy() { try { this.io && this.io.disconnect(); this.pdf && this.pdf.destroy(); } catch {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================================= DOCX (converted to HTML in the browser; shown in a frame that cannot run scripts)
|
||||||
|
class RdDocx {
|
||||||
|
constructor(R) { this.R = R; }
|
||||||
|
async convert() {
|
||||||
|
const buf = await rdFetchBook(this.R.book.id, 'docx');
|
||||||
|
return { html: (await mammoth.convertToHtml({ arrayBuffer: buf }, { styleMap: ['p[style-name="Title"] => h1.title:fresh', 'p[style-name="Subtitle"] => p.subtitle:fresh'] })).value, head: '' };
|
||||||
|
}
|
||||||
|
async open(stage, loc) {
|
||||||
|
const R = this.R, res = await this.convert();
|
||||||
|
this.f = document.createElement('iframe'); this.f.setAttribute('sandbox', 'allow-same-origin'); this.f.title = R.book.title; this.f.className = 'rdframe';
|
||||||
|
stage.appendChild(this.f);
|
||||||
|
this.f.srcdoc = `<!doctype html><html><head><meta charset="utf-8"><style id="base">body{max-width:46em;margin:0 auto;padding:1.2em 6%;overflow-wrap:break-word}table{border-collapse:collapse}td,th{border:1px solid #8888;padding:.3em .5em}h1,h2,h3{line-height:1.25}mark.rdh{border-radius:2px;color:inherit;padding:0}mark.rdh.flash{outline:2px solid #e8590c}mark.rdq{background:#ffd43b88;color:inherit}</style><style id="theme"></style>${res.head}</head><body><article>${res.html}</article></body></html>`;
|
||||||
|
await new Promise(ok => { this.f.onload = ok; });
|
||||||
|
this.d = this.f.contentDocument; this.w = this.f.contentWindow; this.root = this.d.documentElement;
|
||||||
|
this.restyle();
|
||||||
|
this.d.addEventListener('keydown', e => R.keys(e));
|
||||||
|
this.d.addEventListener('mousedown', () => R.hideSel());
|
||||||
|
const sel = () => setTimeout(() => this.checkSel(), 30);
|
||||||
|
this.d.addEventListener('mouseup', sel); this.d.addEventListener('touchend', sel); this.d.addEventListener('keyup', sel);
|
||||||
|
this.d.addEventListener('click', e => { const s = this.w.getSelection(); if (s && !s.isCollapsed) return; if (e.target.closest('a[href]')) { e.preventDefault(); const a = e.target.closest('a[href]'); if (!this.link(a)) this.d.getElementById((a.getAttribute('href') || '').replace(/^#/, ''))?.scrollIntoView(); return; } R.toggleBars(); });
|
||||||
|
this.d.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||||||
|
this.w.addEventListener('scroll', () => { if (!this.raf) this.raf = requestAnimationFrame(() => { this.raf = 0; this.report(); }); }, { passive: true });
|
||||||
|
for (const n of R.notes) this.drawNote(n);
|
||||||
|
const m = /^o:(\d+)/.exec(loc || ''); if (m) this.gotoOffset(+m[1]); this.report();
|
||||||
|
}
|
||||||
|
link() { return false; }
|
||||||
|
scroller() { return this.d.scrollingElement || this.root; }
|
||||||
|
restyle(k) {
|
||||||
|
const R = this.R, t = rdTheme(R.P); this.d.getElementById('theme').textContent = rdCss(R.P, t); R.root.style.setProperty('--rd-bg', t.bg);
|
||||||
|
if (k === 'theme') R.applyChrome();
|
||||||
|
if (this.at != null && k !== 'theme') setTimeout(() => this.gotoOffset(this.at), 50);
|
||||||
|
}
|
||||||
|
textNodes() { const w = this.d.createTreeWalker(this.d.body, NodeFilter.SHOW_TEXT), out = []; let n; while ((n = w.nextNode())) out.push(n); return out; }
|
||||||
|
offsetTo(node, off) { let total = 0; for (const t of this.textNodes()) { if (t === node) return total + off; total += t.nodeValue.length; } return total; }
|
||||||
|
nodeAt(offset) { let total = 0; const all = this.textNodes(); for (const t of all) { const len = t.nodeValue.length; if (offset <= total + len) return [t, Math.max(0, offset - total)]; total += len; } const l = all[all.length - 1]; return l ? [l, l.nodeValue.length] : [this.d.body, 0]; }
|
||||||
|
gotoOffset(o) { const [n, off] = this.nodeAt(o), r = this.d.createRange(); r.setStart(n, Math.min(off, n.nodeValue ? n.nodeValue.length : 0)); r.collapse(true); const rc = r.getBoundingClientRect(); this.scroller().scrollTop += rc.top - 24; }
|
||||||
|
topOffset() {
|
||||||
|
const cp = this.d.caretRangeFromPoint ? this.d.caretRangeFromPoint(this.w.innerWidth / 2, 16) : (this.d.caretPositionFromPoint ? (p => p && { startContainer: p.offsetNode, startOffset: p.offset })(this.d.caretPositionFromPoint(this.w.innerWidth / 2, 16)) : null);
|
||||||
|
return cp && cp.startContainer ? this.offsetTo(cp.startContainer, cp.startOffset) : 0;
|
||||||
|
}
|
||||||
|
report() { const s = this.scroller(), max = s.scrollHeight - s.clientHeight, off = this.topOffset(); this.at = off; this.R.relocated({ loc: 'o:' + off, pct: max > 0 ? Math.min(1, s.scrollTop / max) : 1, label: '' }); this.R.$('#rdbm').classList.toggle('on', !!this.R.isMarked()); }
|
||||||
|
next() { this.scroller().scrollBy({ top: this.w.innerHeight * 0.9, behavior: 'smooth' }); } prev() { this.scroller().scrollBy({ top: -this.w.innerHeight * 0.9, behavior: 'smooth' }); }
|
||||||
|
seek(f) { const s = this.scroller(); s.scrollTop = f * (s.scrollHeight - s.clientHeight); }
|
||||||
|
here() { const o = this.topOffset(), t = this.d.body.textContent.slice(o, o + 120).replace(/\s+/g, ' ').trim(); return { loc: 'o:' + o, text: t }; }
|
||||||
|
sameSpot(n) { if (n.type !== 'mark') return false; const m = /^o:(\d+)/.exec(n.loc), o = this.topOffset(); return !!m && Math.abs(+m[1] - o) < 400 && this.w.innerHeight > 0 && Math.abs(+m[1] - o) < this.d.body.textContent.length; }
|
||||||
|
async toc() { const out = []; this.d.querySelectorAll('h1,h2,h3,h4').forEach(h => { const t = h.textContent.trim(); if (!t) return; const w = this.d.createTreeWalker(h, NodeFilter.SHOW_TEXT), first = w.nextNode(); out.push({ label: t.slice(0, 120), loc: 'o:' + (first ? this.offsetTo(first, 0) : 0), depth: Math.min(3, +h.tagName[1] - 1) }); }); return out; }
|
||||||
|
async search(q, stale) {
|
||||||
|
const text = this.d.body.textContent, low = text.toLowerCase(), lq = q.toLowerCase(), out = []; let i = -1;
|
||||||
|
while ((i = low.indexOf(lq, i + 1)) >= 0 && out.length < 200) out.push({ loc: 'o:' + i, excerpt: rdExcerpt(text.replace(/\s/g, ' '), i, q.length), where: Math.round(i / Math.max(1, text.length) * 100) + '% into the document' });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
goto(loc, term, note) {
|
||||||
|
const m = /^o:(\d+)(?:-(\d+))?$/.exec(loc); if (!m) return; this.gotoOffset(+m[1]);
|
||||||
|
if (term || note) { const a = +m[1], b = m[2] ? +m[2] : a + (term ? term.length : 0); this.wrap(a, b, 'rdq flash', null); setTimeout(() => this.d.querySelectorAll('mark.rdq').forEach(k => { k.replaceWith(...k.childNodes); }), 2200); this.d.body.normalize(); }
|
||||||
|
}
|
||||||
|
checkSel() {
|
||||||
|
const s = this.w.getSelection(); if (!s || s.isCollapsed || !s.rangeCount) return; const r = s.getRangeAt(0), a = this.offsetTo(r.startContainer, r.startOffset), b = this.offsetTo(r.endContainer, r.endOffset);
|
||||||
|
const text = s.toString().replace(/\s+/g, ' ').trim(); if (!text || b <= a) return;
|
||||||
|
this.R.showSel({ loc: `o:${a}-${b}`, text, clear: () => s.removeAllRanges() });
|
||||||
|
}
|
||||||
|
wrap(a, b, cls, id, color) { // wrap the text between two offsets in <mark> elements, one per text node it touches
|
||||||
|
let total = 0; const todo = [];
|
||||||
|
for (const t of this.textNodes()) { const len = t.nodeValue.length, s = Math.max(a, total), e = Math.min(b, total + len); if (e > s) todo.push([t, s - total, e - total]); total += len; if (total >= b) break; }
|
||||||
|
for (const [t, s, e] of todo) { const mid = t.splitText(s); mid.splitText(e - s); const k = this.d.createElement('mark'); k.className = cls; if (id) { k.dataset.nid = id; k.style.background = (RD_COLORS[color] || RD_COLORS.yellow) + 'a8'; k.style.cursor = 'pointer'; k.onclick = () => { const cur = this.R.notes.find(x => x.id === id); if (cur && this.w.getSelection().isCollapsed) this.R.noteDialog(cur); }; } mid.parentNode.replaceChild(k, mid); k.appendChild(mid); }
|
||||||
|
}
|
||||||
|
drawNote(n) { const m = /^o:(\d+)-(\d+)$/.exec(n.loc || ''); if (!m || n.del || n.type === 'mark' || this.d.querySelector(`mark[data-nid="${n.id}"]`)) return; this.wrap(+m[1], +m[2], 'rdh', n.id, n.color); }
|
||||||
|
eraseNote(n) { this.d.querySelectorAll(`mark[data-nid="${n.id}"]`).forEach(k => k.replaceWith(...k.childNodes)); this.d.body.normalize(); }
|
||||||
|
resize() {}
|
||||||
|
destroy() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================================= MOBI / AZW / AZW3 (DRM-free): every chapter is joined into one page, then read like a DOCX
|
||||||
|
class RdMobi extends RdDocx {
|
||||||
|
async convert() {
|
||||||
|
const buf = new Uint8Array(await rdFetchBook(this.R.book.id, 'mobi'));
|
||||||
|
this.m = await rdMobiOpen(buf); const spine = this.m.getSpine(), css = new Set(), parts = [];
|
||||||
|
for (const ch of spine) { const c = this.m.loadChapter(ch.id); if (!c) continue; (c.css || []).forEach(x => css.add(x.href)); parts.push(`<section class="rdch" id="rdch-${esc(ch.id)}" data-ch="${esc(ch.id)}">${c.html}</section>`); }
|
||||||
|
if (!parts.length) throw new Error('This book has no readable text. It may have DRM.');
|
||||||
|
return { html: parts.join(''), head: [...css].map(h => `<link rel="stylesheet" href="${esc(h)}">`).join('') };
|
||||||
|
}
|
||||||
|
/** a link inside the book: the parser says which chapter (and where in it) it points to */
|
||||||
|
link(a) {
|
||||||
|
const href = a.getAttribute('href') || ''; if (/^(https?:|mailto:)/i.test(href)) { window.open(href, '_blank', 'noopener'); return true; }
|
||||||
|
let r; try { r = this.m.resolveHref(href); } catch {} if (!r) return false;
|
||||||
|
const sec = this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} }
|
||||||
|
if (el) { el.scrollIntoView(); return true; } return false;
|
||||||
|
}
|
||||||
|
async toc() {
|
||||||
|
const out = [], walk = (items, d) => (items || []).forEach(t => {
|
||||||
|
let loc = null; try { const r = this.m.resolveHref(t.href); const sec = r && this.d.getElementById('rdch-' + r.id); let el = sec; if (sec && r.selector) { try { el = sec.querySelector(r.selector) || sec; } catch {} } if (el) { const w = this.d.createTreeWalker(el, NodeFilter.SHOW_TEXT), first = w.nextNode(); if (first) loc = 'o:' + this.offsetTo(first, 0); } } catch {}
|
||||||
|
if (loc) out.push({ label: String(t.label || '').trim().slice(0, 120) || 'Untitled', loc, depth: d }); walk(t.children, d + 1); });
|
||||||
|
walk(this.m.getToc(), 0);
|
||||||
|
return out.length ? out : super.toc();
|
||||||
|
}
|
||||||
|
destroy() { try { this.m && this.m.destroy(); } catch {} }
|
||||||
|
}
|
||||||
+14
-3
@@ -13,15 +13,19 @@ async function remindersInit() {
|
|||||||
if (!(allowed('tasks') || allowed('calendar'))) return document.getElementById('sec-rem').remove();
|
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; }
|
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; }
|
let info; try { info = await jfetch('/api/push/key'); } catch (e) { box.innerHTML = `<div class="row"><span class="grow"><small>${esc(e.message)}</small></span></div>`; return; }
|
||||||
const reg = await navigator.serviceWorker.ready, sub = await reg.pushManager.getSubscription();
|
const reg = await Promise.race([navigator.serviceWorker.ready, new Promise(ok => setTimeout(ok, 4000))]); // (never ready: the browser refused the service worker)
|
||||||
|
if (!reg) { box.innerHTML = '<div class="row"><span class="grow"><b>Not available here</b><small style="white-space:normal">The browser did not start the background service reminders need. Reload the page, or open NE Hub over https in a regular browser.</small></span></div>'; return; }
|
||||||
|
const sub = await reg.pushManager.getSubscription();
|
||||||
const on = !!sub && Notification.permission === 'granted';
|
const on = !!sub && Notification.permission === 'granted';
|
||||||
const post = (p, body) => jfetch('/api/push/' + p, { method: 'POST', body: JSON.stringify({ tz: myTz(), ...body }) });
|
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>
|
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>
|
${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>` : ''}
|
<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>
|
${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"><b>How early</b></span><select id="remm" style="width:auto">${[5, 10, 15, 30, 60].map(m => `<option value="${m}" ${m === info.eventMins ? 'selected' : ''}>${m} min</option>`).join('')}</select></div>` : ''}
|
||||||
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>`;
|
<div class="row"><span class="grow"><small>Send a test to every device that is on</small></span><button class="btn ghost" id="remtest" style="min-height:36px">Test</button></div>
|
||||||
|
<div class="row"><span class="grow"><small>Dismiss every notification from NE Hub still showing on this device</small></span><button class="btn ghost" id="remclear" style="min-height:36px">Clear all</button></div>`;
|
||||||
const on1 = document.getElementById('remon');
|
const on1 = document.getElementById('remon');
|
||||||
on1.onchange = async () => {
|
on1.onchange = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -39,6 +43,13 @@ async function remindersInit() {
|
|||||||
} catch (e) { on1.checked = !on1.checked; toast(e.message); }
|
} 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)); };
|
const pref = (id, key, get) => { const el = document.getElementById(id); if (el) el.onchange = () => post('prefs', { [key]: get(el) }).catch(e => toast(e.message)); };
|
||||||
pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
|
pref('remmail', 'mail', e => e.checked); pref('remt', 'tasks', e => e.checked); pref('remat', 'tasksAt', e => e.value); pref('reme', 'events', e => e.checked); pref('remm', 'eventMins', e => +e.value);
|
||||||
|
document.getElementById('remclear').onclick = async () => {
|
||||||
|
try {
|
||||||
|
const list = await reg.getNotifications(); list.forEach(n => n.close());
|
||||||
|
try { navigator.clearAppBadge && await navigator.clearAppBadge(); } catch {}
|
||||||
|
toast(list.length ? 'Cleared ' + list.length + (list.length === 1 ? ' notification' : ' notifications') : 'No notifications to clear');
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
};
|
||||||
document.getElementById('remtest').onclick = () => post('test', {}).then(() => toast('Test sent')).catch(e => toast(e.message));
|
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(' · ');
|
||||||
+37
-5
@@ -25,8 +25,27 @@ async function searchMusic(term) {
|
|||||||
return items.slice(0, 16);
|
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() {
|
function viewSearch() {
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music…" autocomplete="off" autocapitalize="off">
|
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Mail, tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
|
||||||
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
<p class="sub" id="src" style="margin:8px 0 0"></p>
|
||||||
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
|
<div class="bar" id="web"><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>
|
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
|
||||||
@@ -41,7 +60,7 @@ function viewSearch() {
|
|||||||
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
|
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 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') : '✓';
|
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
|
||||||
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)}`;
|
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}${allowed('mail') && cfg.mail ? ' · Mail ' + part(r.mail, '✓') : ''}`;
|
||||||
};
|
};
|
||||||
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
|
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
|
||||||
showSources();
|
showSources();
|
||||||
@@ -55,12 +74,16 @@ function viewSearch() {
|
|||||||
const enc = encodeURIComponent, lower = term.toLowerCase();
|
const enc = encodeURIComponent, lower = term.toLowerCase();
|
||||||
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
|
||||||
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
|
||||||
const [libs, files, music, ddg, hub] = await Promise.allSettled([
|
const [libs, files, music, video, pods, books, ddg, hub, mailR] = await Promise.allSettled([
|
||||||
libsP,
|
libsP,
|
||||||
libsP.then(l => searchFiles(term, l)),
|
libsP.then(l => searchFiles(term, l)),
|
||||||
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
|
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('/api/ddg?q=' + enc(term)),
|
jfetch('/api/ddg?q=' + enc(term)),
|
||||||
jfetch('/api/search?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')),
|
||||||
]);
|
]);
|
||||||
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
if (mySeq !== seq || !location.hash.startsWith('#/search')) return;
|
||||||
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
const sec = (title, html) => html ? `<h2>${title}</h2><div class="list">${html}</div>` : '';
|
||||||
@@ -78,6 +101,10 @@ function viewSearch() {
|
|||||||
else if (m.type === 'album') musicHtml += `<a class="row" href="#/music/album/${m.ratingKey}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${px.art(m.thumb, 80)}"><span class="grow"><b>${esc(m.title)}</b><small>Album · ${esc(m.parentTitle || '')}</small></span></a>`;
|
else if (m.type === '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>`; }
|
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' ? ddg.value : {};
|
const d = ddg.status === 'fulfilled' ? ddg.value : {};
|
||||||
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
|
||||||
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
|
||||||
@@ -96,12 +123,17 @@ function viewSearch() {
|
|||||||
events: hub.status === 'fulfilled' ? { n: (h.events || []).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' },
|
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 },
|
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 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('') : '';
|
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
|
||||||
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + notes;
|
res.innerHTML = card + 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 (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
|
if (!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('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
|
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-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'; });
|
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-16
@@ -67,37 +67,44 @@ function viewSettings() {
|
|||||||
const isAdmin = ME && ME.role === 'admin';
|
const isAdmin = ME && ME.role === 'admin';
|
||||||
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
|
const tabNames = ME ? TABS.filter(t => ME.tabs.includes(t[0]) && !['home', 'settings'].includes(t[0])).map(t => t[1]).join(', ') : '';
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
|
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
|
||||||
${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
|
<div class="sgrp" id="settabs" role="tablist" aria-label="Settings sections">${[['general', 'General'], ['connections', 'Connections'], ['security', 'Security'], ...(isAdmin ? [['admin', 'Admin']] : [])].map(([k, l]) => `<a href="#" role="tab" data-g="${k}">${l}</a>`).join('')}</div>
|
||||||
${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
<div class="sgroup" data-g="general">${ME ? `<h2>Signed in as</h2><div class="list"><div class="row"><span class="grow"><b>${esc(ME.name)}</b><small style="white-space:normal">${isAdmin ? 'Admin · can use everything and manage people' : 'Can use: ' + esc(tabNames || 'Home and Settings only')}</small></span></div></div>` : ''}
|
||||||
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
|
<div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
||||||
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
||||||
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
||||||
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2>${installHtml()}</div>
|
||||||
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
<div class="sgroup" data-g="connections"><div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||||
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
|
|
||||||
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
|
||||||
<div id="sec-conn"><h2>Connections</h2><div class="list">
|
|
||||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||||
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
||||||
</div>
|
</div>
|
||||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
|
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
|
||||||
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
|
<div id="sec-mail"><h2>Mail</h2><div class="list" id="mailst"><div class="row"><span class="grow"><small>Checking…</small></span></div></div></div>
|
||||||
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Saved on your server (encrypted) and shared with all your devices. Leave blank to skip. Use the copy button on the Audiobooks tab to paste them into the site's sign-in form.</p>
|
<div id="sec-audio"><h2>Audiobooks sign-in</h2><p class="sub">Your OpenAudible username and password (the server's audiobook library login). Saved on your server (encrypted) and shared with all your devices. The Audiobooks tab uses them to load your library and play books.</p>
|
||||||
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
<div class="card" style="max-width:none"><label for="ab-u">Username</label>
|
||||||
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
|
||||||
<label for="ab-p">Password</label>
|
<label for="ab-p">Password</label>
|
||||||
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
|
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
|
||||||
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
|
</div></div>
|
||||||
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
|
<div class="sgroup" data-g="security"><h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||||
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
|
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
||||||
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div>${remindersHtml()}<h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
|
<h2>Recovery codes</h2><div class="list"><div class="row"><span class="grow"><b id="rcn">Checking…</b><small style="white-space:normal">One-time codes that let you add a new passkey if you lose every device. Keep them somewhere safe (a password manager or on paper).</small></span><button class="btn ghost" id="rcgen">Make codes</button></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="sgroup" data-g="admin">${isAdmin ? `<h2>People</h2><div class="list" id="people"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||||
|
<div class="bar"><button class="btn" id="addperson">Add a person</button></div>
|
||||||
|
<h2>Health</h2><div class="list" id="health"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
|
<h2>Storage</h2><div class="list" id="stor"><div class="row"><span class="grow"><small>Checking…</small></span></div></div>
|
||||||
|
<div class="bar"><a class="btn ghost" href="/api/backup" download>Download a backup</a></div><p class="sub">A .tar.gz of everything in /data (accounts, passkeys, tasks, events, settings, Signal history). Keep it private: it holds the session secret and encrypted sign-in tokens. Restore by unpacking it into the data volume.</p>` : ''}
|
||||||
|
${isAdmin ? `<h2>Activity</h2><div class="list" id="audit"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>` : ''}
|
||||||
|
</div></div>`;
|
||||||
remindersInit();
|
remindersInit();
|
||||||
|
installInit();
|
||||||
// only show what this person may use
|
// only show what this person may use
|
||||||
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
|
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
|
||||||
hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
hide(!((allowed('mail') && cfg.mail) || cfg.mailAdmin), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
|
||||||
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
|
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
|
||||||
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
|
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
|
||||||
|
settingsTabs();
|
||||||
if ($('#sga-s')) {
|
if ($('#sga-s')) {
|
||||||
const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
|
const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
|
||||||
if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
|
if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
|
||||||
@@ -122,6 +129,18 @@ function viewSettings() {
|
|||||||
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
|
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
|
||||||
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
|
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
|
||||||
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
|
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); }
|
||||||
|
if (isAdmin) jfetch('/api/health').then(h => {
|
||||||
|
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${bad ? '⚠ ' : '✓ '}${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||||
|
const gb = n => (n / 1e9).toFixed(1) + ' GB', days = t => Math.floor((Date.now() - t) / 864e5);
|
||||||
|
const up = h.uptime > 86400 ? Math.floor(h.uptime / 86400) + ' days' : h.uptime > 3600 ? Math.floor(h.uptime / 3600) + ' hours' : Math.max(1, Math.floor(h.uptime / 60)) + ' min';
|
||||||
|
$('#health').innerHTML =
|
||||||
|
(h.plex.configured ? row('Plex', h.plex.ok ? 'Answering in ' + h.plex.ms + ' ms' + (h.plex.user ? ' · using ' + esc(h.plex.user) : '') : esc(h.plex.error || 'Not answering'), !h.plex.ok) : row('Plex', 'Not set up', false))
|
||||||
|
+ (h.mail.length ? h.mail.map(m => row('Mail: ' + esc(m.address), m.ok ? 'Connected (' + m.ms + ' ms)' : esc(m.error || 'Could not connect'), !m.ok)).join('') : row('Mail', 'No account connected', false))
|
||||||
|
+ (h.disk ? row('Disk', gb(h.disk.free) + ' free of ' + gb(h.disk.total), h.disk.free / h.disk.total < .1 || h.disk.free < 2e9) : '')
|
||||||
|
+ row('Last backup download', h.backup ? days(h.backup.t) + ' day' + (days(h.backup.t) === 1 ? '' : 's') + ' ago (by ' + esc(h.backup.who || '?') + ')' : 'Never. Use Download a backup below.', !h.backup || days(h.backup.t) > 30)
|
||||||
|
+ (h.transcripts && h.transcripts.enabled ? row('Transcripts', `${h.transcripts.saved} saved · ${h.transcripts.working ? 'working on ' + h.transcripts.working : 'idle'}${h.transcripts.failed ? ' · ' + h.transcripts.failed + ' failed recently' : ''} · ${h.transcripts.auto ? 'automatic for the newest ' + h.transcripts.keep + ' of each show' : 'automatic transcribing is off'}`, false) : '')
|
||||||
|
+ row('Server', 'Up ' + up + ' · Node ' + esc(h.node) + ' · lyrics ' + (h.lyrics ? 'on' : 'off') + ' · Signal ' + (h.signal ? 'on' : 'off'), false);
|
||||||
|
}).catch(() => { const e = $('#health'); if (e) e.innerHTML = '<div class="row"><small>Could not read the health report</small></div>'; });
|
||||||
if (isAdmin) jfetch('/api/system').then(i => {
|
if (isAdmin) jfetch('/api/system').then(i => {
|
||||||
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
|
const anon = i.source && i.source.kind === 'anonymous', ok = i.mounted !== false && !anon && i.writable;
|
||||||
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
const row = (t, d, bad) => `<div class="row"><span class="grow"><b>${t}</b><small style="white-space:normal;${bad ? 'color:var(--danger)' : ''}">${d}</small></span></div>`;
|
||||||
@@ -170,6 +189,38 @@ function viewSettings() {
|
|||||||
el.querySelector('#sx').onclick = closeSheet;
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
} catch (e) { toast(e.message); }
|
} catch (e) { toast(e.message); }
|
||||||
};
|
};
|
||||||
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
|
$('#hubout').onclick = async () => { try { mcClear(); for (const k of Object.keys(localStorage)) if (k.startsWith('dc:')) localStorage.removeItem(k); } catch {} await authPost('/api/auth/logout'); location.replace('/login'); };
|
||||||
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
|
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Install: the browser's own prompt where it offers one (Chrome, Edge, Android); iPhone/iPad have to use Share
|
||||||
|
let installEvt = null;
|
||||||
|
addEventListener('beforeinstallprompt', e => { e.preventDefault(); installEvt = e; const b = document.getElementById('install-btn'); if (b) installInit(); });
|
||||||
|
addEventListener('appinstalled', () => { installEvt = null; const b = document.getElementById('install-box'); if (b) b.innerHTML = '<p class="sub">NE Hub is installed on this device.</p>'; });
|
||||||
|
const isInstalled = () => matchMedia('(display-mode: standalone)').matches || navigator.standalone === true;
|
||||||
|
function installHtml() {
|
||||||
|
const body = isInstalled() ? '<p class="sub">NE Hub is installed on this device.</p>'
|
||||||
|
: '<p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p><div class="bar"><button class="btn" id="install-btn" hidden>Install NE Hub</button></div>';
|
||||||
|
return '<div id="install-box">' + body + '</div>';
|
||||||
|
}
|
||||||
|
function installInit() {
|
||||||
|
const b = document.getElementById('install-btn'); if (!b || !installEvt) return;
|
||||||
|
b.hidden = false;
|
||||||
|
b.onclick = async () => { const ev = installEvt; installEvt = null; b.hidden = true; try { await ev.prompt(); } catch {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings sections: one group at a time (General, Connections, Security, Admin), the last one is remembered on this device
|
||||||
|
function settingsTabs() {
|
||||||
|
const tabs = [...document.querySelectorAll('#settabs a')], groups = [...document.querySelectorAll('.sgroup')];
|
||||||
|
const empty = g => !g.textContent.trim() && !g.querySelector('input, button');
|
||||||
|
for (const g of groups) if (empty(g)) { g.remove(); const a = tabs.find(x => x.dataset.g === g.dataset.g); if (a) a.remove(); }
|
||||||
|
const left = [...document.querySelectorAll('#settabs a')]; if (left.length < 2) { const b = $('#settabs'); if (b) b.hidden = true; }
|
||||||
|
const show = k => {
|
||||||
|
if (!left.some(a => a.dataset.g === k)) k = left[0] && left[0].dataset.g;
|
||||||
|
left.forEach(a => { a.classList.toggle('on', a.dataset.g === k); a.setAttribute('aria-selected', a.dataset.g === k); });
|
||||||
|
document.querySelectorAll('.sgroup').forEach(g => { g.hidden = g.dataset.g !== k; });
|
||||||
|
try { store.set('settings_tab', k); } catch {}
|
||||||
|
};
|
||||||
|
left.forEach(a => a.onclick = e => { e.preventDefault(); show(a.dataset.g); $('#view').scrollTop = 0; });
|
||||||
|
show(store.get('settings_tab', 'general'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
'use strict';
|
||||||
|
// Files shared into NE Hub from other apps (the manifest's share target; sw.js keeps them): choose a Seafile folder, then upload.
|
||||||
|
async function shareCheck() {
|
||||||
|
if (!('caches' in window)) return;
|
||||||
|
let c, keys;
|
||||||
|
try { c = await caches.open('hub-share'); keys = await c.keys(); } catch { return; }
|
||||||
|
if (!keys.length) return;
|
||||||
|
const clear = async () => { for (const k of await c.keys()) await c.delete(k); };
|
||||||
|
if (!allowed('files')) { await clear(); toast('You do not have the Files tab, so shared files were discarded'); return; }
|
||||||
|
const files = [];
|
||||||
|
for (const k of keys) { const r = await c.match(k); if (r) files.push(new File([await r.blob()], decodeURIComponent(r.headers.get('x-name') || 'file'), { type: r.headers.get('content-type') || '' })); }
|
||||||
|
const loc = store.get('office_loc');
|
||||||
|
const dst = await chooseFolder('Save ' + (files.length === 1 ? '“' + files[0].name + '”' : files.length + ' shared files') + ' to…', loc ? { repo: loc.repo, repoName: loc.repoName || '', dir: loc.dir } : null, { okLabel: 'Upload here', newFolder: true });
|
||||||
|
await clear();
|
||||||
|
if (!dst) return toast('Shared files discarded');
|
||||||
|
enqueueUploads(dst.repo, dst.dir, files.map(file => ({ file })));
|
||||||
|
}
|
||||||
+16
-2
@@ -4,10 +4,24 @@
|
|||||||
// ---- Offline notice
|
// ---- Offline notice
|
||||||
const bar = document.createElement('div');
|
const bar = document.createElement('div');
|
||||||
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
|
bar.id = 'offline'; bar.setAttribute('role', 'status'); bar.hidden = true;
|
||||||
bar.textContent = 'You are offline. Showing what was saved on this device; changes will not sync until you are back online.';
|
bar.innerHTML = 'You are offline. <button type="button" id="offinfo" style="background:none;border:0;color:inherit;text-decoration:underline;font:inherit;cursor:pointer;padding:0 0 0 6px;min-height:0">What still works?</button>';
|
||||||
document.body.appendChild(bar);
|
document.body.appendChild(bar);
|
||||||
const sync = () => { bar.hidden = navigator.onLine; };
|
const sync = () => { bar.hidden = navigator.onLine; };
|
||||||
addEventListener('online', sync); addEventListener('offline', sync); sync();
|
bar.querySelector('#offinfo').onclick = () => {
|
||||||
|
const el = openSheet('You are offline', 'The app opens, and these still work from what this device has saved:', `<ul style="line-height:1.7;padding-left:18px">
|
||||||
|
<li><b>Mail</b>: your last message lists and every message you have opened. Deleting and replying wait until you are back online.</li>
|
||||||
|
<li><b>Files</b>: folders you have opened, and the saved list of documents.</li>
|
||||||
|
<li><b>Calendar, tasks, reminders</b>: what was loaded last time.</li>
|
||||||
|
<li><b>Music, video, Signal</b>: need the connection (the song or movie streams from your server).</li></ul>
|
||||||
|
<p class="sub">Nothing is lost: it all catches up when you reconnect.</p><div class="bar"><button class="btn ghost" id="offx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||||
|
el.querySelector('#offx').onclick = closeSheet;
|
||||||
|
};
|
||||||
|
// Back online, or coming back to the app after a while away: refresh what is on screen quietly.
|
||||||
|
let hiddenAt = 0;
|
||||||
|
const refresh = () => { try { if (typeof mailPoll === 'function') mailPoll(); if (location.hash.startsWith('#/mail') && typeof mailLoadList === 'function' && typeof mail !== 'undefined' && mail.st && $('#mrows') && !mail.msg) mailLoadList(true); else if (/^#\/(home|planner|calendar|tasks)?$/.test(location.hash) && typeof route === 'function' && !document.getElementById('sheet')) route(); } catch {} };
|
||||||
|
addEventListener('online', () => { sync(); refresh(); });
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) hiddenAt = Date.now(); else if (hiddenAt && Date.now() - hiddenAt > 60e3 && navigator.onLine) { hiddenAt = 0; refresh(); } });
|
||||||
|
addEventListener('offline', sync); sync();
|
||||||
|
|
||||||
// ---- Skip link and landmarks
|
// ---- Skip link and landmarks
|
||||||
const skip = document.createElement('a');
|
const skip = document.createElement('a');
|
||||||
|
|||||||
+20
-9
@@ -28,9 +28,10 @@ function sgChime() {
|
|||||||
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
|
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
|
||||||
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
|
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
|
||||||
}
|
}
|
||||||
function sgBadgeEverywhere() {
|
function sgBadgeEverywhere() { // Signal chats, unread mail and tasks due: one number on the tab title and the installed app's icon
|
||||||
document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
|
const n = (sg.unread || 0) + (typeof mailUnread === 'number' ? mailUnread : 0) + (typeof taskDue === 'number' ? taskDue : 0);
|
||||||
try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
|
document.title = (n ? '(' + (n > 99 ? '99+' : n) + ') ' : '') + 'NE Hub';
|
||||||
|
try { if (navigator.setAppBadge) { n ? navigator.setAppBadge(n) : navigator.clearAppBadge(); } } catch {}
|
||||||
}
|
}
|
||||||
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
|
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
|
||||||
const a = sgAlerts();
|
const a = sgAlerts();
|
||||||
@@ -153,15 +154,25 @@ function sgWireComposer() {
|
|||||||
sgq('#sgat').onclick = () => sgq('#sgfile').click();
|
sgq('#sgat').onclick = () => sgq('#sgfile').click();
|
||||||
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
|
sgq('#sgfile').onchange = e => { sg.tray.push(...e.target.files); e.target.value = ''; sgDrawTray(); };
|
||||||
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
|
ta.addEventListener('paste', e => { const fs = [...(e.clipboardData && e.clipboardData.files || [])]; if (fs.length) { sg.tray.push(...fs); sgDrawTray(); } });
|
||||||
|
// The message shows up in the chat at once, dimmed as "Sending…"; Signal takes a few seconds to take it, and that happens in the background.
|
||||||
|
// If it fails, the text (and files) go back into the box so nothing is lost.
|
||||||
sgq('#sgform').onsubmit = async e => {
|
sgq('#sgform').onsubmit = async e => {
|
||||||
e.preventDefault(); const text = ta.value, files = [...sg.tray]; if (!text.trim() && !files.length) return;
|
e.preventDefault(); const text = ta.value, files = [...sg.tray], reply = sg.reply, to = sg.cur; if (!text.trim() && !files.length) return;
|
||||||
const btn = sgq('#sgsend'); btn.disabled = true;
|
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); ta.focus();
|
||||||
|
const list = sgq('#sgmsgs'), tmp = document.createElement('div');
|
||||||
|
tmp.className = 'sgb out sgpending'; tmp.style.opacity = '.65';
|
||||||
|
tmp.innerHTML = `${esc(text) || 'Attachment'}<div class="sgmeta"><small>Sending…</small></div>`;
|
||||||
|
if (list) { list.appendChild(tmp); list.scrollTop = list.scrollHeight; }
|
||||||
|
sg.sendQ = (sg.sendQ || Promise.resolve()).then(async () => { // one at a time, so quick messages arrive in the order you wrote them
|
||||||
try {
|
try {
|
||||||
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
|
const attachments = []; for (const f of files) attachments.push({ name: f.name, type: f.type || 'application/octet-stream', data: await sgB64(f) });
|
||||||
await sgPost('/send', { to: sg.cur, text, attachments, quote: sg.reply ? sg.reply.ts : undefined });
|
await sgPost('/send', { to, text, attachments, quote: reply ? reply.ts : undefined });
|
||||||
ta.value = ''; grow(); sg.tray = []; sg.reply = null; sgDrawTray(); sgDrawReply(); await sgRefresh(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
|
await sgRefresh(); tmp.remove(); const el = sgq('#sgmsgs'); if (el) el.scrollTop = el.scrollHeight;
|
||||||
} catch (err) { toast(err.message); }
|
} catch (err) {
|
||||||
btn.disabled = false; ta.focus();
|
tmp.remove(); toast(err.message || 'Could not send');
|
||||||
|
if (sg.cur === to && !ta.value) { ta.value = text; grow(); sg.tray = files; sg.reply = reply; sgDrawTray(); sgDrawReply(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// ---- editing contacts and removing chats
|
// ---- editing contacts and removing chats
|
||||||
|
|||||||
+329
-3
@@ -32,7 +32,7 @@ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; color: v
|
|||||||
.sub { color: var(--muted); margin: 0 0 8px; }
|
.sub { color: var(--muted); margin: 0 0 8px; }
|
||||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
.wrap { max-width: 1100px; margin: 0 auto; }
|
||||||
|
|
||||||
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr); }
|
.grid { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px;
|
.tile { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 16px;
|
||||||
display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; }
|
display: flex; flex-direction: column; gap: 10px; min-height: 108px; text-align: left; }
|
||||||
.tile:active, .row:active { background: var(--panel2); }
|
.tile:active, .row:active { background: var(--panel2); }
|
||||||
@@ -452,8 +452,8 @@ body.lyropen { overflow: hidden; }
|
|||||||
.ll.on { opacity: 1; transform: scale(1.04); }
|
.ll.on { opacity: 1; transform: scale(1.04); }
|
||||||
.lgap { height: 24px; }
|
.lgap { height: 24px; }
|
||||||
.ll .lt { color: inherit; }
|
.ll .lt { color: inherit; }
|
||||||
.ll.on .lt { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
.ll.on .lt { color: var(--accent); } /* (a line-synced song shows the whole current line; only real word timing sweeps across words) */
|
||||||
.ll.on .lt:has(.w) { background: none; color: inherit; }
|
.ll.on .lt:has(.w) { color: inherit; }
|
||||||
.ll .w { color: inherit; }
|
.ll .w { color: inherit; }
|
||||||
.ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; }
|
.ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; }
|
||||||
.ll.on .w.sung { background: none; color: var(--accent); }
|
.ll.on .w.sung { background: none; color: var(--accent); }
|
||||||
@@ -576,3 +576,329 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
/* Text preview and editing */
|
/* Text preview and editing */
|
||||||
.ptxt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60dvh; overflow: auto; background: var(--panel2); border-radius: 12px; padding: 12px 14px; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: 8px 0; }
|
.ptxt { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 60dvh; overflow: auto; background: var(--panel2); border-radius: 12px; padding: 12px 14px; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; margin: 8px 0; }
|
||||||
.ptxt-edit { width: 100%; height: 55dvh; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); resize: vertical; }
|
.ptxt-edit { width: 100%; height: 55dvh; font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); resize: vertical; }
|
||||||
|
|
||||||
|
/* Movies & TV */
|
||||||
|
.vgrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 14px; margin-top: 10px; }
|
||||||
|
.vrow { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 6px; }
|
||||||
|
.vcard { display: block; min-width: 0; color: inherit; }
|
||||||
|
.vcard.wide { flex: none; width: 250px; }
|
||||||
|
.vcard b, .vcard small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vcard b { margin-top: 6px; font-size: 14px; } .vcard small { color: var(--muted); }
|
||||||
|
.vimg { position: relative; display: block; width: 100%; border-radius: 10px; overflow: hidden; background: var(--panel2); }
|
||||||
|
.vimg img { width: 100%; height: 100%; object-fit: cover; display: block; position: absolute; inset: 0; }
|
||||||
|
.vimg > i:not(.vprog) { position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted); }
|
||||||
|
.vseen { position: absolute; top: 6px; right: 6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; }
|
||||||
|
.vseen svg { width: 14px; height: 14px; }
|
||||||
|
.vprog { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(0, 0, 0, .5); display: block; }
|
||||||
|
.vprog b { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.vdetail .vhead { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.vpost { width: 150px; flex: none; }
|
||||||
|
.vinfo { flex: 1; min-width: 220px; }
|
||||||
|
.vsum { line-height: 1.6; max-width: 70ch; }
|
||||||
|
.vhero { position: relative; }
|
||||||
|
.vbg { position: absolute; inset: -8px -16px auto -16px; width: calc(100% + 32px); height: 220px; object-fit: cover; opacity: .18; z-index: -1; mask-image: linear-gradient(#000, transparent); -webkit-mask-image: linear-gradient(#000, transparent); }
|
||||||
|
.vep { align-items: flex-start; }
|
||||||
|
.vwrap { max-width: 1280px; margin: 0 auto; }
|
||||||
|
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vnext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; padding: 12px 14px; border-radius: 12px; background: var(--panel2); }
|
||||||
|
.vnext b { flex: 1; min-width: 160px; }
|
||||||
|
@media (min-width: 768px) { .vgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .vpost { width: 190px; } }
|
||||||
|
|
||||||
|
.vrow { scrollbar-width: thin; }
|
||||||
|
.vrow::-webkit-scrollbar { height: 6px; } .vrow::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
|
||||||
|
|
||||||
|
|
||||||
|
/* Movies & TV player controls */
|
||||||
|
.vstage { position: relative; background: #000; border-radius: 12px; overflow: hidden; user-select: none; -webkit-user-select: none; }
|
||||||
|
.vstage video { width: 100%; max-height: 78dvh; display: block; background: #000; }
|
||||||
|
.vstage.vhide { cursor: none; }
|
||||||
|
.vtop, .vctl { position: absolute; left: 0; right: 0; z-index: 3; color: #fff; transition: opacity .25s; }
|
||||||
|
.vtop { top: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px 28px; background: linear-gradient(rgba(0, 0, 0, .7), transparent); }
|
||||||
|
.vctl { bottom: 0; padding: 26px 12px 8px; background: linear-gradient(transparent, rgba(0, 0, 0, .8)); }
|
||||||
|
.vstage.vhide .vtop, .vstage.vhide .vctl { opacity: 0; pointer-events: none; }
|
||||||
|
.vtop .btn { background: rgba(255, 255, 255, .15); color: #fff; border: 0; min-height: 38px; }
|
||||||
|
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vseek { display: flex; align-items: center; gap: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||||
|
.vseek input { flex: 1; margin: 0; }
|
||||||
|
.vctl input[type=range] { -webkit-appearance: none; appearance: none; height: 22px; background: transparent; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.vctl input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
|
||||||
|
.vctl input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; margin-top: -5px; }
|
||||||
|
.vctl input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
|
||||||
|
.vctl input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
|
||||||
|
.vrow2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
|
||||||
|
.vleft, .vright { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.vb { border: 0; background: transparent; color: #fff; min-width: 44px; height: 44px; border-radius: 10px; display: inline-grid; place-items: center; cursor: pointer; font: inherit; }
|
||||||
|
.vb:hover { background: rgba(255, 255, 255, .18); }
|
||||||
|
.vb:disabled { opacity: .35; cursor: default; }
|
||||||
|
.vb svg { width: 24px; height: 24px; }
|
||||||
|
.vb.big { width: 54px; height: 54px; background: rgba(255, 255, 255, .2); border-radius: 50%; } .vb.big svg { width: 28px; height: 28px; }
|
||||||
|
.vb.txt { padding: 0 10px; font-size: 14px; }
|
||||||
|
.vskip { position: relative; display: grid; place-items: center; } .vskip em { position: absolute; font-style: normal; font-size: 9px; font-weight: 700; top: 8px; }
|
||||||
|
.vright input[type=range] { width: 80px; }
|
||||||
|
.vflash { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(.9); z-index: 4; padding: 10px 18px; border-radius: 30px; background: rgba(0, 0, 0, .65); color: #fff; font-size: 22px; opacity: 0; pointer-events: none; }
|
||||||
|
.vflash.on { animation: vflash .7s ease-out; } @keyframes vflash { 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 70% { opacity: 1; } 100% { opacity: 0; transform: translate(-50%, -60%) scale(1.05); } }
|
||||||
|
.vspin { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 3; pointer-events: none; }
|
||||||
|
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
||||||
|
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
||||||
|
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
||||||
|
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
|
||||||
|
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
|
||||||
|
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
||||||
|
.vstage:fullscreen video { height: 100%; }
|
||||||
|
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
||||||
|
|
||||||
|
.vnav { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.vcrumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
|
||||||
|
.vcrumbs a { color: var(--muted); } .vcrumbs a:hover { color: var(--text); } .vcrumbs i { font-style: normal; opacity: .6; }
|
||||||
|
.vnav .sgrp { margin-left: auto; }
|
||||||
|
.vtop .vcrumbs { color: rgba(255, 255, 255, .8); } .vtop .vcrumbs a { color: rgba(255, 255, 255, .8); }
|
||||||
|
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
|
||||||
|
|
||||||
|
/* Video mini player */
|
||||||
|
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 340px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
|
||||||
|
@media (min-width: 768px) { #vmini { bottom: 84px; } }
|
||||||
|
#vmini[hidden] { display: none; }
|
||||||
|
.vmslot { cursor: pointer; } .vmslot video { width: 100%; display: block; aspect-ratio: 16 / 9; background: #000; }
|
||||||
|
|
||||||
|
/* Office */
|
||||||
|
.ocreate { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 4px; }
|
||||||
|
.octile { display: flex; align-items: center; gap: 12px; text-align: left; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--panel); min-width: 0; }
|
||||||
|
.octile:hover { border-color: var(--c, var(--accent)); }
|
||||||
|
.octile b, .octile small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .octile small { color: var(--muted); }
|
||||||
|
.octile > span:last-child { min-width: 0; }
|
||||||
|
.ofic { position: relative; } .ofic em { position: absolute; bottom: 2px; font-style: normal; font-size: 8px; font-weight: 700; letter-spacing: .02em; color: #fff; }
|
||||||
|
.ofic svg { margin-top: -4px; }
|
||||||
|
.ofilters { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 0; }
|
||||||
|
.ofilters input[type=search] { flex: 1; min-width: 180px; margin: 0; }
|
||||||
|
.orecent { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; }
|
||||||
|
.octile.rc { padding: 10px 12px; }
|
||||||
|
.orow .rmain { min-width: 0; }
|
||||||
|
#ofwrap.dropt { outline: 2px dashed var(--accent); outline-offset: -6px; border-radius: 14px; }
|
||||||
|
|
||||||
|
.vmctl { background: var(--panel); color: var(--text); padding: 6px 10px 8px; }
|
||||||
|
.vmhead { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.vmtitle { flex: 1; font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.vmctl input[type=range] { width: 100%; margin: 2px 0; accent-color: var(--accent); cursor: pointer; }
|
||||||
|
.vmrow { display: flex; align-items: center; justify-content: space-between; gap: 6px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.vmmid { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.vmb { border: 0; background: transparent; color: var(--text); min-width: 34px; height: 34px; border-radius: 8px; display: inline-grid; place-items: center; cursor: pointer; }
|
||||||
|
.vmb:hover { background: var(--panel2); } .vmb:disabled { opacity: .35; cursor: default; } .vmb[hidden] { display: none; }
|
||||||
|
.vmb svg { width: 20px; height: 20px; } .vmb.big { width: 40px; height: 40px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } .vmb.big svg { width: 22px; height: 22px; }
|
||||||
|
.vmb .vskip em { font-size: 8px; top: 6px; }
|
||||||
|
|
||||||
|
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
||||||
|
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
|
||||||
|
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
|
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
||||||
|
.vtop .vtc { flex: 0 1 auto; }
|
||||||
|
.mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; }
|
||||||
|
@media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } }
|
||||||
|
|
||||||
|
/* calendar colors */
|
||||||
|
.cdot{width:22px;height:22px;min-height:0;padding:0;border-radius:50%;border:2px solid var(--panel2);box-shadow:0 0 0 1px var(--line);flex:none;cursor:pointer}
|
||||||
|
.swatches{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 4px}
|
||||||
|
.sw{width:30px;height:30px;min-height:0;padding:0;border-radius:50%;border:2px solid transparent;cursor:pointer;position:relative;box-shadow:0 0 0 1px var(--line)}
|
||||||
|
.sw.on{border-color:var(--text)}
|
||||||
|
.sw.custom{background:conic-gradient(#d9453a,#e5a00d,#2f9e6e,#4f7cff,#8a5cf0,#d9453a);overflow:hidden}
|
||||||
|
.sw.custom input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}
|
||||||
|
|
||||||
|
/* Plex details editor: image choices */
|
||||||
|
.vimgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin:6px 0}
|
||||||
|
.vimgs.arts{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
||||||
|
.vpick{padding:0;min-height:0;border:2px solid transparent;border-radius:8px;overflow:hidden;background:var(--panel2);cursor:pointer;line-height:0}
|
||||||
|
.vpick.on{border-color:var(--accent)}
|
||||||
|
.vpick img{width:100%;height:auto;display:block}
|
||||||
|
|
||||||
|
/* Home: header with the quick actions beside it, today's cards in two columns on wide screens, apps as a compact launcher */
|
||||||
|
.homehead { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.homehead h1 { margin: 0; }
|
||||||
|
.homehead .sub { margin: 2px 0 0; }
|
||||||
|
.homeact { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.homeact .btn { margin: 0; }
|
||||||
|
.home .searchbar { margin: 14px 0 4px; }
|
||||||
|
.homecols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 0 24px; align-items: start; }
|
||||||
|
.hcol:empty, .hcol > :empty { display: none; }
|
||||||
|
.home .homeapps h2:first-child { margin-top: 24px; }
|
||||||
|
.home .tile { flex-direction: row; align-items: center; gap: 12px; min-height: 0; padding: 12px 14px; border-radius: 14px; }
|
||||||
|
.home .tile .ic { flex: none; width: 36px; height: 36px; border-radius: 10px; }
|
||||||
|
.home .tile b, .home .tile small { display: block; line-height: 1.25; }
|
||||||
|
.home .tile small { font-size: 12px; word-break: normal; overflow-wrap: anywhere; }
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.homecols { grid-template-columns: 1.2fr 1fr; }
|
||||||
|
.home .grid { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) { .homeact .btn small { display: none; } }
|
||||||
|
@media (max-width: 767px) { /* phone: the compact app launcher comes before the cards (the bottom tabs reach the same places) */
|
||||||
|
.home { display: flex; flex-direction: column; }
|
||||||
|
.homecols { order: 5; }
|
||||||
|
.homeapps { order: 4; }
|
||||||
|
.homeapps h2:first-child { margin-top: 18px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#settabs { margin: 12px 0 0; }
|
||||||
|
.sgroup[hidden] { display: none; }
|
||||||
|
.sgroup > h2:first-child, .sgroup > div:first-child > h2:first-child { margin-top: 20px; }
|
||||||
|
.home .tile small { overflow-wrap: break-word; }
|
||||||
|
.home .tile > span:not(.ic) { min-width: 0; }
|
||||||
|
.home .tile { display: grid; grid-template-columns: auto minmax(0, 1fr); grid-template-rows: auto auto; column-gap: 12px; row-gap: 0; align-content: center; }
|
||||||
|
.home .tile .ic { grid-row: 1 / span 2; align-self: center; }
|
||||||
|
.home .tile b { align-self: end; }
|
||||||
|
.home .tile small { align-self: start; }
|
||||||
|
@media (max-width: 560px) { .homehead { flex-wrap: nowrap; align-items: center; } #qpal { display: none; } } /* the command palette is a keyboard thing: Quick add sits beside the greeting on a phone */
|
||||||
|
@media (max-width: 560px) { .homehead h1 { font-size: 24px; } .homeact .btn { white-space: nowrap; } #settabs { display: flex; } #settabs a { flex: 1; justify-content: center; padding: 6px 8px; } }
|
||||||
|
|
||||||
|
/* Home never scrolls sideways: wide content shrinks or wraps instead */
|
||||||
|
.home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; }
|
||||||
|
#view { overflow-x: hidden; }
|
||||||
|
|
||||||
|
/* Movies & TV: remove from Continue watching, filters */
|
||||||
|
.vdeckc { position: relative; flex: none; }
|
||||||
|
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
|
||||||
|
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
|
||||||
|
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
|
||||||
|
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
|
||||||
|
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
|
||||||
|
.vdeckc .vcard.wide { width: 250px; }
|
||||||
|
|
||||||
|
/* Movies & TV polish */
|
||||||
|
.vimg { box-shadow: 0 2px 8px rgba(0, 0, 0, .18); transition: transform .18s ease, box-shadow .18s ease; }
|
||||||
|
.vimg img { animation: vfade .35s ease both; }
|
||||||
|
@keyframes vfade { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
@media (hover: hover) {
|
||||||
|
.vcard:hover .vimg, .vcard:focus-visible .vimg { transform: translateY(-3px) scale(1.025); box-shadow: 0 10px 24px rgba(0, 0, 0, .32); }
|
||||||
|
.vcard.wide .vimg::after, .vcard .vimg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(transparent 55%, rgba(0, 0, 0, .45)); opacity: 0; transition: opacity .18s; pointer-events: none; }
|
||||||
|
.vcard:hover .vimg::after { opacity: 1; }
|
||||||
|
.vx { opacity: 0; transition: opacity .15s; } .vdeckc:hover .vx, .vx:focus-visible { opacity: .95; }
|
||||||
|
}
|
||||||
|
.vrow { scroll-snap-type: x proximity; padding: 4px 2px 10px; margin: 0 -2px; }
|
||||||
|
.vrow > * { scroll-snap-align: start; }
|
||||||
|
.vcard b { font-weight: 650; letter-spacing: -.005em; }
|
||||||
|
.vprog { height: 5px; border-radius: 0 3px 3px 0; overflow: hidden; } .vprog b { border-radius: 0 3px 3px 0; box-shadow: 0 0 6px var(--accent); }
|
||||||
|
#view h2 { letter-spacing: -.01em; }
|
||||||
|
.vclear { vertical-align: middle; font-size: 13px; }
|
||||||
|
.vfilt select { border-radius: 10px; border: 1px solid var(--line); background: var(--panel); padding: 0 10px; color: var(--text); }
|
||||||
|
.vsk { aspect-ratio: 2/3; border-radius: 10px; background: linear-gradient(100deg, var(--panel2) 30%, var(--panel) 50%, var(--panel2) 70%); background-size: 300% 100%; animation: vshim 1.3s linear infinite; }
|
||||||
|
@keyframes vshim { from { background-position: 100% 0; } to { background-position: 0 0; } }
|
||||||
|
.vhero .vbg { opacity: .3; height: 300px; filter: saturate(1.2); }
|
||||||
|
.vpost { box-shadow: 0 10px 30px rgba(0, 0, 0, .35); border-radius: 12px; }
|
||||||
|
.vsum { font-size: 15.5px; }
|
||||||
|
.vmark { border-radius: 10px; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); animation: vfade .25s ease both; font-weight: 650; }
|
||||||
|
/* Lyrics polish */
|
||||||
|
.ll { text-shadow: 0 1px 12px color-mix(in srgb, var(--bg) 70%, transparent); }
|
||||||
|
.ll.on { text-shadow: 0 0 28px color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||||
|
.lyrart { box-shadow: 0 4px 14px rgba(0, 0, 0, .3); }
|
||||||
|
.vban { position: relative; display: block; height: clamp(170px, 30vw, 280px); border-radius: 16px; overflow: hidden; margin: 6px 0 14px; background: var(--panel2); color: #fff; box-shadow: 0 12px 32px rgba(0, 0, 0, .3); }
|
||||||
|
.vban img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; animation: vfade .5s ease both; }
|
||||||
|
.vban::after { content: ''; position: absolute; inset: 0; background: linear-gradient(90deg, rgba(0, 0, 0, .78), rgba(0, 0, 0, .15) 70%), linear-gradient(transparent 50%, rgba(0, 0, 0, .5)); }
|
||||||
|
.vbant { position: absolute; z-index: 1; left: 20px; bottom: 18px; right: 20px; display: flex; flex-direction: column; gap: 4px; align-items: flex-start; }
|
||||||
|
.vbant b { font-size: clamp(20px, 4vw, 32px); line-height: 1.15; letter-spacing: -.02em; } .vbant small { opacity: .85; font-size: 13px; }
|
||||||
|
.vbant .btn { margin-top: 8px; display: inline-flex; align-items: center; gap: 6px; font-style: normal; min-height: 44px; }
|
||||||
|
.vhero { --vtint: transparent; }
|
||||||
|
.vhero::before { content: ''; position: absolute; inset: -8px -16px 0 -16px; z-index: -1; background: radial-gradient(120% 90% at 20% 0, color-mix(in srgb, var(--vtint) 45%, transparent), transparent 70%); pointer-events: none; transition: background .5s; }
|
||||||
|
.vempty { grid-column: 1 / -1; display: grid; justify-items: center; gap: 8px; padding: 36px 12px; color: var(--muted); } .vempty svg { width: 40px; height: 40px; opacity: .6; }
|
||||||
|
kbd { display: inline-block; padding: 1px 7px; border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 6px; background: var(--panel2); font: 12px ui-monospace, Menlo, monospace; }
|
||||||
|
/* every poster in a row is the same size (a row has no grid to size them) */
|
||||||
|
.vrow > .vcard:not(.wide) { flex: none; width: 130px; }
|
||||||
|
@media (min-width: 768px) { .vrow > .vcard:not(.wide) { width: 150px; } }
|
||||||
|
.vrow > .vcard:not(.wide) .vimg { aspect-ratio: 2/3; }
|
||||||
|
/* sideways rows: arrows and drag */
|
||||||
|
.vslide { position: relative; }
|
||||||
|
.vrow { scrollbar-width: none; } .vrow::-webkit-scrollbar { display: none; }
|
||||||
|
.vrow.drag { cursor: grabbing; user-select: none; scroll-snap-type: none; } .vrow.drag * { pointer-events: none; }
|
||||||
|
.vsl { position: absolute; top: 28%; z-index: 4; width: 38px; height: 38px; min-height: 0; padding: 0; border-radius: 50%; border: 1px solid var(--line); background: color-mix(in srgb, var(--panel) 92%, transparent); color: var(--text); font-size: 24px; line-height: 1; display: grid; place-items: center; cursor: pointer; box-shadow: 0 4px 14px rgba(0, 0, 0, .3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
|
||||||
|
.vsl[hidden] { display: none; } .vsl.l { left: -6px; } .vsl.r { right: -6px; }
|
||||||
|
@media (hover: none) { .vsl { display: none; } }
|
||||||
|
/* Mail: one button per account, each with its own unread count */
|
||||||
|
.maccts { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; margin-bottom: 6px; padding-bottom: 2px; }
|
||||||
|
.maccts::-webkit-scrollbar { display: none; }
|
||||||
|
.maccts button { flex: none; display: flex; align-items: center; gap: 8px; max-width: 220px; min-height: 40px; padding: 4px 12px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--muted); text-align: left; cursor: pointer; }
|
||||||
|
.maccts button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; }
|
||||||
|
.maccts button.new:not(.on) { color: var(--text); border-color: var(--accent); }
|
||||||
|
.maccts .mal { font-weight: 650; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.maccts small { display: none; }
|
||||||
|
.maccts .nbadge { margin-left: 2px; }
|
||||||
|
.mfold .maccts { flex-direction: column; overflow: visible; margin-bottom: 10px; } .mfold .maccts button { max-width: none; } .mfold .maccts small { display: block; opacity: .75; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.mfold .maccts .mal, .mfold .maccts small { display: block; } .mfold .maccts button > .mal { max-width: 100%; }
|
||||||
|
|
||||||
|
/* Books: library and reader */
|
||||||
|
.rdcover { position: relative; display: block; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
|
||||||
|
.rdcover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.rdcover em { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 10px; text-align: center; font: 600 13px/1.25 Georgia, serif; color: var(--muted); overflow: hidden; z-index: 0; }
|
||||||
|
.rdcover img + em { display: none; }
|
||||||
|
.rdext { position: absolute; top: 6px; right: 6px; z-index: 1; font: 700 9px/1 system-ui; text-transform: uppercase; letter-spacing: .06em; background: rgba(0,0,0,.6); color: #fff; padding: 3px 5px; border-radius: 4px; }
|
||||||
|
.rdprog { position: absolute; left: 0; right: 0; bottom: 0; height: 4px; background: rgba(0,0,0,.25); z-index: 1; }
|
||||||
|
.rdprog s { display: block; height: 100%; background: var(--accent); }
|
||||||
|
#rdlib.drop { outline: 2px dashed var(--accent); outline-offset: 6px; border-radius: 12px; }
|
||||||
|
#rdbody select { min-height: 44px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); color: var(--text); padding: 0 10px; font-size: 16px; }
|
||||||
|
.rd { --rd-bg: #fff; --rd-fg: #1b1b1b; --rd-link: #1a5fd0; position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; background: var(--rd-bg); color: var(--rd-fg); }
|
||||||
|
.rd.dark { color-scheme: dark; }
|
||||||
|
.rdbar, .rdfoot { display: flex; align-items: center; gap: 2px; padding: 4px 6px; background: var(--rd-bg); color: var(--rd-fg); border-color: rgba(128,128,128,.25); flex: none; z-index: 5; }
|
||||||
|
.rdbar { padding-top: calc(var(--sat, 0px) + 4px); border-bottom: 1px solid rgba(128,128,128,.25); }
|
||||||
|
.rdfoot { gap: 10px; padding: 6px 14px calc(var(--sab, 0px) + 6px); border-top: 1px solid rgba(128,128,128,.25); }
|
||||||
|
.rd .ibtn { color: inherit; flex: none; }
|
||||||
|
.rd .ibtn.on svg { fill: currentColor; color: var(--accent); }
|
||||||
|
.rdtitle { min-width: 0; padding: 0 6px; } .rdtitle b, .rdtitle small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rdtitle small { opacity: .65; font-size: 12px; }
|
||||||
|
.rd.hidebars .rdbar, .rd.hidebars .rdfoot { display: none; }
|
||||||
|
.rdmain { position: relative; flex: 1; min-height: 0; display: flex; }
|
||||||
|
.rdstage { flex: 1; min-width: 0; min-height: 0; position: relative; overflow: hidden; background: var(--rd-bg); }
|
||||||
|
.rdstage.pdf { overflow-y: auto; overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
.rdstage.pdf .rdpages { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 8px 0; min-width: min-content; }
|
||||||
|
.rdframe { width: 100%; height: 100%; border: 0; display: block; background: var(--rd-bg); }
|
||||||
|
.rdzone { position: absolute; top: 0; bottom: 0; width: 56px; background: none; border: 0; z-index: 2; opacity: 0; display: none; }
|
||||||
|
.rdzone.l { left: 0; } .rdzone.r { right: 0; }
|
||||||
|
@media (min-width: 768px) and (hover: hover) { .rdzone { display: block; } .rdzone:hover { opacity: 1; background: linear-gradient(90deg, rgba(128,128,128,.15), transparent); } .rdzone.r:hover { background: linear-gradient(270deg, rgba(128,128,128,.15), transparent); } }
|
||||||
|
.rdload { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: 0; color: var(--rd-fg); opacity: .8; text-align: center; padding: 20px; z-index: 3; }
|
||||||
|
.rdload .err { opacity: 1; }
|
||||||
|
.rdlabel { flex: 0 1 30%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; opacity: .7; }
|
||||||
|
.rdfoot input[type=range] { flex: 1; min-width: 60px; accent-color: var(--accent); }
|
||||||
|
.rdpct { font-size: 12px; opacity: .7; min-width: 34px; text-align: right; }
|
||||||
|
.pg { position: relative; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.25); flex: none; overflow: hidden; }
|
||||||
|
.pg canvas { display: block; }
|
||||||
|
.pdark .pg canvas { filter: invert(.9) hue-rotate(180deg); } .psepia .pg canvas { filter: sepia(.45) contrast(.95); }
|
||||||
|
.pdark .pg { background: #111; }
|
||||||
|
.textLayer { position: absolute; inset: 0; overflow: hidden; line-height: 1; text-size-adjust: none; z-index: 2; }
|
||||||
|
.textLayer > span, .textLayer br { color: transparent; position: absolute; white-space: pre; cursor: text; transform-origin: 0% 0%; }
|
||||||
|
.textLayer ::selection { background: rgba(60,120,255,.35); }
|
||||||
|
.rdhls { position: absolute; inset: 0; z-index: 1; pointer-events: none; mix-blend-mode: multiply; opacity: .5; }
|
||||||
|
.pdark .rdhls { mix-blend-mode: screen; opacity: .45; }
|
||||||
|
.rdhl { position: absolute; border-radius: 2px; } .rdhl.flash { outline: 2px solid #e8590c; }
|
||||||
|
.rdsel { position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); z-index: 6; display: flex; align-items: center; gap: 8px; background: var(--panel); color: var(--text); border: 1px solid var(--line); border-radius: 999px; padding: 6px 10px; box-shadow: 0 6px 24px rgba(0,0,0,.3); max-width: calc(100% - 16px); }
|
||||||
|
.rdsel[hidden] { display: none; }
|
||||||
|
.rdcol { width: 30px; height: 30px; border-radius: 50%; border: 2px solid transparent; flex: none; } .rdcol.on { border-color: var(--text); }
|
||||||
|
.rdsb { display: flex; align-items: center; gap: 4px; background: none; border: 0; color: var(--text); font-size: 14px; min-height: 36px; padding: 0 6px; } .rdsb svg { width: 18px; height: 18px; }
|
||||||
|
.rdcolors { display: flex; gap: 10px; margin: 12px 0; }
|
||||||
|
.rddot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
|
||||||
|
.rdq { margin: 0 0 8px; padding: 6px 12px; border-left: 3px solid var(--accent); color: var(--muted); }
|
||||||
|
.rdlist { max-height: 60vh; overflow: auto; }
|
||||||
|
.rdlist mark, .rdlist .row mark { background: #ffd43b88; color: inherit; border-radius: 2px; }
|
||||||
|
.rdseg { display: flex; gap: 6px; flex-wrap: wrap; } .rdseg button { flex: 1; min-height: 40px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; white-space: nowrap; }
|
||||||
|
.rdseg button.on { background: var(--accent); color: var(--accent-t); border-color: transparent; font-weight: 600; }
|
||||||
|
.rdstep { display: flex; align-items: center; gap: 12px; } .rdstep span { min-width: 56px; text-align: center; font-weight: 600; } .rdstep .btn { min-width: 64px; justify-content: center; }
|
||||||
|
|
||||||
|
/* Mail: swipe a message left to delete, right to archive */
|
||||||
|
.mrow { position: relative; touch-action: pan-y; will-change: transform; background: var(--panel); }
|
||||||
|
.mrow.swl { box-shadow: var(--sww, 100vw) 0 0 var(--danger); } .mrow.swr { box-shadow: calc(-1 * var(--sww, 100vw)) 0 0 #2f9e6e; }
|
||||||
|
.mrow.swl.swgo { box-shadow: var(--sww, 100vw) 0 0 #b3261e; }
|
||||||
|
.mrow.swl::after, .mrow.swr::after { content: attr(data-sw); position: absolute; top: 0; bottom: 0; display: flex; align-items: center; color: #fff; font-weight: 600; padding: 0 20px; white-space: nowrap; }
|
||||||
|
.mrow.swl::after { left: 100%; } .mrow.swr::after { right: 100%; }
|
||||||
|
|
||||||
|
/* Mail: choosing several messages */
|
||||||
|
.mchk { width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--muted); flex: none; margin-top: 1px; position: relative; }
|
||||||
|
.mchk.on { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.mchk.on::after { content: ''; position: absolute; left: 6px; top: 2px; width: 5px; height: 10px; border: solid var(--accent-t); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||||
|
.mrow.picked { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
|
||||||
|
.mselbar { position: sticky; bottom: 8px; z-index: 4; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 8px 10px; margin: 10px 0 0; box-shadow: 0 4px 16px rgba(0,0,0,.12); }
|
||||||
|
.msbtop { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .msbtop .btn { min-height: 36px; }
|
||||||
|
.msbrow { display: flex; gap: 6px; flex-wrap: wrap; } .msb { min-height: 40px; flex: 1 1 auto; justify-content: center; padding: 0 10px; font-size: 14px; } .msb svg { width: 18px; height: 18px; margin-right: 4px; }
|
||||||
|
.msb:disabled { opacity: .45; }
|
||||||
|
body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
|
||||||
|
.mflag { color: var(--danger); } .mflag svg { fill: currentColor; }
|
||||||
|
.rdhave { position: absolute; left: 0; right: 0; bottom: 0; z-index: 1; background: rgba(47,158,110,.92); color: #fff; font: 600 11px/1 system-ui; text-align: center; padding: 5px 0; }
|
||||||
|
|
||||||
|
/* Radio */
|
||||||
|
.rdorow { padding: 0; } .rdorow.playing { background: color-mix(in srgb, var(--accent) 12%, var(--panel)); }
|
||||||
|
.rdoplay { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; padding: 10px 12px 10px 16px; background: none; border: 0; text-align: left; color: inherit; min-height: 56px; }
|
||||||
|
.rdoplay > svg { width: 20px; height: 20px; color: var(--accent); flex: none; }
|
||||||
|
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.rdorow .ibtn { margin-right: 6px; }
|
||||||
|
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
|
||||||
+13
-1
@@ -1,5 +1,6 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v8';
|
const CACHE = 'hub-v43';
|
||||||
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
|
// Install: keep the icons, and (when signed in) the page itself with the scripts and styles it names, so the app opens offline.
|
||||||
@@ -21,8 +22,19 @@ self.addEventListener('activate', e => {
|
|||||||
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
|
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))));
|
||||||
self.clients.claim();
|
self.clients.claim();
|
||||||
});
|
});
|
||||||
|
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
|
||||||
|
async function takeShare(request) {
|
||||||
|
try {
|
||||||
|
const form = await request.formData(), c = await caches.open(SHARE);
|
||||||
|
for (const k of await c.keys()) await c.delete(k);
|
||||||
|
let i = 0;
|
||||||
|
for (const f of form.getAll('files')) if (f && f.name) await c.put('/share-files/' + (i++), new Response(f, { headers: { 'x-name': encodeURIComponent(f.name), 'content-type': f.type || 'application/octet-stream' } }));
|
||||||
|
} catch { /* nothing usable was shared */ }
|
||||||
|
return Response.redirect(self.location.origin + '/#/files', 303);
|
||||||
|
}
|
||||||
self.addEventListener('fetch', e => {
|
self.addEventListener('fetch', e => {
|
||||||
const u = new URL(e.request.url);
|
const u = new URL(e.request.url);
|
||||||
|
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
|
||||||
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
|
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
|
||||||
e.respondWith(fetch(e.request).then(r => {
|
e.respondWith(fetch(e.request).then(r => {
|
||||||
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
|
if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); }
|
||||||
|
|||||||
+36
-57
@@ -3,6 +3,7 @@
|
|||||||
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
const dueInfo = d => { if (!d) return null; const t = ymd(new Date()); const label = new Date(d + 'T00:00:00').toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||||
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
|
return d < t ? { text: 'Overdue · ' + label, color: 'var(--danger)' } : d === t ? { text: 'Today', color: 'var(--accent)' } : { text: label, color: 'var(--muted)' }; };
|
||||||
const taskStore = []; // tasks on screen, so a tap can open the right one
|
const taskStore = []; // tasks on screen, so a tap can open the right one
|
||||||
|
let taskLists = []; // the lists (NE Hub and iCloud), so a task seen in "All" opens with its own list's options
|
||||||
|
|
||||||
async function viewTasks() {
|
async function viewTasks() {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
@@ -10,11 +11,12 @@ async function viewTasks() {
|
|||||||
try {
|
try {
|
||||||
const { lists, icloud } = await jfetch('/api/tasks/lists');
|
const { lists, icloud } = await jfetch('/api/tasks/lists');
|
||||||
if (!location.hash.startsWith('#/tasks')) return;
|
if (!location.hash.startsWith('#/tasks')) return;
|
||||||
let cur = store.get('task_list'); if (!lists.some(l => l.id === cur)) cur = lists[0].id;
|
taskLists = lists;
|
||||||
const list = lists.find(l => l.id === cur);
|
let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together)
|
||||||
|
const list = cur === 'all' ? lists.find(l => l.id === 'local:inbox') || lists.find(l => l.local) || lists[0] : lists.find(l => l.id === cur); // (where a task typed into All goes)
|
||||||
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
v.innerHTML = `<div class="wrap">${plannerHead('tasks')}
|
||||||
<div class="seg">${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
<div class="seg">${lists.length > 1 ? `<a href="#" data-l="all" class="${cur === 'all' ? 'on' : ''}">All</a>` : ''}${lists.map(l => `<a href="#" data-l="${esc(l.id)}" class="${l.id === cur ? 'on' : ''}">${esc(l.name)}${l.icloud ? ' (iCloud)' : ''}</a>`).join('')}<a href="#" id="nl2" aria-label="New list">+ List</a></div>
|
||||||
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="Add a task" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
<form id="qa" class="bar" style="margin:6px 0 12px;flex-wrap:nowrap"><input type="text" id="qt" placeholder="${cur === 'all' ? 'Add a task to ' + esc(list.name) : 'Add a task'}" autocomplete="off" style="flex:1"><button class="btn" aria-label="Add">${icon('check')}Add</button></form>
|
||||||
<div id="tl"><p class="empty">Loading…</p></div>
|
<div id="tl"><p class="empty">Loading…</p></div>
|
||||||
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
<p class="sub" id="tn" style="margin-top:16px"></p></div>`;
|
||||||
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
$('#tn').textContent = !icloud.connected ? 'Tip: connect iCloud (Calendar → Manage calendars) to include any reminder lists your account shares with apps.'
|
||||||
@@ -23,7 +25,7 @@ async function viewTasks() {
|
|||||||
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
|
$('#nl2').onclick = e => { e.preventDefault(); newListSheet(lists, list); };
|
||||||
$('#qa').onsubmit = async e => {
|
$('#qa').onsubmit = async e => {
|
||||||
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
|
e.preventDefault(); const t = $('#qt').value.trim(); if (!t) return;
|
||||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: list.id, fields: { title: t, notes: '', due: '', done: false } }) }); $('#qt').value = ''; loadTaskList(cur, list); }
|
||||||
catch (err) { toast(err.message); }
|
catch (err) { toast(err.message); }
|
||||||
};
|
};
|
||||||
loadTaskList(cur, list);
|
loadTaskList(cur, list);
|
||||||
@@ -33,7 +35,9 @@ async function viewTasks() {
|
|||||||
async function loadTaskList(cur, list) {
|
async function loadTaskList(cur, list) {
|
||||||
const box = $('#tl'); if (!box) return;
|
const box = $('#tl'); if (!box) return;
|
||||||
try {
|
try {
|
||||||
const tasks = await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
|
const all = cur === 'all', byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
|
||||||
|
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
|
||||||
|
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
|
||||||
if (!$('#tl')) return;
|
if (!$('#tl')) return;
|
||||||
taskStore.length = 0;
|
taskStore.length = 0;
|
||||||
const ids = new Set(tasks.map(t => t.id)), byParent = {};
|
const ids = new Set(tasks.map(t => t.id)), byParent = {};
|
||||||
@@ -43,7 +47,7 @@ async function loadTaskList(cur, list) {
|
|||||||
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
const done = tasks.filter(t => top(t) && t.done).sort((a, b) => (b.completedAt || 0) - (a.completedAt || 0) || a.title.localeCompare(b.title));
|
||||||
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
|
const REPEAT_LABEL = { daily: 'Daily', weekdays: 'Weekdays', weekly: 'Weekly', monthly: 'Monthly', yearly: 'Yearly' };
|
||||||
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
const row = (t, sub) => { const i = taskStore.push(t) - 1, du = dueInfo(t.due), kids = byParent[t.id] || [], rep = REPEAT_LABEL[t.repeat], prog = kids.length ? kids.filter(k => k.done).length + '/' + kids.length + ' subtasks' : '';
|
||||||
const extra = [rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
|
const extra = [all && t._ln ? t._ln : '', rep ? '↻ ' + rep : '', prog].filter(Boolean).join(' · ');
|
||||||
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
return `<div class="row it${t.done ? ' donetask' : ''}${sub ? ' subtask' : ''}"><button class="ibtn tick" data-t="${i}" aria-label="${t.done ? 'Mark not done' : 'Mark done'}">${icon('check')}</button>
|
||||||
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
<button class="rmain" data-e="${i}"><span class="grow"><b>${esc(t.title)}</b>${du || t.notes || extra ? `<small>${du ? `<span style="color:${du.color}">${esc(du.text)}</span>` : ''}${du && (extra || t.notes) ? ' · ' : ''}${esc(extra)}${extra && t.notes ? ' · ' : ''}${esc((t.notes || '').split('\n')[0])}</small>` : ''}</span></button></div>`; };
|
||||||
const showDone = store.get('task_show_done', false);
|
const showDone = store.get('task_show_done', false);
|
||||||
@@ -52,14 +56,15 @@ async function loadTaskList(cur, list) {
|
|||||||
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
|
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
|
||||||
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
|
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
|
||||||
const t = taskStore[+b.dataset.t]; b.disabled = true;
|
const t = taskStore[+b.dataset.t]; b.disabled = true;
|
||||||
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
|
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
|
||||||
catch (err) { b.disabled = false; toast(err.message); }
|
catch (err) { b.disabled = false; toast(err.message); }
|
||||||
});
|
});
|
||||||
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => taskSheet(taskStore[+b.dataset.e], cur, list));
|
box.querySelectorAll('button[data-e]').forEach(b => b.onclick = () => { const t = taskStore[+b.dataset.e]; taskSheet(t, t.list, byId[t.list] || list, cur, list); });
|
||||||
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function taskSheet(t, cur, list) {
|
function taskSheet(t, cur, list, viewCur, viewList) { // (cur and list: the task's own list; viewCur and viewList: what the page is showing, to redraw)
|
||||||
|
const redraw = () => loadTaskList(viewCur || cur, viewList || list);
|
||||||
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
const el = openSheet('Task', list.name, `<form id="tf"><input type="text" id="tt" value="${esc(t.title)}" autocomplete="off" required style="margin-top:6px">
|
||||||
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
<label for="tdue">Due date</label><div style="display:flex;gap:8px"><input type="date" id="tdue" value="${esc(t.due)}" style="flex:1"><button type="button" class="btn ghost" id="tclr">Clear</button></div>
|
||||||
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
<label for="tnote">Notes</label><textarea id="tnote" rows="3">${esc(t.notes)}</textarea>
|
||||||
@@ -73,18 +78,18 @@ function taskSheet(t, cur, list) {
|
|||||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
|
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#tclr').onclick = () => { el.querySelector('#tdue').value = ''; };
|
||||||
el.querySelector('#tf').onsubmit = async e => {
|
el.querySelector('#tf').onsubmit = async e => {
|
||||||
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
|
e.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true;
|
||||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); loadTaskList(cur, list); }
|
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, fields: { title: el.querySelector('#tt').value.trim(), notes: el.querySelector('#tnote').value.trim(), due: el.querySelector('#tdue').value, done: el.querySelector('#tdone').checked, ...(el.querySelector('#trep') ? { repeat: el.querySelector('#trep').value } : {}) } }) }).then(r => { if (r && r.rolled) toast('Repeats: next due ' + r.rolled); }); closeSheet(); redraw(); }
|
||||||
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
|
catch (err) { btn.disabled = false; el.querySelector('#te').textContent = err.message; }
|
||||||
};
|
};
|
||||||
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
|
if (el.querySelector('#tsubadd')) el.querySelector('#tsubadd').onclick = async () => {
|
||||||
const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return;
|
const inp = el.querySelector('#tsub'), title = inp.value.trim(); if (!title) return;
|
||||||
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) });
|
try { await jfetch('/api/tasks', { method: 'POST', body: JSON.stringify({ list: cur, fields: { title, notes: '', due: '', done: false, parent: t.id } }) });
|
||||||
el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); inp.value = ''; inp.focus(); loadTaskList(cur, list); }
|
el.querySelector('#tsubs').insertAdjacentHTML('beforeend', `<div class="row"><span class="grow"><b>${esc(title)}</b></span></div>`); inp.value = ''; inp.focus(); redraw(); }
|
||||||
catch (err) { el.querySelector('#te').textContent = err.message; }
|
catch (err) { el.querySelector('#te').textContent = err.message; }
|
||||||
};
|
};
|
||||||
if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } };
|
if (el.querySelector('#tsub')) el.querySelector('#tsub').onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); el.querySelector('#tsubadd').click(); } };
|
||||||
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => {
|
el.querySelector('#td').onclick = () => confirmSheet('Delete task?', t.title + (taskStore.some(k => k.parent === t.id) ? ' and its subtasks' : ''), 'Delete', async () => {
|
||||||
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); loadTaskList(cur, list); }
|
try { await jfetch('/api/tasks/delete', { method: 'POST', body: JSON.stringify({ list: cur, id: t.id, href: t.href, etag: t.etag }) }); toast('Task deleted'); redraw(); }
|
||||||
catch (err) { toast(err.message); }
|
catch (err) { toast(err.message); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -99,58 +104,32 @@ function newListSheet(lists, list) {
|
|||||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
|
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = () => confirmSheet('Delete list?', 'Its tasks are deleted too.', 'Delete', async () => { await jfetch('/api/tasks/lists/delete', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); store.del('task_list'); viewTasks(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Home: a card only when something is overdue or due today
|
// Overdue and due-today tasks, for the icon badge
|
||||||
|
let taskDue = 0;
|
||||||
|
async function taskPoll() {
|
||||||
|
if (!allowed('tasks')) return;
|
||||||
|
try { const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = (s.overdue || 0) + (s.today || 0); if (n !== taskDue) { taskDue = n; renderNav(); } } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home: the tasks that are overdue, due today or due this week, from every list (NE Hub and iCloud), with a tick to finish each
|
||||||
async function renderTasksCard(el) {
|
async function renderTasksCard(el) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const s = await jfetch('/api/tasks/summary?today=' + ymd(new Date())), n = s.overdue + s.today;
|
const r = await jfetch('/api/tasks/upcoming?today=' + ymd(new Date()));
|
||||||
if (!n || !document.body.contains(el)) return;
|
if (!document.body.contains(el)) return; if (!r.tasks.length) { el.innerHTML = ''; return; }
|
||||||
el.innerHTML = `<h2>Tasks</h2><a class="row card2" href="#/tasks">${icon('check')}<span class="grow"><b>${n} task${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} attention</b><small>${s.overdue ? s.overdue + ' overdue' : ''}${s.overdue && s.today ? ' · ' : ''}${s.today ? s.today + ' due today' : ''}</small></span>${icon('chev', 'chev')}</a>`;
|
const rows = r.tasks.map((t, i) => { const du = dueInfo(t.due);
|
||||||
|
return `<div class="row it"><button class="ibtn tick" data-h="${i}" aria-label="Mark done">${icon('check')}</button><a class="rmain grow" href="#/tasks" style="color:inherit"><span class="grow"><b>${esc(t.title)}</b><small><span style="color:${du.color}">${esc(du.text)}</span>${t.listName ? ' · ' + esc(t.listName) : ''}</small></span></a></div>`; }).join('');
|
||||||
|
el.innerHTML = `<h2>Tasks</h2><div class="list">${rows}</div><p style="margin:6px 2px 0"><a href="#/tasks" style="color:var(--accent)">${r.total > r.tasks.length ? 'All ' + r.total + ' coming up' : 'All tasks'}</a></p>`;
|
||||||
|
el.querySelectorAll('button[data-h]').forEach(b => b.onclick = async () => {
|
||||||
|
const t = r.tasks[+b.dataset.h]; b.disabled = true;
|
||||||
|
try { await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: true }) }); taskPoll(); renderTasksCard(el); }
|
||||||
|
catch (err) { b.disabled = false; toast(err.message); }
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
|
const officeHash = (repo, path) => '#/office/doc/' + encodeURIComponent(repo) + path.split('/').filter(Boolean).map(x => '/' + encodeURIComponent(x)).join('');
|
||||||
|
|
||||||
function viewOffice(args = []) {
|
|
||||||
if (args[0] === 'doc') return viewEdit(args.slice(1), { fromOffice: true });
|
|
||||||
$('#view').innerHTML = `<div class="wrap"><h1>Office</h1><p class="sub">Create, open and edit documents without leaving this tab. Everything is saved in Seafile.</p>
|
|
||||||
<div class="grid">
|
|
||||||
<button class="tile" data-x="docx" data-l="document"><span class="ic" style="background:#3d6bf0">${icon('doc')}</span><b>Document</b><small>Word (.docx)</small></button>
|
|
||||||
<button class="tile" data-x="xlsx" data-l="spreadsheet"><span class="ic" style="background:#2f9e6e">${icon('doc')}</span><b>Spreadsheet</b><small>Excel (.xlsx)</small></button>
|
|
||||||
<button class="tile" data-x="pptx" data-l="presentation"><span class="ic" style="background:#e0742b">${icon('doc')}</span><b>Presentation</b><small>PowerPoint (.pptx)</small></button>
|
|
||||||
</div>
|
|
||||||
<p class="sub" id="olib" style="margin-top:12px">${sf.token() ? 'Loading…' : 'Sign in to Files to create and open documents.'}</p>
|
|
||||||
<div id="odocs"></div>
|
|
||||||
<div class="bar"><a class="btn ghost" href="#/files">Browse all files</a>${sf.token() ? '' : '<a class="btn" href="#/files">Sign in</a>'}</div></div>`;
|
|
||||||
$('#view').querySelectorAll('.tile[data-x]').forEach(t => t.onclick = () => newOfficeDoc(t.dataset.x, t.dataset.l));
|
|
||||||
if (!sf.token()) return;
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const loc = await officeLocation(false), el = $('#olib'); if (!el) return;
|
|
||||||
if (!loc) {
|
|
||||||
el.textContent = 'Choose where your documents are saved in Seafile.';
|
|
||||||
$('#odocs').innerHTML = '<div class="bar"><button class="btn" id="chl2">Choose location</button></div>';
|
|
||||||
$('#chl2').onclick = () => chooseFolder('Save documents in…').then(l => { if (l) { store.set('office_loc', l); viewOffice(); } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.innerHTML = `Saved in <b>${esc(locLabel(loc))}</b> · <a href="#" id="chl" style="color:var(--accent)">change</a>`;
|
|
||||||
$('#chl').onclick = e => { e.preventDefault(); chooseFolder('Save documents in…', loc).then(l => { if (l) { store.set('office_loc', l); viewOffice(); } }); };
|
|
||||||
const all = await sf.req(`/api2/repos/${encodeURIComponent(loc.repo)}/dir/?p=${encodeURIComponent(loc.dir)}&recursive=1`);
|
|
||||||
const docs = all.filter(f => f.type === 'file' && EDIT_EXT.has(ext(f.name)) && ext(f.name) !== 'txt').sort((a, b) => b.mtime - a.mtime).slice(0, 60);
|
|
||||||
if (!location.hash.startsWith('#/office') || !$('#odocs')) return;
|
|
||||||
$('#odocs').innerHTML = docs.length ? `<h2>Documents</h2><div class="list">${docs.map((f, i) => {
|
|
||||||
const color = { docx: '#3d6bf0', doc: '#3d6bf0', odt: '#3d6bf0', rtf: '#3d6bf0', xlsx: '#2f9e6e', xls: '#2f9e6e', ods: '#2f9e6e', csv: '#2f9e6e' }[ext(f.name)] || '#e0742b';
|
|
||||||
return `<button class="row" data-i="${i}"><span class="ic" style="background:${color}">${icon('file')}</span><span class="grow"><b>${esc(f.name)}</b><small>${esc(f.parent_dir || '/')} · ${fmtDate(f.mtime)}</small></span></button>`; }).join('')}</div>`
|
|
||||||
: '<p class="empty">No documents here yet. Create one above.</p>';
|
|
||||||
$('#odocs').querySelectorAll('button[data-i]').forEach(b => b.onclick = () => {
|
|
||||||
const f = docs[b.dataset.i], path = (f.parent_dir || '/').replace(/\/?$/, '/') + f.name;
|
|
||||||
addRecent(loc.repo, path, f.name);
|
|
||||||
if (cfg.editor) location.hash = officeHash(loc.repo, path); else window.open(`${cfg.seafile}/lib/${encodeURIComponent(loc.repo)}/file${encP(path)}`, '_blank', 'noopener');
|
|
||||||
});
|
|
||||||
} catch (e) { const el = $('#olib'); if (el) el.innerHTML = `<span class="err">${esc(e.message)}</span>`; }
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
|
|
||||||
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
|
const locLabel = l => l.repoName + (l.dir === '/' ? '' : ' / ' + l.dir.split('/').filter(Boolean).join(' / '));
|
||||||
// The saved Office location, if it still exists (the library may have been deleted).
|
// The saved Office location, if it still exists (the library may have been deleted).
|
||||||
async function officeLocation(ask) {
|
async function officeLocation(ask) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
'use strict';
|
||||||
|
// Home: "Pick up where you left off" (Continue watching and Continue listening), shown above the day's agenda in the evening and below it in the morning.
|
||||||
|
async function homeResume(el) {
|
||||||
|
if (!el) return;
|
||||||
|
const bits = await Promise.allSettled([
|
||||||
|
allowed('video') ? (async () => {
|
||||||
|
const st = await jfetch('/api/plex/status'); if (!st.signedIn || !st.server) return '';
|
||||||
|
px.serverKey = st.server;
|
||||||
|
const items = ((await px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=8')).Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode').slice(0, 6);
|
||||||
|
return items.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue watching</h3><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>` : '';
|
||||||
|
})() : '',
|
||||||
|
allowed('audio') ? (async () => {
|
||||||
|
const [data] = await Promise.all([abApi('/books'), abLoadProg()]), books = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||||
|
const cont = books.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 6);
|
||||||
|
today.books = cont;
|
||||||
|
return cont.length ? `<h3 class="sub" style="margin:10px 0 4px">Continue listening</h3><div class="vrow">${cont.map(b => { const p = abProg[abId(b)], pc = Math.min(100, p.pos / p.dur * 100);
|
||||||
|
return `<a class="vcard" href="#/audio" data-book="${esc(abId(b))}" style="flex:none;width:130px"><span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pc > 1 ? `<i class="vprog"><b style="width:${pc}%"></b></i>` : ''}</span><b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></a>`; }).join('')}</div>` : '';
|
||||||
|
})() : '',
|
||||||
|
]);
|
||||||
|
const html = bits.map(b => b.status === 'fulfilled' ? b.value : '').join('');
|
||||||
|
if (!html || !el.isConnected) return;
|
||||||
|
el.innerHTML = `<h2>Pick up where you left off</h2>${html}`;
|
||||||
|
el.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (today.books || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
|
||||||
|
}
|
||||||
|
const today = { books: [] };
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
// ---- Verse By Verse Ministry (Podcast -> Verse By Verse): free verse-by-verse Bible studies from versebyverseministry.org. Pick a study, play a lesson (the rest of the
|
||||||
|
// study follows), open its handout. Where you got to in each lesson is kept on the server, so it follows you between devices.
|
||||||
|
const vbApi = (p, opts) => jfetch('/api/vbv' + p, opts);
|
||||||
|
const vbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||||
|
let vbProg = {}, vbQueued = {}, vbLast = 0;
|
||||||
|
const vbPlaying = () => { const t = cur(), m = t && /^vbv:([\w-]+):(\d+)$/.exec(String(t.key)); return m ? { slug: m[1], n: +m[2], key: t.key } : null; };
|
||||||
|
const vbLoadProg = async () => { try { for (const [k, p] of Object.entries(await vbApi('/progress') || {})) if (!vbProg[k] || p.t > vbProg[k].t) vbProg[k] = p; } catch {} };
|
||||||
|
const vbPush = keepalive => {
|
||||||
|
if (!Object.keys(vbQueued).length) return;
|
||||||
|
const body = JSON.stringify(vbQueued); vbQueued = {};
|
||||||
|
fetch('/api/vbv/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { vbQueued = { ...JSON.parse(body), ...vbQueued }; });
|
||||||
|
};
|
||||||
|
const vbSave = keepalive => { const n = vbPlaying(), d = pl.a.duration; if (!n || !isFinite(d) || d <= 0) return; vbProg[n.key] = vbQueued[n.key] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; vbPush(keepalive); };
|
||||||
|
pl.a.addEventListener('timeupdate', () => { if (Date.now() - vbLast > 8000) { vbLast = Date.now(); vbSave(); } });
|
||||||
|
pl.a.addEventListener('pause', () => vbSave()); pl.a.addEventListener('ended', () => vbSave()); addEventListener('pagehide', () => vbSave(true));
|
||||||
|
const vbLeft = key => { const p = vbProg[key]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; };
|
||||||
|
const vbDone = key => { const p = vbProg[key]; return !!(p && p.dur && p.pos >= p.dur - 30); };
|
||||||
|
const vbDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
|
||||||
|
const vbCover = (s, big) => `<span class="vimg" style="aspect-ratio:1/1;background:var(--panel2)"><img loading="lazy" alt="" referrerpolicy="no-referrer" src="${esc(s.cover || '')}" onerror="this.remove()"></span>`;
|
||||||
|
|
||||||
|
async function viewVbv(args = []) {
|
||||||
|
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Verse By Verse</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try { await vbLoadProg(); if (args[0] === 'study' && args[1]) return await vbStudy(args[1]); return await vbHome(); }
|
||||||
|
catch (e) { if (!location.hash.startsWith('#/podcast/vbv')) return; v.innerHTML = `<div class="wrap"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1>Verse By Verse</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vbretry">Try again</button><a class="btn ghost" href="https://versebyverseministry.org" target="_blank" rel="noopener">Open the website</a></div></div>`; $('#vbretry').onclick = () => route(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vbHome() {
|
||||||
|
const all = await vbApi('/studies'); if (!location.hash.startsWith('#/podcast/vbv')) return;
|
||||||
|
const S = { q: '', cat: '' }, bySlug = new Map(all.studies.map(s => [s.slug, s]));
|
||||||
|
const recent = Object.entries(vbProg).map(([k, p]) => { const m = /^vbv:([\w-]+):(\d+)$/.exec(k); return m && { slug: m[1], n: +m[2], key: k, t: p.t }; }).filter(x => x && bySlug.has(x.slug) && vbLeft(x.key)).sort((a, b) => b.t - a.t)
|
||||||
|
.filter((x, i, a) => a.findIndex(y => y.slug === x.slug) === i).slice(0, 8);
|
||||||
|
const card = s => `<a class="vcard" href="#/podcast/vbv/study/${esc(s.slug)}">${vbCover(s)}<b>${esc(s.title)}</b><small>${esc([s.lessons ? s.lessons + ' lessons' : '', s.teacher].filter(Boolean).join(' · '))}</small></a>`;
|
||||||
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast">${icon('back')}Podcast</a><h1 style="margin:0">Verse By Verse</h1></div>
|
||||||
|
<p class="sub">Free verse-by-verse Bible teaching from <a href="https://versebyverseministry.org" target="_blank" rel="noopener" style="color:var(--accent)">Verse By Verse Ministry International</a>. Each study plays lesson after lesson, and most lessons have a handout.</p>
|
||||||
|
${recent.length ? `<h2>Continue listening</h2><div class="vrow">${recent.map(x => { const s = bySlug.get(x.slug), p = vbProg[x.key]; return `<a class="vcard wide" style="width:140px;flex:none" href="#/podcast/vbv/study/${esc(x.slug)}">${vbCover(s).replace('</span>', `<i class="vprog"><b style="width:${Math.min(100, p.pos / p.dur * 100)}%"></b></i></span>`)}<b>${esc(s.title)}</b><small>Lesson ${x.n + 1}</small></a>`; }).join('')}</div>` : ''}
|
||||||
|
<input type="search" id="vbq" placeholder="Search studies" autocomplete="off" autocapitalize="off" style="width:100%;margin-top:12px">
|
||||||
|
<div class="seg" id="vbchips"><a href="#" data-c="" class="on">All</a>${all.categories.map(c => `<a href="#" data-c="${esc(c.id)}">${esc(c.name)}</a>`).join('')}</div><div id="vbgrid" class="vgrid"></div></div>`;
|
||||||
|
const draw = () => { const q = S.q.toLowerCase(), list = all.studies.filter(s => (!S.cat || s.categories.includes(S.cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q)));
|
||||||
|
$('#vbgrid').innerHTML = list.map(card).join('') || '<p class="empty">No studies match.</p>'; };
|
||||||
|
$('#vbq').oninput = e => { S.q = e.target.value.trim(); draw(); };
|
||||||
|
$('#vbchips').querySelectorAll('a').forEach(a => a.onclick = e => { e.preventDefault(); S.cat = a.dataset.c; $('#vbchips').querySelectorAll('a').forEach(x => x.classList.toggle('on', x === a)); draw(); });
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vbStudy(slug) {
|
||||||
|
const s = await vbApi('/study/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/podcast/vbv')) return;
|
||||||
|
const key = l => `vbv:${slug}:${l.n}`, partial = s.lessons.filter(l => vbLeft(key(l))).sort((a, b) => vbProg[key(b)].t - vbProg[key(a)].t)[0], started = s.lessons.some(l => vbProg[key(l)]);
|
||||||
|
const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0];
|
||||||
|
const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button>
|
||||||
|
<span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span>
|
||||||
|
<a class="btn ghost" href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a>${allowed('files') ? `<button class="ibtn" data-save aria-label="Save to Files" title="Save to Files">${icon('dl')}</button>` : ''}</div>`; };
|
||||||
|
const resume = nextUp(), rp = vbLeft(key(resume));
|
||||||
|
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div>
|
||||||
|
<div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc([s.teacher, s.lessons.length + ' lessons'].filter(Boolean).join(' · '))}</p>
|
||||||
|
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(s.description)}</p></div></div>
|
||||||
|
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button>${allowed('files') ? `<button class="btn ghost" id="vbsave">${icon('dl')}Save to Files</button>` : ''}</div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
|
||||||
|
<p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`;
|
||||||
|
const play = l => { // this lesson (from where you left off in it), then the rest of the study in order
|
||||||
|
const p = vbLeft(key(l)), np = vbPlaying(); if (np && np.key === key(l)) { if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
||||||
|
const q = s.lessons.slice(l.n).map(x => ({ key: key(x), title: x.title, artist: s.title, album: 'Verse By Verse Ministry', thumb: s.cover, src: `/api/vbv/audio/${encodeURIComponent(slug)}/${x.n}`, nocache: true, dur: 0 }));
|
||||||
|
playQueue(q, 0); if (p) pl.a.addEventListener('loadedmetadata', () => { const t = vbPlaying(); if (t && t.key === key(l)) pl.a.currentTime = p.pos; }, { once: true });
|
||||||
|
};
|
||||||
|
$('#vbgo').onclick = () => play(resume);
|
||||||
|
if ($('#vbsave')) $('#vbsave').onclick = () => actionSheet('Save “' + s.title + '” to Files', s.lessons.length + ' lessons. Whatever is already in the folder is skipped.', [
|
||||||
|
{ label: 'Lessons and handouts', icon: 'dl', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'both' }) },
|
||||||
|
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'audio' }) },
|
||||||
|
{ label: 'Handouts only', icon: 'doc', run: () => saveToFiles(s.title, { kind: 'vbv-study', slug, what: 'notes' }) }]);
|
||||||
|
document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l);
|
||||||
|
const sv = r.querySelector('[data-save]'); if (sv) sv.onclick = () => actionSheet(s.title + ', lesson ' + (l.n + 1), l.title, [
|
||||||
|
{ label: 'Lesson and handout', icon: 'dl', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'both' }) },
|
||||||
|
{ label: 'Lesson audio only', icon: 'audio', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-lesson', slug, n: l.n, what: 'audio' }) },
|
||||||
|
{ label: 'Handout only', icon: 'doc', run: () => saveToFiles(s.title + ' ' + (l.n + 1), { kind: 'vbv-notes', slug, n: l.n }) }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
Vendored
+28
@@ -0,0 +1,28 @@
|
|||||||
|
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
|
||||||
|
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
|
||||||
|
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
|
||||||
|
|
||||||
|
That work is also covered by the Apache 2 License, following copyright:
|
||||||
|
Copyright (c) 2013-2015 Brightcove
|
||||||
|
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
!function e(t){var r,i;r=this,i=function(){"use strict";function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var r=0,i=Array(t);r<t;r++)i[r]=e[r];return i}function i(e,t,r,i,n,a,s){try{var o=e[a](s),l=o.value}catch(e){return void r(e)}o.done?t(l):Promise.resolve(l).then(i,n)}function n(e,t){for(var r=0;r<t.length;r++){var i=t[r];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,y(i.key),i)}}function a(e,t,r){return t&&n(e.prototype,t),r&&n(e,r),Object.defineProperty(e,"prototype",{writable:!1}),e}function s(e,t){var i="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(i)return(i=i.call(e)).next.bind(i);if(Array.isArray(e)||(i=function(e,t){if(e){if("string"==typeof e)return r(e,t);var i={}.toString.call(e).slice(8,-1);return"Object"===i&&e.constructor&&(i=e.constructor.name),"Map"===i||"Set"===i?Array.from(e):"Arguments"===i||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(i)?r(e,t):void 0}}(e))||t){i&&(e=i);var n=0;return function(){return n>=e.length?{done:!0}:{done:!1,value:e[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function o(e,t,r){return(t=y(t))in e?Object.defineProperty(e,t,{value:r,enumerable:!0,configurable:!0,writable:!0}):e[t]=r,e}function l(){return l=Object.assign?Object.assign.bind():function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var i in r)({}).hasOwnProperty.call(r,i)&&(e[i]=r[i])}return e},l.apply(null,arguments)}function u(e){return u=Object.setPrototypeOf?Object.getPrototypeOf.bind():function(e){return e.__proto__||Object.getPrototypeOf(e)},u(e)}function d(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,p(e,t)}function h(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){})))}catch(e){}return(h=function(){return!!e})()}function f(e,t){var r=Object.keys(e);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(e);t&&(i=i.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),r.push.apply(r,i)}return r}function c(e){for(var t=1;t<arguments.length;t++){var r=null!=arguments[t]?arguments[t]:{};t%2?f(Object(r),!0).forEach((function(t){o(e,t,r[t])})):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(r)):f(Object(r)).forEach((function(t){Object.defineProperty(e,t,Object.getOwnPropertyDescriptor(r,t))}))}return e}function g(e,t){if(null==e)return{};var r={};for(var i in e)if({}.hasOwnProperty.call(e,i)){if(-1!==t.indexOf(i))continue;r[i]=e[i]}return r}function v(){
|
||||||
|
/*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */
|
||||||
|
var e,t,r="function"==typeof Symbol?Symbol:{},i=r.iterator||"@@iterator",n=r.toStringTag||"@@toStringTag";function a(r,i,n,a){var l=i&&i.prototype instanceof o?i:o,u=Object.create(l.prototype);return m(u,"_invoke",function(r,i,n){var a,o,l,u=0,d=n||[],h=!1,f={p:0,n:0,v:e,a:c,f:c.bind(e,4),d:function(t,r){return a=t,o=0,l=e,f.n=r,s}};function c(r,i){for(o=r,l=i,t=0;!h&&u&&!n&&t<d.length;t++){var n,a=d[t],c=f.p,g=a[2];r>3?(n=g===i)&&(l=a[(o=a[4])?5:(o=3,3)],a[4]=a[5]=e):a[0]<=c&&((n=r<2&&c<a[1])?(o=0,f.v=i,f.n=a[1]):c<g&&(n=r<3||a[0]>i||i>g)&&(a[4]=r,a[5]=i,f.n=g,o=0))}if(n||r>1)return s;throw h=!0,i}return function(n,d,g){if(u>1)throw TypeError("Generator is already running");for(h&&1===d&&c(d,g),o=d,l=g;(t=o<2?e:l)||!h;){a||(o?o<3?(o>1&&(f.n=-1),c(o,l)):f.n=l:f.v=l);try{if(u=2,a){if(o||(n="next"),t=a[n]){if(!(t=t.call(a,l)))throw TypeError("iterator result is not an object");if(!t.done)return t;l=t.value,o<2&&(o=0)}else 1===o&&(t=a.return)&&t.call(a),o<2&&(l=TypeError("The iterator does not provide a '"+n+"' method"),o=1);a=e}else if((t=(h=f.n<0)?l:r.call(i,f))!==s)break}catch(t){a=e,o=1,l=t}finally{u=1}}return{value:t,done:h}}}(r,n,a),!0),u}var s={};function o(){}function l(){}function u(){}t=Object.getPrototypeOf;var d=[][i]?t(t([][i]())):(m(t={},i,(function(){return this})),t),h=u.prototype=o.prototype=Object.create(d);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,m(e,n,"GeneratorFunction")),e.prototype=Object.create(h),e}return l.prototype=u,m(h,"constructor",u),m(u,"constructor",l),l.displayName="GeneratorFunction",m(u,n,"GeneratorFunction"),m(h),m(h,n,"Generator"),m(h,i,(function(){return this})),m(h,"toString",(function(){return"[object Generator]"})),(v=function(){return{w:a,m:f}})()}function m(e,t,r,i){var n=Object.defineProperty;try{n({},"",{})}catch(e){n=0}m=function(e,t,r,i){function a(t,r){m(e,t,(function(e){return this._invoke(t,r,e)}))}t?n?n(e,t,{value:r,enumerable:!i,configurable:!i,writable:!i}):e[t]=r:(a("next",0),a("throw",1),a("return",2))},m(e,t,r,i)}function p(e,t){return p=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(e,t){return e.__proto__=t,e},p(e,t)}function y(e){var t=function(e,t){if("object"!=typeof e||!e)return e;var r=e[Symbol.toPrimitive];if(void 0!==r){var i=r.call(e,t);if("object"!=typeof i)return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===t?String:Number)(e)}(e,"string");return"symbol"==typeof t?t:t+""}function E(e){var t="function"==typeof Map?new Map:void 0;return E=function(e){if(null===e||!function(e){try{return-1!==Function.toString.call(e).indexOf("[native code]")}catch(t){return"function"==typeof e}}(e))return e;if("function"!=typeof e)throw new TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,r)}function r(){return function(e,t,r){if(h())return Reflect.construct.apply(null,arguments);var i=[null];i.push.apply(i,t);var n=new(e.bind.apply(e,i));return r&&p(n,r.prototype),n}(e,arguments,u(this).constructor)}return r.prototype=Object.create(e.prototype,{constructor:{value:r,enumerable:!1,writable:!0,configurable:!0}}),p(r,e)},E(e)}function T(e){return e instanceof ArrayBuffer||"undefined"!=typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer}var S="utf-16",A="utf-16be",I="utf-16le",L="utf-8";function R(e,t){if(e<0)return-R(-e,t);var r=Math.pow(10,t);if(Math.abs(e*r%1-.5)<Number.EPSILON){var i=Math.floor(e*r);return(i%2==0?i:i+1)/r}return Math.round(e*r)/r}function b(){try{return crypto.randomUUID()}catch(i){try{var e=URL.createObjectURL(new Blob),t=e.toString();return URL.revokeObjectURL(e),t.slice(t.lastIndexOf("/")+1)}catch(e){var r=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var t=(r+16*Math.random())%16|0;return r=Math.floor(r/16),("x"==e?t:3&t|8).toString(16)}))}}}function k(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var D,P,_={exports:{}},C=(D||(D=1,function(e){var t=Object.prototype.hasOwnProperty,r="~";function i(){}function n(e,t,r){this.fn=e,this.context=t,this.once=r||!1}function a(e,t,i,a,s){if("function"!=typeof i)throw new TypeError("The listener must be a function");var o=new n(i,a||e,s),l=r?r+t:t;return e._events[l]?e._events[l].fn?e._events[l]=[e._events[l],o]:e._events[l].push(o):(e._events[l]=o,e._eventsCount++),e}function s(e,t){0==--e._eventsCount?e._events=new i:delete e._events[t]}function o(){this._events=new i,this._eventsCount=0}Object.create&&(i.prototype=Object.create(null),(new i).__proto__||(r=!1)),o.prototype.eventNames=function(){var e,i,n=[];if(0===this._eventsCount)return n;for(i in e=this._events)t.call(e,i)&&n.push(r?i.slice(1):i);return Object.getOwnPropertySymbols?n.concat(Object.getOwnPropertySymbols(e)):n},o.prototype.listeners=function(e){var t=r?r+e:e,i=this._events[t];if(!i)return[];if(i.fn)return[i.fn];for(var n=0,a=i.length,s=new Array(a);n<a;n++)s[n]=i[n].fn;retLine truncated
|
||||||
|
//# sourceMappingURL=hls.min.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e(require("JSZip")):"function"==typeof define&&define.amd?define(["JSZip"],e):"object"==typeof exports?exports.ePub=e(require("JSZip")):t.ePub=e(t.JSZip)}(window,(function(t){return function(t){var e={};function i(n){if(e[n])return e[n].exports;var s=e[n]={i:n,l:!1,exports:{}};return t[n].call(s.exports,s,s.exports,i),s.l=!0,s.exports}return i.m=t,i.c=e,i.d=function(t,e,n){i.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:n})},i.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},i.t=function(t,e){if(1&e&&(t=i(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var n=Object.create(null);if(i.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var s in t)i.d(n,s,function(e){return t[e]}.bind(null,s));return n},i.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return i.d(e,"a",e),e},i.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},i.p="/dist/",i(i.s=30)}([function(t,e,i){"use strict";i.r(e),i.d(e,"requestAnimationFrame",(function(){return s})),i.d(e,"uuid",(function(){return o})),i.d(e,"documentHeight",(function(){return a})),i.d(e,"isElement",(function(){return h})),i.d(e,"isNumber",(function(){return l})),i.d(e,"isFloat",(function(){return c})),i.d(e,"prefixed",(function(){return u})),i.d(e,"defaults",(function(){return d})),i.d(e,"extend",(function(){return f})),i.d(e,"insert",(function(){return p})),i.d(e,"locationOf",(function(){return g})),i.d(e,"indexOfSorted",(function(){return m})),i.d(e,"bounds",(function(){return v})),i.d(e,"borders",(function(){return y})),i.d(e,"nodeBounds",(function(){return b})),i.d(e,"windowBounds",(function(){return w})),i.d(e,"indexOfNode",(function(){return x})),i.d(e,"indexOfTextNode",(function(){return E})),i.d(e,"indexOfElementNode",(function(){return S})),i.d(e,"isXml",(function(){return N})),i.d(e,"createBlob",(function(){return _})),i.d(e,"createBlobUrl",(function(){return T})),i.d(e,"revokeBlobUrl",(function(){return C})),i.d(e,"createBase64Url",(function(){return O})),i.d(e,"type",(function(){return I})),i.d(e,"parse",(function(){return R})),i.d(e,"qs",(function(){return k})),i.d(e,"qsa",(function(){return A})),i.d(e,"qsp",(function(){return L})),i.d(e,"sprint",(function(){return j})),i.d(e,"treeWalker",(function(){return D})),i.d(e,"walk",(function(){return P})),i.d(e,"blob2base64",(function(){return M})),i.d(e,"defer",(function(){return z})),i.d(e,"querySelectorByType",(function(){return B})),i.d(e,"findChildren",(function(){return q})),i.d(e,"parents",(function(){return F})),i.d(e,"filterChildren",(function(){return U})),i.d(e,"getParentByTagName",(function(){return W})),i.d(e,"RangeObject",(function(){return H}));var n=i(15);const s="undefined"!=typeof window&&(window.requestAnimationFrame||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame||window.msRequestAnimationFrame),r="undefined"!=typeof URL?URL:"undefined"!=typeof window?window.URL||window.webkitURL||window.mozURL:void 0;function o(){var t=(new Date).getTime();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,(function(e){var i=(t+16*Math.random())%16|0;return t=Math.floor(t/16),("x"==e?i:7&i|8).toString(16)}))}function a(){return Math.max(document.documentElement.clientHeight,document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight)}function h(t){return!(!t||1!=t.nodeType)}function l(t){return!isNaN(parseFloat(t))&&isFinite(t)}function c(t){let e=parseFloat(t);return!1!==l(t)&&("string"==typeof t&&t.indexOf(".")>-1||Math.floor(e)!==e)}function u(t){var e=["-webkit-","-webkit-","-moz-","-o-","-ms-"],i=t.toLowerCase(),n=["Webkit","webkit","Moz","O","ms"].length;if("undefined"==typeof document||void 0!==document.body.style[i])return t;for(var s=0;s<n;s++)if(void 0!==document.body.style[e[s]+i])return e[s]+i;return t}function d(t){for(var e=1,i=arguments.length;e<i;e++){var n=arguments[e];for(var s in n)void 0===t[s]&&(t[s]=n[s])}return t}function f(t){var e=[].slice.call(arguments,1);return e.forEach((function(e){e&&Object.getOwnPropertyNames(e).forEach((function(i){Object.defineProperty(t,i,Object.getOwnPropertyDescriptor(e,i))}))})),t}function p(t,e,i){var n=g(t,e,i);return e.splice(n,0,t),n}function g(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?h:(r=i(e[h],t),a-o==1?r>=0?h:h+1:0===r?h:-1===r?g(t,e,i,h,a):g(t,e,i,o,h))}function m(t,e,i,n,s){var r,o=n||0,a=s||e.length,h=parseInt(o+(a-o)/2);return i||(i=function(t,e){return t>e?1:t<e?-1:t==e?0:void 0}),a-o<=0?-1:(r=i(e[h],t),a-o==1?0===r?h:-1:0===r?h:-1===r?m(t,e,i,h,a):m(t,e,i,o,h))}function v(t){var e=window.getComputedStyle(t),i=0,n=0;return["width","padLine truncated
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/*!
|
||||||
|
|
||||||
|
JSZip v3.10.1 - A JavaScript class for generating and reading zip files
|
||||||
|
<http://stuartk.com/jszip>
|
||||||
|
|
||||||
|
(c) 2009-2016 Stuart Knightley <stuart [at] stuartk.com>
|
||||||
|
Dual licenced under the MIT license or GPLv3. See https://raw.github.com/Stuk/jszip/main/LICENSE.markdown.
|
||||||
|
|
||||||
|
JSZip uses the library pako released under the MIT license :
|
||||||
|
https://github.com/nodeca/pako/blob/main/LICENSE
|
||||||
|
*/
|
||||||
|
|
||||||
|
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=e()}}(function(){return function s(a,o,h){function u(r,e){if(!o[r]){if(!a[r]){var t="function"==typeof require&&require;if(!e&&t)return t(r,!0);if(l)return l(r,!0);var n=new Error("Cannot find module '"+r+"'");throw n.code="MODULE_NOT_FOUND",n}var i=o[r]={exports:{}};a[r][0].call(i.exports,function(e){var t=a[r][1][e];return u(t||e)},i,i.exports,s,a,o,h)}return o[r].exports}for(var l="function"==typeof require&&require,e=0;e<h.length;e++)u(h[e]);return u}({1:[function(e,t,r){"use strict";var d=e("./utils"),c=e("./support"),p="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";r.encode=function(e){for(var t,r,n,i,s,a,o,h=[],u=0,l=e.length,f=l,c="string"!==d.getTypeOf(e);u<e.length;)f=l-u,n=c?(t=e[u++],r=u<l?e[u++]:0,u<l?e[u++]:0):(t=e.charCodeAt(u++),r=u<l?e.charCodeAt(u++):0,u<l?e.charCodeAt(u++):0),i=t>>2,s=(3&t)<<4|r>>4,a=1<f?(15&r)<<2|n>>6:64,o=2<f?63&n:64,h.push(p.charAt(i)+p.charAt(s)+p.charAt(a)+p.charAt(o));return h.join("")},r.decode=function(e){var t,r,n,i,s,a,o=0,h=0,u="data:";if(e.substr(0,u.length)===u)throw new Error("Invalid base64 input, it looks like a data url.");var l,f=3*(e=e.replace(/[^A-Za-z0-9+/=]/g,"")).length/4;if(e.charAt(e.length-1)===p.charAt(64)&&f--,e.charAt(e.length-2)===p.charAt(64)&&f--,f%1!=0)throw new Error("Invalid base64 input, bad content length.");for(l=c.uint8array?new Uint8Array(0|f):new Array(0|f);o<e.length;)t=p.indexOf(e.charAt(o++))<<2|(i=p.indexOf(e.charAt(o++)))>>4,r=(15&i)<<4|(s=p.indexOf(e.charAt(o++)))>>2,n=(3&s)<<6|(a=p.indexOf(e.charAt(o++))),l[h++]=t,64!==s&&(l[h++]=r),64!==a&&(l[h++]=n);return l}},{"./support":30,"./utils":32}],2:[function(e,t,r){"use strict";var n=e("./external"),i=e("./stream/DataWorker"),s=e("./stream/Crc32Probe"),a=e("./stream/DataLengthProbe");function o(e,t,r,n,i){this.compressedSize=e,this.uncompressedSize=t,this.crc32=r,this.compression=n,this.compressedContent=i}o.prototype={getContentWorker:function(){var e=new i(n.Promise.resolve(this.compressedContent)).pipe(this.compression.uncompressWorker()).pipe(new a("data_length")),t=this;return e.on("end",function(){if(this.streamInfo.data_length!==t.uncompressedSize)throw new Error("Bug : uncompressed data size mismatch")}),e},getCompressedWorker:function(){return new i(n.Promise.resolve(this.compressedContent)).withStreamInfo("compressedSize",this.compressedSize).withStreamInfo("uncompressedSize",this.uncompressedSize).withStreamInfo("crc32",this.crc32).withStreamInfo("compression",this.compression)}},o.createWorkerFrom=function(e,t,r){return e.pipe(new s).pipe(new a("uncompressedSize")).pipe(t.compressWorker(r)).pipe(new a("compressedSize")).withStreamInfo("compression",t)},t.exports=o},{"./external":6,"./stream/Crc32Probe":25,"./stream/DataLengthProbe":26,"./stream/DataWorker":27}],3:[function(e,t,r){"use strict";var n=e("./stream/GenericWorker");r.STORE={magic:"\0\0",compressWorker:function(){return new n("STORE compression")},uncompressWorker:function(){return new n("STORE decompression")}},r.DEFLATE=e("./flate")},{"./flate":7,"./stream/GenericWorker":28}],4:[function(e,t,r){"use strict";var n=e("./utils");var o=function(){for(var e,t=[],r=0;r<256;r++){e=r;for(var n=0;n<8;n++)e=1&e?3988292384^e>>>1:e>>>1;t[r]=e}return t}();t.exports=function(e,t){return void 0!==e&&e.length?"string"!==n.getTypeOf(e)?function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t[a])];return-1^e}(0|t,e,e.length,0):function(e,t,r,n){var i=o,s=n+r;e^=-1;for(var a=n;a<s;a++)e=e>>>8^i[255&(e^t.charCodeAt(a))];return-1^e}(0|t,e,e.length,0):0}},{"./utils":32}],5:[function(e,t,r){"use strict";r.base64=!1,r.binary=!1,r.dir=!1,r.createFolders=!0,r.date=null,r.compression=null,r.compressionOptions=null,r.comment=null,r.unixPermissions=null,r.dosPermissions=null},{}],6:[function(e,t,r){"use strict";var n=null;n="undefined"!=typeof Promise?Promise:e("lie"),t.exports={Promise:n}},{lie:37}],7:[function(e,t,r){"use strict";var n="undefined"!=typeof Uint8Array&&"undefined"!=typeof Uint16Array&&"undefined"!=typeof Uint32Array,i=e("pako"),s=e("./utils"),a=e("./stream/GenericWorker"),o=n?"uint8array":"array";function h(e,t){a.call(this,"FlateWorker/"+e),this._pako=null,this._pakoAction=e,this._pakoOptions=t,this.meta={}}r.magic="\b\0",s.inherits(h,a),h.prototype.processChunk=function(e){this.meta=e.meta,null===this._pako&&this._createPako(),this._pako.push(s.transformTo(o,e.data),!1)},h.prototype.flush=function(){a.prototype.flush.call(this),null===this._pako&&this._createPako(),this._pako.push([],!0)},h.prototype.cleanUp=function(){a.prototype.cleanUp.call(this),this._pako=null},h.prototype._createPako=function(){this._pako=new i[this._pakoAction]({raw:!0,level:this._pakoOptions.level||-1});var t=this;this._pako.onData=function(Line truncated
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
!function(f){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=f();else if("function"==typeof define&&define.amd)define([],f);else{var g;g="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,g.mammoth=f()}}(function(){var define,module,exports;return function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a="function"==typeof require&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}for(var i="function"==typeof require&&require,o=0;o<r.length;o++)s(r[o]);return s}({1:[function(require,module,exports){function Files(){function read(uri){return promises.reject(new Error("could not open external image: '"+uri+"'\ncannot open linked files from a web browser"))}return{read:read}}var promises=require("../../lib/promises");exports.Files=Files},{"../../lib/promises":23}],2:[function(require,module,exports){function openZip(options){return options.arrayBuffer?promises.resolve(zipfile.openArrayBuffer(options.arrayBuffer)):promises.reject(new Error("Could not find file in options"))}var promises=require("../lib/promises"),zipfile=require("../lib/zipfile");exports.openZip=openZip},{"../lib/promises":23,"../lib/zipfile":40}],3:[function(require,module,exports){function DocumentConverter(options){return{convertToHtml:function(element){var comments=_.indexBy(element.type===documents.types.document?element.comments:[],"commentId"),conversion=new DocumentConversion(options,comments);return conversion.convertToHtml(element)}}}function DocumentConversion(options,comments){function convertToHtml(document){var messages=[],html=elementToHtml(document,messages,{}),deferredNodes=[];walkHtml(html,function(node){"deferred"===node.type&&deferredNodes.push(node)});var deferredValues={};return promises.mapSeries(deferredNodes,function(deferred){return deferred.value().then(function(value){deferredValues[deferred.id]=value})}).then(function(){function replaceDeferred(nodes){return flatMap(nodes,function(node){return"deferred"===node.type?deferredValues[node.id]:node.children?[_.extend({},node,{children:replaceDeferred(node.children)})]:[node]})}var writer=writers.writer({prettyPrint:options.prettyPrint,outputFormat:options.outputFormat});return Html.write(writer,Html.simplify(replaceDeferred(html))),new results.Result(writer.asString(),messages)})}function convertElements(elements,messages,options){return flatMap(elements,function(element){return elementToHtml(element,messages,options)})}function elementToHtml(element,messages,options){if(!options)throw new Error("options not set");var handler=elementConverters[element.type];return handler?handler(element,messages,options):[]}function convertParagraph(element,messages,options){return htmlPathForParagraph(element,messages).wrap(function(){var content=convertElements(element.children,messages,options);return ignoreEmptyParagraphs?content:[Html.forceWrite].concat(content)})}function htmlPathForParagraph(element,messages){var style=findStyle(element);return style?style.to:(element.styleId&&messages.push(unrecognisedStyleWarning("paragraph",element)),defaultParagraphStyle)}function convertRun(run,messages,options){var nodes=function(){return convertElements(run.children,messages,options)},paths=[];if(null!==run.highlight){var path=findHtmlPath({type:"highlight",color:run.highlight});path&&paths.push(path)}run.isSmallCaps&&paths.push(findHtmlPathForRunProperty("smallCaps")),run.isAllCaps&&paths.push(findHtmlPathForRunProperty("allCaps")),run.isStrikethrough&&paths.push(findHtmlPathForRunProperty("strikethrough","s")),run.isUnderline&&paths.push(findHtmlPathForRunProperty("underline")),run.verticalAlignment===documents.verticalAlignment.subscript&&paths.push(htmlPaths.element("sub",{},{fresh:!1})),run.verticalAlignment===documents.verticalAlignment.superscript&&paths.push(htmlPaths.element("sup",{},{fresh:!1})),run.isItalic&&paths.push(findHtmlPathForRunProperty("italic","em")),run.isBold&&paths.push(findHtmlPathForRunProperty("bold","strong"));var stylePath=htmlPaths.empty,style=findStyle(run);return style?stylePath=style.to:run.styleId&&messages.push(unrecognisedStyleWarning("run",run)),paths.push(stylePath),paths.forEach(function(path){nodes=path.wrap.bind(path,nodes)}),nodes()}function findHtmlPathForRunProperty(elementType,defaultTagName){var path=findHtmlPath({type:elementType});return path?path:defaultTagName?htmlPaths.element(defaultTagName,{},{fresh:!1}):htmlPaths.empty}function findHtmlPath(element,defaultPath){var style=findStyle(element);return style?style.to:defaultPath}function findStyle(element){for(var i=0;i<styleMap.length;i++)if(styleMap[i].from.matches(element))return styleMap[i]}function recoveringConvertImage(convertImage){return function(image,messages){return promises.attempt(functionLine truncated
|
||||||
|
return new ReadResult(func(first.value,second.value),joinElements(first.extra,second.extra),first.messages.concat(second.messages))}},{"../documents":4,"../results":25,"./uris":16,"dingbat-to-unicode":85,underscore:102}],6:[function(require,module,exports){function createCommentsReader(bodyReader){function readCommentsXml(element){return Result.combine(element.getElementsByTagName("w:comment").map(readCommentElement))}function readCommentElement(element){function readOptionalAttribute(name){return(element.attributes[name]||"").trim()||null}var id=element.attributes["w:id"];return bodyReader.readXmlElements(element.children).map(function(body){return documents.comment({commentId:id,body:body,authorName:readOptionalAttribute("w:author"),authorInitials:readOptionalAttribute("w:initials")})})}return readCommentsXml}var documents=require("../documents"),Result=require("../results").Result;exports.createCommentsReader=createCommentsReader},{"../documents":4,"../results":25}],7:[function(require,module,exports){function readContentTypesFromXml(element){var extensionDefaults={},overrides={};return element.children.forEach(function(child){if("content-types:Default"===child.name&&(extensionDefaults[child.attributes.Extension]=child.attributes.ContentType),"content-types:Override"===child.name){var name=child.attributes.PartName;"/"===name.charAt(0)&&(name=name.substring(1)),overrides[name]=child.attributes.ContentType}}),contentTypes(overrides,extensionDefaults)}function contentTypes(overrides,extensionDefaults){return{findContentType:function(path){var overrideContentType=overrides[path];if(overrideContentType)return overrideContentType;var pathParts=path.split("."),extension=pathParts[pathParts.length-1];if(extensionDefaults.hasOwnProperty(extension))return extensionDefaults[extension];var fallback=fallbackContentTypes[extension.toLowerCase()];return fallback?"image/"+fallback:null}}}exports.readContentTypesFromXml=readContentTypesFromXml;var fallbackContentTypes={png:"png",gif:"gif",jpeg:"jpeg",jpg:"jpeg",tif:"tiff",tiff:"tiff",bmp:"bmp"};exports.defaultContentTypes=contentTypes({},{})},{}],8:[function(require,module,exports){function DocumentXmlReader(options){function convertXmlToDocument(element){var body=element.first("w:body");if(null==body)throw new Error("Could not find the body element: are you sure this is a docx file?");var result=bodyReader.readXmlElements(body.children).map(function(children){return new documents.Document(children,{notes:options.notes,comments:options.comments})});return new Result(result.value,result.messages)}var bodyReader=options.bodyReader;return{convertXmlToDocument:convertXmlToDocument}}exports.DocumentXmlReader=DocumentXmlReader;var documents=require("../documents"),Result=require("../results").Result},{"../documents":4,"../results":25}],9:[function(require,module,exports){function read(docxFile,input){return input=input||{},promises.props({contentTypes:readContentTypesFromZipFile(docxFile),partPaths:findPartPaths(docxFile),docxFile:docxFile,files:input.path?Files.relativeToFile(input.path):new Files(null)}).also(function(result){return{styles:readStylesFromZipFile(docxFile,result.partPaths.styles)}}).also(function(result){return{numbering:readNumberingFromZipFile(docxFile,result.partPaths.numbering,result.styles)}}).also(function(result){return{footnotes:readXmlFileWithBody(result.partPaths.footnotes,result,function(bodyReader,xml){return xml?notesReader.createFootnotesReader(bodyReader)(xml):new Result([])}),endnotes:readXmlFileWithBody(result.partPaths.endnotes,result,function(bodyReader,xml){return xml?notesReader.createEndnotesReader(bodyReader)(xml):new Result([])}),comments:readXmlFileWithBody(result.partPaths.comments,result,function(bodyReader,xml){return xml?commentsReader.createCommentsReader(bodyReader)(xml):new Result([])})}}).also(function(result){return{notes:result.footnotes.flatMap(function(footnotes){return result.endnotes.map(function(endnotes){return new documents.Notes(footnotes.concat(endnotes))})})}}).then(function(result){return readXmlFileWithBody(result.partPaths.mainDocument,result,function(bodyReader,xml){return result.notes.flatMap(function(notes){return result.comments.flatMap(function(comments){var reader=new DocumentXmlReader({bodyReader:bodyReader,notes:notes,comments:comments});return reader.convertXmlToDocument(xml)})})})})}function findPartPaths(docxFile){return readPackageRelationships(docxFile).then(function(packageRelationships){var mainDocumentPath=findPartPath({docxFile:docxFile,relationships:packageRelationships,relationshipType:"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",basePath:"",fallbackPath:"word/document.xml"});if(!docxFile.exists(mainDocumentPath))throw new Error("Could not find main document part. Are you sure this is a valid .docx file?");return xmlFileReader({filename:relationshipsFilename(mainDocumentPath),readElement:relationshipsReader.readRelationships,defaultValue:relationshipsReLine truncated
|
||||||
|
return{from:documentMatcher,to:htmlPath.valueOrElse(htmlPaths.empty)}})}function readDocumentMatcher(string){return parseString(documentMatcherRule(),string)}function documentMatcherRule(){function createMatcherSuffixesRule(rules){var matcherSuffix=lop.rules.firstOf.apply(lop.rules.firstOf,["matcher suffix"].concat(rules)),matcherSuffixes=lop.rules.zeroOrMore(matcherSuffix);return lop.rules.then(matcherSuffixes,function(suffixes){var matcherOptions={};return suffixes.forEach(function(suffix){_.extend(matcherOptions,suffix)}),matcherOptions})}var sequence=lop.rules.sequence,identifierToConstant=function(identifier,constant){return lop.rules.then(lop.rules.token("identifier",identifier),function(){return constant})},paragraphRule=identifierToConstant("p",documentMatchers.paragraph),runRule=identifierToConstant("r",documentMatchers.run),elementTypeRule=lop.rules.firstOf("p or r or table",paragraphRule,runRule),styleIdRule=lop.rules.sequence(lop.rules.tokenOfType("dot"),lop.rules.sequence.cut(),lop.rules.sequence.capture(identifierRule)).map(function(styleId){return{styleId:styleId}}),styleNameMatcherRule=lop.rules.firstOf("style name matcher",lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("equals"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.equalTo(styleName)}}),lop.rules.then(lop.rules.sequence(lop.rules.tokenOfType("startsWith"),lop.rules.sequence.cut(),lop.rules.sequence.capture(stringRule)).head(),function(styleName){return{styleName:documentMatchers.startsWith(styleName)}})),styleNameRule=lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","style-name"),lop.rules.sequence.capture(styleNameMatcherRule),lop.rules.tokenOfType("close-square-bracket")).head(),listTypeRule=lop.rules.firstOf("list type",identifierToConstant("ordered-list",{isOrdered:!0}),identifierToConstant("unordered-list",{isOrdered:!1})),listRule=sequence(lop.rules.tokenOfType("colon"),sequence.capture(listTypeRule),sequence.cut(),lop.rules.tokenOfType("open-paren"),sequence.capture(integerRule),lop.rules.tokenOfType("close-paren")).map(function(listType,levelNumber){return{list:{isOrdered:listType.isOrdered,levelIndex:levelNumber-1}}}),paragraphOrRun=sequence(sequence.capture(elementTypeRule),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule,listRule]))).map(function(createMatcher,matcherOptions){return createMatcher(matcherOptions)}),table=sequence(lop.rules.token("identifier","table"),sequence.capture(createMatcherSuffixesRule([styleIdRule,styleNameRule]))).map(function(options){return documentMatchers.table(options)}),bold=identifierToConstant("b",documentMatchers.bold),italic=identifierToConstant("i",documentMatchers.italic),underline=identifierToConstant("u",documentMatchers.underline),strikethrough=identifierToConstant("strike",documentMatchers.strikethrough),allCaps=identifierToConstant("all-caps",documentMatchers.allCaps),smallCaps=identifierToConstant("small-caps",documentMatchers.smallCaps),highlight=sequence(lop.rules.token("identifier","highlight"),lop.rules.sequence.capture(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("open-square-bracket"),lop.rules.sequence.cut(),lop.rules.token("identifier","color"),lop.rules.tokenOfType("equals"),lop.rules.sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).head()))).map(function(color){return documentMatchers.highlight({color:color.valueOrElse(void 0)})}),commentReference=identifierToConstant("comment-reference",documentMatchers.commentReference),breakMatcher=sequence(lop.rules.token("identifier","br"),sequence.cut(),lop.rules.tokenOfType("open-square-bracket"),lop.rules.token("identifier","type"),lop.rules.tokenOfType("equals"),sequence.capture(stringRule),lop.rules.tokenOfType("close-square-bracket")).map(function(breakType){switch(breakType){case"line":return documentMatchers.lineBreak;case"page":return documentMatchers.pageBreak;case"column":return documentMatchers.columnBreak}});return lop.rules.firstOf("element type",paragraphOrRun,table,bold,italic,underline,strikethrough,allCaps,smallCaps,highlight,commentReference,breakMatcher)}function readHtmlPath(string){return parseString(htmlPathRule(),string)}function htmlPathRule(){var capture=lop.rules.sequence.capture,whitespaceRule=lop.rules.tokenOfType("whitespace"),freshRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","fresh"))),function(option){return option.map(function(){return!0}).valueOrElse(!1)}),separatorRule=lop.rules.then(lop.rules.optional(lop.rules.sequence(lop.rules.tokenOfType("colon"),lop.rules.token("identifier","separator"),lop.rules.tokenOfType("open-paren"),capture(stringRule),lop.rules.tokenOfType("close-paren")).head()),function(option){return option.valueOrElse("")}),tagNamesRule=lop.rules.oneOrMoreWithSeparator(identifierRule,lop.ruLine truncated
|
||||||
|
},{"./conventions":41,"./dom":43,"./entities":44,"./sax":46}],43:[function(require,module,exports){function notEmptyString(input){return""!==input}function splitOnASCIIWhitespace(input){return input?input.split(/[\t\n\f\r ]+/).filter(notEmptyString):[]}function orderedSetReducer(current,element){return current.hasOwnProperty(element)||(current[element]=!0),current}function toOrderedSet(input){if(!input)return[];var list=splitOnASCIIWhitespace(input);return Object.keys(list.reduce(orderedSetReducer,{}))}function arrayIncludes(list){return function(element){return list&&-1!==list.indexOf(element)}}function copy(src,dest){for(var p in src)Object.prototype.hasOwnProperty.call(src,p)&&(dest[p]=src[p])}function _extends(Class,Super){function t(){}var pt=Class.prototype;pt instanceof Super||(t.prototype=Super.prototype,t=new t,copy(pt,t),Class.prototype=pt=t),pt.constructor!=Class&&("function"!=typeof Class&&console.error("unknown Class:"+Class),pt.constructor=Class)}function DOMException(code,message){if(message instanceof Error)var error=message;else error=this,Error.call(this,ExceptionMessage[code]),this.message=ExceptionMessage[code],Error.captureStackTrace&&Error.captureStackTrace(this,DOMException);return error.code=code,message&&(this.message=this.message+": "+message),error}function NodeList(){}function LiveNodeList(node,refresh){this._node=node,this._refresh=refresh,_updateLiveList(this)}function _updateLiveList(list){var inc=list._node._inc||list._node.ownerDocument._inc;if(list._inc!=inc){var ls=list._refresh(list._node);__set__(list,"length",ls.length),copy(ls,list),list._inc=inc}}function NamedNodeMap(){}function _findNodeIndex(list,node){for(var i=list.length;i--;)if(list[i]===node)return i}function _addNamedNode(el,list,newAttr,oldAttr){if(oldAttr?list[_findNodeIndex(list,oldAttr)]=newAttr:list[list.length++]=newAttr,el){newAttr.ownerElement=el;var doc=el.ownerDocument;doc&&(oldAttr&&_onRemoveAttribute(doc,el,oldAttr),_onAddAttribute(doc,el,newAttr))}}function _removeNamedNode(el,list,attr){var i=_findNodeIndex(list,attr);if(!(i>=0))throw new DOMException(NOT_FOUND_ERR,new Error(el.tagName+"@"+attr));for(var lastIndex=list.length-1;lastIndex>i;)list[i]=list[++i];if(list.length=lastIndex,el){var doc=el.ownerDocument;doc&&(_onRemoveAttribute(doc,el,attr),attr.ownerElement=null)}}function DOMImplementation(){}function Node(){}function _xmlEncoder(c){return"<"==c&&"<"||">"==c&&">"||"&"==c&&"&"||'"'==c&&"""||"&#"+c.charCodeAt()+";"}function _visitNode(node,callback){if(callback(node))return!0;if(node=node.firstChild)do if(_visitNode(node,callback))return!0;while(node=node.nextSibling)}function Document(){this.ownerDocument=this}function _onAddAttribute(doc,el,newAttr){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&(el._nsMap[newAttr.prefix?newAttr.localName:""]=newAttr.value)}function _onRemoveAttribute(doc,el,newAttr,remove){doc&&doc._inc++;var ns=newAttr.namespaceURI;ns===NAMESPACE.XMLNS&&delete el._nsMap[newAttr.prefix?newAttr.localName:""]}function _onUpdateChild(doc,el,newChild){if(doc&&doc._inc){doc._inc++;var cs=el.childNodes;if(newChild)cs[cs.length++]=newChild;else{for(var child=el.firstChild,i=0;child;)cs[i++]=child,child=child.nextSibling;cs.length=i,delete cs[cs.length]}}}function _removeChild(parentNode,child){var previous=child.previousSibling,next=child.nextSibling;return previous?previous.nextSibling=next:parentNode.firstChild=next,next?next.previousSibling=previous:parentNode.lastChild=previous,child.parentNode=null,child.previousSibling=null,child.nextSibling=null,_onUpdateChild(parentNode.ownerDocument,parentNode),child}function hasValidParentNodeType(node){return node&&(node.nodeType===Node.DOCUMENT_NODE||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.ELEMENT_NODE)}function hasInsertableNodeType(node){return node&&(isElementNode(node)||isTextNode(node)||isDocTypeNode(node)||node.nodeType===Node.DOCUMENT_FRAGMENT_NODE||node.nodeType===Node.COMMENT_NODE||node.nodeType===Node.PROCESSING_INSTRUCTION_NODE)}function isDocTypeNode(node){return node&&node.nodeType===Node.DOCUMENT_TYPE_NODE}function isElementNode(node){return node&&node.nodeType===Node.ELEMENT_NODE}function isTextNode(node){return node&&node.nodeType===Node.TEXT_NODE}function isElementInsertionPossible(doc,child){var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,isElementNode)||isDocTypeNode(child))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function isElementReplacementPossible(doc,child){function hasElementChildThatIsNotChild(node){return isElementNode(node)&&node!==child}var parentChildNodes=doc.childNodes||[];if(find(parentChildNodes,hasElementChildThatIsNotChild))return!1;var docTypeNode=find(parentChildNodes,isDocTypeNode);return!(child&&docTypeNode&&parentChildNodes.indexOf(docTypeNode)>parentChildNodes.indexOf(child))}function assertPreLine truncated
|
||||||
|
circ:"ˆ",tilde:"˜",ensp:" ",emsp:" ",thinsp:" ",zwnj:"",zwj:"",lrm:"",rlm:"",ndash:"–",mdash:"—",lsquo:"‘",rsquo:"’",sbquo:"‚",ldquo:"“",rdquo:"”",bdquo:"„",dagger:"†",Dagger:"‡",bull:"•",hellip:"…",permil:"‰",prime:"′",Prime:"″",lsaquo:"‹",rsaquo:"›",oline:"‾",euro:"€",trade:"™",larr:"←",uarr:"↑",rarr:"→",darr:"↓",harr:"↔",crarr:"↵",lceil:"⌈",rceil:"⌉",lfloor:"⌊",rfloor:"⌋",loz:"◊",spades:"♠",clubs:"♣",hearts:"♥",diams:"♦"}),exports.entityMap=exports.HTML_ENTITIES},{"./conventions":41}],45:[function(require,module,exports){var dom=require("./dom");exports.DOMImplementation=dom.DOMImplementation,exports.XMLSerializer=dom.XMLSerializer,exports.DOMParser=require("./dom-parser").DOMParser},{"./dom":43,"./dom-parser":42}],46:[function(require,module,exports){function ParseError(message,locator){this.message=message,this.locator=locator,Error.captureStackTrace&&Error.captureStackTrace(this,ParseError)}function XMLReader(){}function parse(source,defaultNSMapCopy,entityMap,domBuilder,errorHandler){function fixedFromCharCode(code){if(code>65535){code-=65536;var surrogate1=55296+(code>>10),surrogate2=56320+(1023&code);return String.fromCharCode(surrogate1,surrogate2)}return String.fromCharCode(code)}function entityReplacer(a){var k=a.slice(1,-1);return Object.hasOwnProperty.call(entityMap,k)?entityMap[k]:"#"===k.charAt(0)?fixedFromCharCode(parseInt(k.substr(1).replace("x","0x"))):(errorHandler.error("entity not found:"+a),a)}function appendText(end){if(end>start){var xt=source.substring(start,end).replace(/&#?\w+;/g,entityReplacer);locator&&position(start),domBuilder.characters(xt,0,end-start),start=end}}function position(p,m){for(;p>=lineEnd&&(m=linePattern.exec(source));)lineStart=m.index,lineEnd=lineStart+m[0].length,locator.lineNumber++;locator.columnNumber=p-lineStart+1}for(var lineStart=0,lineEnd=0,linePattern=/.*(?:\r\n?|\n)|.*$/g,locator=domBuilder.locator,parseStack=[{currentNSMap:defaultNSMapCopy}],closeMap={},start=0;;){try{var tagStart=source.indexOf("<",start);if(0>tagStart){if(!source.substr(start).match(/^\s*$/)){var doc=domBuilder.doc,text=doc.createTextNode(source.substr(start));doc.appendChild(text),domBuilder.currentElement=text}return}switch(tagStart>start&&appendText(tagStart),source.charAt(tagStart+1)){case"/":var end=source.indexOf(">",tagStart+3),tagName=source.substring(tagStart+2,end).replace(/[ \t\n\r]+$/g,""),config=parseStack.pop();0>end?(tagName=source.substring(tagStart+2).replace(/[\s<].*/,""),errorHandler.error("end tag name: "+tagName+" is not complete:"+config.tagName),end=tagStart+1+tagName.length):tagName.match(/\s</)&&(tagName=tagName.replace(/[\s<].*/,""),errorHandler.error("end tag name: "+tagName+" maybe not complete"),end=tagStart+1+tagName.length);var localNSMap=config.localNSMap,endMatch=config.tagName==tagName,endIgnoreCaseMach=endMatch||config.tagName&&config.tagName.toLowerCase()==tagName.toLowerCase();if(endIgnoreCaseMach){if(domBuilder.endElement(config.uri,config.localName,tagName),localNSMap)for(var prefix in localNSMap)Object.prototype.hasOwnProperty.call(localNSMap,prefix)&&domBuilder.endPrefixMapping(prefix);endMatch||errorHandler.fatalError("end tag name: "+tagName+" is not match the current start tagName:"+config.tagName)}else parseStack.push(config);end++;break;case"?":locator&&position(tagStart),end=parseInstruction(source,tagStart,domBuilder);break;case"!":locator&&position(tagStart),end=parseDCC(source,tagStart,domBuilder,errorHandler);break;default:locator&&position(tagStart);var el=new ElementAttributes,currentNSMap=parseStack[parseStack.length-1].currentNSMap,end=parseElementStartPart(source,tagStart,el,currentNSMap,entityReplacer,errorHandler),len=el.length;if(!el.closed&&fixSelfClosed(source,end,el.tagName,closeMap)&&(el.closed=!0,entityMap.nbsp||errorHandler.warning("unclosed xml attribute")),locator&&len){for(var locator2=copyLocator(locator,{}),i=0;len>i;i++){var a=el[i];position(a.offset),a.locator=copyLocator(locator,{})}domBuilder.locator=locator2,appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el),domBuilder.locator=locator}else appendElement(el,domBuilder,currentNSMap)&&parseStack.push(el);NAMESPACE.isHTML(el.uri)&&!el.closed?end=parseHtmlSpecialContent(source,end,el.tagName,entityReplacer,domBuilder):end++}}catch(e){if(e instanceof ParseError)throw e;errorHandler.error("element parse error: "+e),end=-1}end>start?start=end:appendText(Math.max(tagStart,start)+1)}}function copyLocator(f,t){return t.lineNumber=f.lineNumber,t.columnNumber=f.columnNumber,t}function parseElementStartPart(source,start,el,currentNSMap,entityReplacer,errorHandler){function addAttribute(qname,value,startIndex){el.attributeNames.hasOwnProperty(qname)&&errorHandler.fatalError("Attribute "+qname+" redefined"),el.addValue(qname,value.replace(/[\t\n\r]/g," ").replace(/&#?\w+;/g,entityReplacer),startIndex)}for(var attrName,value,p=++start,s=S_TAG;;){var c=source.charAt(p);switLine truncated
|
||||||
|
i>0&&(creatorLine="\n"+traceLines[i-1]);break}}var msg="a promise was created in a "+name+"handler "+handlerLine+"but was not returned from it, see http://goo.gl/rRqMUw"+creatorLine;promise._warn(msg,!0,promiseCreated)}}function deprecated(name,replacement){var message=name+" is deprecated and will be removed in a future version.";return replacement&&(message+=" Use "+replacement+" instead."),warn(message)}function warn(message,shouldUseOwnTrace,promise){if(config.warnings){var ctx,warning=new Warning(message);if(shouldUseOwnTrace)promise._attachExtraTrace(warning);else if(config.longStackTraces&&(ctx=Promise._peekContext()))ctx.attachExtraTrace(warning);else{var parsed=parseStackAndMessage(warning);warning.stack=parsed.message+"\n"+parsed.stack.join("\n")}activeFireEvent("warning",warning)||formatAndLogError(warning,"",!0)}}function reconstructStack(message,stacks){for(var i=0;i<stacks.length-1;++i)stacks[i].push("From previous event:"),stacks[i]=stacks[i].join("\n");return i<stacks.length&&(stacks[i]=stacks[i].join("\n")),message+"\n"+stacks.join("\n")}function removeDuplicateOrEmptyJumps(stacks){for(var i=0;i<stacks.length;++i)(0===stacks[i].length||i+1<stacks.length&&stacks[i][0]===stacks[i+1][0])&&(stacks.splice(i,1),i--)}function removeCommonRoots(stacks){for(var current=stacks[0],i=1;i<stacks.length;++i){for(var prev=stacks[i],currentLastIndex=current.length-1,currentLastLine=current[currentLastIndex],commonRootMeetPoint=-1,j=prev.length-1;j>=0;--j)if(prev[j]===currentLastLine){commonRootMeetPoint=j;break}for(var j=commonRootMeetPoint;j>=0;--j){var line=prev[j];if(current[currentLastIndex]!==line)break;current.pop(),currentLastIndex--}current=prev}}function cleanStack(stack){for(var ret=[],i=0;i<stack.length;++i){var line=stack[i],isTraceLine=" (No stack trace)"===line||stackFramePattern.test(line),isInternalFrame=isTraceLine&&shouldIgnore(line);isTraceLine&&!isInternalFrame&&(indentStackFrames&&" "!==line.charAt(0)&&(line=" "+line),ret.push(line))}return ret}function stackFramesAsArray(error){for(var stack=error.stack.replace(/\s+$/g,"").split("\n"),i=0;i<stack.length;++i){var line=stack[i];if(" (No stack trace)"===line||stackFramePattern.test(line))break}return i>0&&"SyntaxError"!=error.name&&(stack=stack.slice(i)),stack}function parseStackAndMessage(error){var stack=error.stack,message=error.toString();return stack="string"==typeof stack&&stack.length>0?stackFramesAsArray(error):[" (No stack trace)"],{message:message,stack:"SyntaxError"==error.name?stack:cleanStack(stack)}}function formatAndLogError(error,title,isSoft){if("undefined"!=typeof console){var message;if(util.isObject(error)){var stack=error.stack;message=title+formatStack(stack,error)}else message=title+String(error);"function"==typeof printWarning?printWarning(message,isSoft):("function"==typeof console.log||"object"==typeof console.log)&&console.log(message)}}function fireRejectionEvent(name,localHandler,reason,promise){var localEventFired=!1;try{"function"==typeof localHandler&&(localEventFired=!0,"rejectionHandled"===name?localHandler(promise):localHandler(reason,promise))}catch(e){async.throwLater(e)}"unhandledRejection"===name?activeFireEvent(name,reason,promise)||localEventFired||formatAndLogError(reason,"Unhandled rejection "):activeFireEvent(name,promise)}function formatNonError(obj){var str;if("function"==typeof obj)str="[function "+(obj.name||"anonymous")+"]";else{str=obj&&"function"==typeof obj.toString?obj.toString():util.toString(obj);var ruselessToString=/\[object [a-zA-Z0-9$_]+\]/;if(ruselessToString.test(str))try{var newStr=JSON.stringify(obj);str=newStr}catch(e){}0===str.length&&(str="(empty array)")}return"(<"+snip(str)+">, no stack trace)"}function snip(str){var maxChars=41;return str.length<maxChars?str:str.substr(0,maxChars-3)+"..."}function longStackTracesIsSupported(){return"function"==typeof captureStackTrace}function parseLineInfo(line){var matches=line.match(parseLineInfoRegex);return matches?{fileName:matches[1],line:parseInt(matches[2],10)}:void 0}function setBounds(firstLineError,lastLineError){if(longStackTracesIsSupported()){for(var firstFileName,lastFileName,firstStackLines=firstLineError.stack.split("\n"),lastStackLines=lastLineError.stack.split("\n"),firstIndex=-1,lastIndex=-1,i=0;i<firstStackLines.length;++i){var result=parseLineInfo(firstStackLines[i]);if(result){firstFileName=result.fileName,firstIndex=result.line;break}}for(var i=0;i<lastStackLines.length;++i){var result=parseLineInfo(lastStackLines[i]);if(result){lastFileName=result.fileName,lastIndex=result.line;break}}0>firstIndex||0>lastIndex||!firstFileName||!lastFileName||firstFileName!==lastFileName||firstIndex>=lastIndex||(shouldIgnore=function(line){if(bluebirdFramePattern.test(line))return!0;var info=parseLineInfo(line);return info&&info.fileName===firstFileName&&firstIndex<=info.line&&info.line<=lastIndex?!0:!1})}}function CapturedTrace(parent){this._parent=parent,this._promisesCreated=0;var length=this._length=1+(void 0===Line truncated
|
||||||
|
},promiseSetter=function(i){return new Function("promise","holder"," \n 'use strict'; \n holder.pIndex = promise; \n ".replace(/Index/g,i))},generateHolderClass=function(total){for(var props=new Array(total),i=0;i<props.length;++i)props[i]="this.p"+(i+1);var assignment=props.join(" = ")+" = null;",cancellationCode="var promise;\n"+props.map(function(prop){return" \n promise = "+prop+"; \n if (promise instanceof Promise) { \n promise.cancel(); \n } \n "}).join("\n"),passedArguments=props.join(", "),name="Holder$"+total,code="return function(tryCatch, errorObj, Promise, async) { \n 'use strict'; \n function [TheName](fn) { \n [TheProperties] \n this.fn = fn; \n this.asyncNeeded = true; \n this.now = 0; \n } \n \n [TheName].prototype._callFunction = function(promise) { \n promise._pushContext(); \n var ret = tryCatch(this.fn)([ThePassedArguments]); \n promise._popContext(); \n if (ret === errorObj) { \n promise._rejectCallback(ret.e, false); \n } else { \n promise._resolveCallback(ret); \n } \n }; \n \n [TheName].prototype.checkFulfillment = function(promise) { \n var now = ++this.now; \n if (now === [TheTotal]) { \n if (this.asyncNeeded) { \n async.invoke(this._callFunction, this, promise); \n } else { \n this._callFunction(promise); \n } \n \n } \n }; \n \n [TheName].prototype._resultCancelled = function() { \n [CancellationCode] \n }; \n \n return [TheName]; \n }(tryCatch, errorObj, Promise, async); \n ";return code=code.replace(/\[TheName\]/g,name).replace(/\[TheTotal\]/g,total).replace(/\[ThePassedArguments\]/g,passedArguments).replace(/\[TheProperties\]/g,assignment).replace(/\[CancellationCode\]/g,cancellationCode),new Function("tryCatch","errorObj","Promise","async",code)(tryCatch,errorObj,Promise,async)},holderClasses=[],thenCallbacks=[],promiseSetters=[],i=0;8>i;++i)holderClasses.push(generateHolderClass(i+1)),thenCallbacks.push(thenCallback(i+1)),promiseSetters.push(promiseSetter(i+1));reject=function(reason){this._reject(reason)}}Promise.join=function(){var fn,last=arguments.length-1;if(last>0&&"function"==typeof arguments[last]&&(fn=arguments[last],8>=last&&canEvaluate)){var ret=new Promise(INTERNAL);ret._captureStackTrace();for(var HolderClass=holderClasses[last-1],holder=new HolderClass(fn),callbacks=thenCallbacks,i=0;last>i;++i){var maybePromise=tryConvertToPromise(arguments[i],ret);if(maybePromise instanceof Promise){maybePromise=maybePromise._target();var bitField=maybePromise._bitField;0===(50397184&bitLine truncated
|
||||||
|
var promise=this._promise=new Promise(INTERNAL);values instanceof Promise&&promise._propagateFrom(values,3),promise._setOnCancel(this),this._values=values,this._length=0,this._totalResolved=0,this._init(void 0,-2)}var util=require("./util");util.isArray;return util.inherits(PromiseArray,Proxyable),PromiseArray.prototype.length=function(){return this._length},PromiseArray.prototype.promise=function(){return this._promise},PromiseArray.prototype._init=function init(_,resolveValueIfEmpty){var values=tryConvertToPromise(this._values,this._promise);if(values instanceof Promise){values=values._target();var bitField=values._bitField;if(this._values=values,0===(50397184&bitField))return this._promise._setAsyncGuaranteed(),values._then(init,this._reject,void 0,this,resolveValueIfEmpty);if(0===(33554432&bitField))return 0!==(16777216&bitField)?this._reject(values._reason()):this._cancel();values=values._value()}if(values=util.asArray(values),null===values){var err=apiRejection("expecting an array or an iterable object but got "+util.classString(values)).reason();return void this._promise._rejectCallback(err,!1)}return 0===values.length?void(-5===resolveValueIfEmpty?this._resolveEmptyArray():this._resolve(toResolutionValue(resolveValueIfEmpty))):void this._iterate(values)},PromiseArray.prototype._iterate=function(values){var len=this.getActualLength(values.length);this._length=len,this._values=this.shouldCopyValues()?new Array(len):this._values;for(var result=this._promise,isResolved=!1,bitField=null,i=0;len>i;++i){var maybePromise=tryConvertToPromise(values[i],result);maybePromise instanceof Promise?(maybePromise=maybePromise._target(),bitField=maybePromise._bitField):bitField=null,isResolved?null!==bitField&&maybePromise.suppressUnhandledRejections():null!==bitField?0===(50397184&bitField)?(maybePromise._proxy(this,i),this._values[i]=maybePromise):isResolved=0!==(33554432&bitField)?this._promiseFulfilled(maybePromise._value(),i):0!==(16777216&bitField)?this._promiseRejected(maybePromise._reason(),i):this._promiseCancelled(i):isResolved=this._promiseFulfilled(maybePromise,i)}isResolved||result._setAsyncGuaranteed()},PromiseArray.prototype._isResolved=function(){return null===this._values},PromiseArray.prototype._resolve=function(value){this._values=null,this._promise._fulfill(value)},PromiseArray.prototype._cancel=function(){!this._isResolved()&&this._promise._isCancellable()&&(this._values=null,this._promise._cancel())},PromiseArray.prototype._reject=function(reason){this._values=null,this._promise._rejectCallback(reason,!1)},PromiseArray.prototype._promiseFulfilled=function(value,index){this._values[index]=value;var totalResolved=++this._totalResolved;return totalResolved>=this._length?(this._resolve(this._values),!0):!1},PromiseArray.prototype._promiseCancelled=function(){return this._cancel(),!0},PromiseArray.prototype._promiseRejected=function(reason){return this._totalResolved++,this._reject(reason),!0},PromiseArray.prototype._resultCancelled=function(){if(!this._isResolved()){var values=this._values;if(this._cancel(),values instanceof Promise)values.cancel();else for(var i=0;i<values.length;++i)values[i]instanceof Promise&&values[i].cancel()}},PromiseArray.prototype.shouldCopyValues=function(){return!0},PromiseArray.prototype.getActualLength=function(len){return len},PromiseArray}},{"./util":82}],70:[function(require,module,exports){"use strict";module.exports=function(Promise,INTERNAL){function propsFilter(key){return!noCopyPropsPattern.test(key)}function isPromisified(fn){try{return fn.__isPromisified__===!0}catch(e){return!1}}function hasPromisified(obj,key,suffix){var val=util.getDataPropertyOrDefault(obj,key+suffix,defaultPromisified);return val?isPromisified(val):!1}function checkValid(ret,suffix,suffixRegexp){for(var i=0;i<ret.length;i+=2){var key=ret[i];if(suffixRegexp.test(key))for(var keyWithoutAsyncSuffix=key.replace(suffixRegexp,""),j=0;j<ret.length;j+=2)if(ret[j]===keyWithoutAsyncSuffix)throw new TypeError("Cannot promisify an API that has normal methods with '%s'-suffix\n\n See http://goo.gl/MqrFmX\n".replace("%s",suffix))}}function promisifiableMethods(obj,suffix,suffixRegexp,filter){for(var keys=util.inheritedDataKeys(obj),ret=[],i=0;i<keys.length;++i){var key=keys[i],value=obj[key],passesDefaultFilter=filter===defaultFilter?!0:defaultFilter(key,value,obj);"function"!=typeof value||isPromisified(value)||hasPromisified(obj,key,suffix)||!filter(key,value,obj,passesDefaultFilter)||ret.push(key,value)}return checkValid(ret,suffix,suffixRegexp),ret}function makeNodePromisifiedClosure(callback,receiver,_,fn,__,multiArgs){function promisified(){var _receiver=receiver;receiver===THIS&&(_receiver=this);var promise=new Promise(INTERNAL);promise._captureStackTrace();var cb="string"==typeof method&&this!==defaultThis?this[method]:callback,fn=nodebackForPromise(promise,multiArgs);try{cb.apply(_receiver,withAppended(arguments,fn))}catch(e){promise._rejectCallback(maybeWrapAsError(e),!0,!0)}return promiseLine truncated
|
||||||
|
ret}}},{"./util":82}],81:[function(require,module,exports){"use strict";module.exports=function(Promise,apiRejection,tryConvertToPromise,createContext,INTERNAL,debug){function thrower(e){setTimeout(function(){throw e},0)}function castPreservingDisposable(thenable){var maybePromise=tryConvertToPromise(thenable);return maybePromise!==thenable&&"function"==typeof thenable._isDisposable&&"function"==typeof thenable._getDisposer&&thenable._isDisposable()&&maybePromise._setDisposable(thenable._getDisposer()),maybePromise}function dispose(resources,inspection){function iterator(){if(i>=len)return ret._fulfill();var maybePromise=castPreservingDisposable(resources[i++]);if(maybePromise instanceof Promise&&maybePromise._isDisposable()){try{maybePromise=tryConvertToPromise(maybePromise._getDisposer().tryDispose(inspection),resources.promise)}catch(e){return thrower(e)}if(maybePromise instanceof Promise)return maybePromise._then(iterator,thrower,null,null,null)}iterator()}var i=0,len=resources.length,ret=new Promise(INTERNAL);return iterator(),ret}function Disposer(data,promise,context){this._data=data,this._promise=promise,this._context=context}function FunctionDisposer(fn,promise,context){this.constructor$(fn,promise,context)}function maybeUnwrapDisposer(value){return Disposer.isDisposer(value)?(this.resources[this.index]._setDisposable(value),value.promise()):value}function ResourceList(length){this.length=length,this.promise=null,this[length-1]=null}var util=require("./util"),TypeError=require("./errors").TypeError,inherits=require("./util").inherits,errorObj=util.errorObj,tryCatch=util.tryCatch,NULL={};Disposer.prototype.data=function(){return this._data},Disposer.prototype.promise=function(){return this._promise},Disposer.prototype.resource=function(){return this.promise().isFulfilled()?this.promise().value():NULL},Disposer.prototype.tryDispose=function(inspection){var resource=this.resource(),context=this._context;void 0!==context&&context._pushContext();var ret=resource!==NULL?this.doDispose(resource,inspection):null;return void 0!==context&&context._popContext(),this._promise._unsetDisposable(),this._data=null,ret},Disposer.isDisposer=function(d){return null!=d&&"function"==typeof d.resource&&"function"==typeof d.tryDispose},inherits(FunctionDisposer,Disposer),FunctionDisposer.prototype.doDispose=function(resource,inspection){var fn=this.data();return fn.call(resource,resource,inspection)},ResourceList.prototype._resultCancelled=function(){for(var len=this.length,i=0;len>i;++i){var item=this[i];item instanceof Promise&&item.cancel()}},Promise.using=function(){var len=arguments.length;if(2>len)return apiRejection("you must pass at least 2 arguments to Promise.using");var fn=arguments[len-1];if("function"!=typeof fn)return apiRejection("expecting a function but got "+util.classString(fn));var input,spreadArgs=!0;2===len&&Array.isArray(arguments[0])?(input=arguments[0],len=input.length,spreadArgs=!1):(input=arguments,len--);for(var resources=new ResourceList(len),i=0;len>i;++i){var resource=input[i];if(Disposer.isDisposer(resource)){var disposer=resource;resource=resource.promise(),resource._setDisposable(disposer)}else{var maybePromise=tryConvertToPromise(resource);maybePromise instanceof Promise&&(resource=maybePromise._then(maybeUnwrapDisposer,null,null,{resources:resources,index:i},void 0))}resources[i]=resource}for(var reflectedResources=new Array(resources.length),i=0;i<reflectedResources.length;++i)reflectedResources[i]=Promise.resolve(resources[i]).reflect();var resultPromise=Promise.all(reflectedResources).then(function(inspections){for(var i=0;i<inspections.length;++i){var inspection=inspections[i];if(inspection.isRejected())return errorObj.e=inspection.error(),errorObj;if(!inspection.isFulfilled())return void resultPromise.cancel();inspections[i]=inspection.value()}promise._pushContext(),fn=tryCatch(fn);var ret=spreadArgs?fn.apply(void 0,inspections):fn(inspections),promiseCreated=promise._popContext();return debug.checkForgottenReturns(ret,promiseCreated,"Promise.using",promise),ret}),promise=resultPromise.lastly(function(){var inspection=new Promise.PromiseInspection(resultPromise);return dispose(resources,inspection)});return resources.promise=promise,promise._setOnCancel(resources),promise},Promise.prototype._setDisposable=function(disposer){this._bitField=131072|this._bitField,this._disposer=disposer},Promise.prototype._isDisposable=function(){return(131072&this._bitField)>0},Promise.prototype._getDisposer=function(){return this._disposer},Promise.prototype._unsetDisposable=function(){this._bitField=-131073&this._bitField,this._disposer=void 0},Promise.prototype.disposer=function(fn){if("function"==typeof fn)return new FunctionDisposer(fn,this,createContext());throw new TypeError}}},{"./errors":58,"./util":82}],82:[function(require,module,exports){(function(process,global){"use strict";function tryCatcher(){try{var target=tryCatchTarget;return tryCatchTarget=null,target.apply(this,arguments)}catcLine truncated
|
||||||
|
}return newBuf},Buffer.prototype.readUIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i<byteLength&&(mul*=256);)val+=this[offset+i]*mul;return val},Buffer.prototype.readUIntBE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset+--byteLength],mul=1;byteLength>0&&(mul*=256);)val+=this[offset+--byteLength]*mul;return val},Buffer.prototype.readUInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),this[offset]},Buffer.prototype.readUInt16LE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]|this[offset+1]<<8},Buffer.prototype.readUInt16BE=function(offset,noAssert){return noAssert||checkOffset(offset,2,this.length),this[offset]<<8|this[offset+1]},Buffer.prototype.readUInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),(this[offset]|this[offset+1]<<8|this[offset+2]<<16)+16777216*this[offset+3]},Buffer.prototype.readUInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),16777216*this[offset]+(this[offset+1]<<16|this[offset+2]<<8|this[offset+3])},Buffer.prototype.readIntLE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var val=this[offset],mul=1,i=0;++i<byteLength&&(mul*=256);)val+=this[offset+i]*mul;return mul*=128,val>=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readIntBE=function(offset,byteLength,noAssert){offset=0|offset,byteLength=0|byteLength,noAssert||checkOffset(offset,byteLength,this.length);for(var i=byteLength,mul=1,val=this[offset+--i];i>0&&(mul*=256);)val+=this[offset+--i]*mul;return mul*=128,val>=mul&&(val-=Math.pow(2,8*byteLength)),val},Buffer.prototype.readInt8=function(offset,noAssert){return noAssert||checkOffset(offset,1,this.length),128&this[offset]?-1*(255-this[offset]+1):this[offset]},Buffer.prototype.readInt16LE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset]|this[offset+1]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt16BE=function(offset,noAssert){noAssert||checkOffset(offset,2,this.length);var val=this[offset+1]|this[offset]<<8;return 32768&val?4294901760|val:val},Buffer.prototype.readInt32LE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]|this[offset+1]<<8|this[offset+2]<<16|this[offset+3]<<24},Buffer.prototype.readInt32BE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),this[offset]<<24|this[offset+1]<<16|this[offset+2]<<8|this[offset+3]},Buffer.prototype.readFloatLE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!0,23,4)},Buffer.prototype.readFloatBE=function(offset,noAssert){return noAssert||checkOffset(offset,4,this.length),ieee754.read(this,offset,!1,23,4)},Buffer.prototype.readDoubleLE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!0,52,8)},Buffer.prototype.readDoubleBE=function(offset,noAssert){return noAssert||checkOffset(offset,8,this.length),ieee754.read(this,offset,!1,52,8)},Buffer.prototype.writeUIntLE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,byteLength=0|byteLength,!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var mul=1,i=0;for(this[offset]=255&value;++i<byteLength&&(mul*=256);)this[offset+i]=value/mul&255;return offset+byteLength},Buffer.prototype.writeUIntBE=function(value,offset,byteLength,noAssert){if(value=+value,offset=0|offset,byteLength=0|byteLength,!noAssert){var maxBytes=Math.pow(2,8*byteLength)-1;checkInt(this,value,offset,byteLength,maxBytes,0)}var i=byteLength-1,mul=1;for(this[offset+i]=255&value;--i>=0&&(mul*=256);)this[offset+i]=value/mul&255;return offset+byteLength},Buffer.prototype.writeUInt8=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,1,255,0),Buffer.TYPED_ARRAY_SUPPORT||(value=Math.floor(value)),this[offset]=255&value,offset+1},Buffer.prototype.writeUInt16LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=255&value,this[offset+1]=value>>>8):objectWriteUInt16(this,value,offset,!0),offset+2},Buffer.prototype.writeUInt16BE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,2,65535,0),Buffer.TYPED_ARRAY_SUPPORT?(this[offset]=value>>>8,this[offset+1]=255&value):objectWriteUInt16(this,value,offset,!1),offset+2},Buffer.prototype.writeUInt32LE=function(value,offset,noAssert){return value=+value,offset=0|offset,noAssert||checkInt(this,value,offset,4,4294967295,0),BufLine truncated
|
||||||
|
"Dingbat dec":"44","Dingbat hex":"2C","Unicode dec":"127798","Unicode hex":"1F336"},{"Typeface name":"Webdings","Dingbat dec":"45","Dingbat hex":"2D","Unicode dec":"127895","Unicode hex":"1F397"},{"Typeface name":"Webdings","Dingbat dec":"46","Dingbat hex":"2E","Unicode dec":"128638","Unicode hex":"1F67E"},{"Typeface name":"Webdings","Dingbat dec":"47","Dingbat hex":"2F","Unicode dec":"128636","Unicode hex":"1F67C"},{"Typeface name":"Webdings","Dingbat dec":"48","Dingbat hex":"30","Unicode dec":"128469","Unicode hex":"1F5D5"},{"Typeface name":"Webdings","Dingbat dec":"49","Dingbat hex":"31","Unicode dec":"128470","Unicode hex":"1F5D6"},{"Typeface name":"Webdings","Dingbat dec":"50","Dingbat hex":"32","Unicode dec":"128471","Unicode hex":"1F5D7"},{"Typeface name":"Webdings","Dingbat dec":"51","Dingbat hex":"33","Unicode dec":"9204","Unicode hex":"23F4"},{"Typeface name":"Webdings","Dingbat dec":"52","Dingbat hex":"34","Unicode dec":"9205","Unicode hex":"23F5"},{"Typeface name":"Webdings","Dingbat dec":"53","Dingbat hex":"35","Unicode dec":"9206","Unicode hex":"23F6"},{"Typeface name":"Webdings","Dingbat dec":"54","Dingbat hex":"36","Unicode dec":"9207","Unicode hex":"23F7"},{"Typeface name":"Webdings","Dingbat dec":"55","Dingbat hex":"37","Unicode dec":"9194","Unicode hex":"23EA"},{"Typeface name":"Webdings","Dingbat dec":"56","Dingbat hex":"38","Unicode dec":"9193","Unicode hex":"23E9"},{"Typeface name":"Webdings","Dingbat dec":"57","Dingbat hex":"39","Unicode dec":"9198","Unicode hex":"23EE"},{"Typeface name":"Webdings","Dingbat dec":"58","Dingbat hex":"3A","Unicode dec":"9197","Unicode hex":"23ED"},{"Typeface name":"Webdings","Dingbat dec":"59","Dingbat hex":"3B","Unicode dec":"9208","Unicode hex":"23F8"},{"Typeface name":"Webdings","Dingbat dec":"60","Dingbat hex":"3C","Unicode dec":"9209","Unicode hex":"23F9"},{"Typeface name":"Webdings","Dingbat dec":"61","Dingbat hex":"3D","Unicode dec":"9210","Unicode hex":"23FA"},{"Typeface name":"Webdings","Dingbat dec":"62","Dingbat hex":"3E","Unicode dec":"128474","Unicode hex":"1F5DA"},{"Typeface name":"Webdings","Dingbat dec":"63","Dingbat hex":"3F","Unicode dec":"128499","Unicode hex":"1F5F3"},{"Typeface name":"Webdings","Dingbat dec":"64","Dingbat hex":"40","Unicode dec":"128736","Unicode hex":"1F6E0"},{"Typeface name":"Webdings","Dingbat dec":"65","Dingbat hex":"41","Unicode dec":"127959","Unicode hex":"1F3D7"},{"Typeface name":"Webdings","Dingbat dec":"66","Dingbat hex":"42","Unicode dec":"127960","Unicode hex":"1F3D8"},{"Typeface name":"Webdings","Dingbat dec":"67","Dingbat hex":"43","Unicode dec":"127961","Unicode hex":"1F3D9"},{"Typeface name":"Webdings","Dingbat dec":"68","Dingbat hex":"44","Unicode dec":"127962","Unicode hex":"1F3DA"},{"Typeface name":"Webdings","Dingbat dec":"69","Dingbat hex":"45","Unicode dec":"127964","Unicode hex":"1F3DC"},{"Typeface name":"Webdings","Dingbat dec":"70","Dingbat hex":"46","Unicode dec":"127981","Unicode hex":"1F3ED"},{"Typeface name":"Webdings","Dingbat dec":"71","Dingbat hex":"47","Unicode dec":"127963","Unicode hex":"1F3DB"},{"Typeface name":"Webdings","Dingbat dec":"72","Dingbat hex":"48","Unicode dec":"127968","Unicode hex":"1F3E0"},{"Typeface name":"Webdings","Dingbat dec":"73","Dingbat hex":"49","Unicode dec":"127958","Unicode hex":"1F3D6"},{"Typeface name":"Webdings","Dingbat dec":"74","Dingbat hex":"4A","Unicode dec":"127965","Unicode hex":"1F3DD"},{"Typeface name":"Webdings","Dingbat dec":"75","Dingbat hex":"4B","Unicode dec":"128739","Unicode hex":"1F6E3"},{"Typeface name":"Webdings","Dingbat dec":"76","Dingbat hex":"4C","Unicode dec":"128269","Unicode hex":"1F50D"},{"Typeface name":"Webdings","Dingbat dec":"77","Dingbat hex":"4D","Unicode dec":"127956","Unicode hex":"1F3D4"},{"Typeface name":"Webdings","Dingbat dec":"78","Dingbat hex":"4E","Unicode dec":"128065","Unicode hex":"1F441"},{"Typeface name":"Webdings","Dingbat dec":"79","Dingbat hex":"4F","Unicode dec":"128066","Unicode hex":"1F442"},{"Typeface name":"Webdings","Dingbat dec":"80","Dingbat hex":"50","Unicode dec":"127966","Unicode hex":"1F3DE"},{"Typeface name":"Webdings","Dingbat dec":"81","Dingbat hex":"51","Unicode dec":"127957","Unicode hex":"1F3D5"},{"Typeface name":"Webdings","Dingbat dec":"82","Dingbat hex":"52","Unicode dec":"128740","Unicode hex":"1F6E4"},{"Typeface name":"Webdings","Dingbat dec":"83","Dingbat hex":"53","Unicode dec":"127967","Unicode hex":"1F3DF"},{"Typeface name":"Webdings","Dingbat dec":"84","Dingbat hex":"54","Unicode dec":"128755","Unicode hex":"1F6F3"},{"Typeface name":"Webdings","Dingbat dec":"85","Dingbat hex":"55","Unicode dec":"128364","Unicode hex":"1F56C"},{"Typeface name":"Webdings","Dingbat dec":"86","Dingbat hex":"56","Unicode dec":"128363","Unicode hex":"1F56B"},{"Typeface name":"Webdings","Dingbat dec":"87","Dingbat hex":"57","Unicode dec":"128360","Unicode hex":"1F568"},{"Typeface name":"Webdings","Dingbat dec":"88","Dingbat hex":"58","Unicode dec":"128264","Unicode hex":"1F508"},{"Typeface name":"WeLine truncated
|
||||||
|
"Dingbat hex":"6A","Unicode dec":"128624","Unicode hex":"1F670"},{"Typeface name":"Wingdings","Dingbat dec":"107","Dingbat hex":"6B","Unicode dec":"128629","Unicode hex":"1F675"},{"Typeface name":"Wingdings","Dingbat dec":"108","Dingbat hex":"6C","Unicode dec":"9899","Unicode hex":"26AB"},{"Typeface name":"Wingdings","Dingbat dec":"109","Dingbat hex":"6D","Unicode dec":"128318","Unicode hex":"1F53E"},{"Typeface name":"Wingdings","Dingbat dec":"110","Dingbat hex":"6E","Unicode dec":"9724","Unicode hex":"25FC"},{"Typeface name":"Wingdings","Dingbat dec":"111","Dingbat hex":"6F","Unicode dec":"128911","Unicode hex":"1F78F"},{"Typeface name":"Wingdings","Dingbat dec":"112","Dingbat hex":"70","Unicode dec":"128912","Unicode hex":"1F790"},{"Typeface name":"Wingdings","Dingbat dec":"113","Dingbat hex":"71","Unicode dec":"10065","Unicode hex":"2751"},{"Typeface name":"Wingdings","Dingbat dec":"114","Dingbat hex":"72","Unicode dec":"10066","Unicode hex":"2752"},{"Typeface name":"Wingdings","Dingbat dec":"115","Dingbat hex":"73","Unicode dec":"128927","Unicode hex":"1F79F"},{"Typeface name":"Wingdings","Dingbat dec":"116","Dingbat hex":"74","Unicode dec":"10731","Unicode hex":"29EB"},{"Typeface name":"Wingdings","Dingbat dec":"117","Dingbat hex":"75","Unicode dec":"9670","Unicode hex":"25C6"},{"Typeface name":"Wingdings","Dingbat dec":"118","Dingbat hex":"76","Unicode dec":"10070","Unicode hex":"2756"},{"Typeface name":"Wingdings","Dingbat dec":"119","Dingbat hex":"77","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings","Dingbat dec":"120","Dingbat hex":"78","Unicode dec":"8999","Unicode hex":"2327"},{"Typeface name":"Wingdings","Dingbat dec":"121","Dingbat hex":"79","Unicode dec":"11193","Unicode hex":"2BB9"},{"Typeface name":"Wingdings","Dingbat dec":"122","Dingbat hex":"7A","Unicode dec":"8984","Unicode hex":"2318"},{"Typeface name":"Wingdings","Dingbat dec":"123","Dingbat hex":"7B","Unicode dec":"127989","Unicode hex":"1F3F5"},{"Typeface name":"Wingdings","Dingbat dec":"124","Dingbat hex":"7C","Unicode dec":"127990","Unicode hex":"1F3F6"},{"Typeface name":"Wingdings","Dingbat dec":"125","Dingbat hex":"7D","Unicode dec":"128630","Unicode hex":"1F676"},{"Typeface name":"Wingdings","Dingbat dec":"126","Dingbat hex":"7E","Unicode dec":"128631","Unicode hex":"1F677"},{"Typeface name":"Wingdings","Dingbat dec":"127","Dingbat hex":"7F","Unicode dec":"9647","Unicode hex":"25AF"},{"Typeface name":"Wingdings","Dingbat dec":"128","Dingbat hex":"80","Unicode dec":"127243","Unicode hex":"1F10B"},{"Typeface name":"Wingdings","Dingbat dec":"129","Dingbat hex":"81","Unicode dec":"10112","Unicode hex":"2780"},{"Typeface name":"Wingdings","Dingbat dec":"130","Dingbat hex":"82","Unicode dec":"10113","Unicode hex":"2781"},{"Typeface name":"Wingdings","Dingbat dec":"131","Dingbat hex":"83","Unicode dec":"10114","Unicode hex":"2782"},{"Typeface name":"Wingdings","Dingbat dec":"132","Dingbat hex":"84","Unicode dec":"10115","Unicode hex":"2783"},{"Typeface name":"Wingdings","Dingbat dec":"133","Dingbat hex":"85","Unicode dec":"10116","Unicode hex":"2784"},{"Typeface name":"Wingdings","Dingbat dec":"134","Dingbat hex":"86","Unicode dec":"10117","Unicode hex":"2785"},{"Typeface name":"Wingdings","Dingbat dec":"135","Dingbat hex":"87","Unicode dec":"10118","Unicode hex":"2786"},{"Typeface name":"Wingdings","Dingbat dec":"136","Dingbat hex":"88","Unicode dec":"10119","Unicode hex":"2787"},{"Typeface name":"Wingdings","Dingbat dec":"137","Dingbat hex":"89","Unicode dec":"10120","Unicode hex":"2788"},{"Typeface name":"Wingdings","Dingbat dec":"138","Dingbat hex":"8A","Unicode dec":"10121","Unicode hex":"2789"},{"Typeface name":"Wingdings","Dingbat dec":"139","Dingbat hex":"8B","Unicode dec":"127244","Unicode hex":"1F10C"},{"Typeface name":"Wingdings","Dingbat dec":"140","Dingbat hex":"8C","Unicode dec":"10122","Unicode hex":"278A"},{"Typeface name":"Wingdings","Dingbat dec":"141","Dingbat hex":"8D","Unicode dec":"10123","Unicode hex":"278B"},{"Typeface name":"Wingdings","Dingbat dec":"142","Dingbat hex":"8E","Unicode dec":"10124","Unicode hex":"278C"},{"Typeface name":"Wingdings","Dingbat dec":"143","Dingbat hex":"8F","Unicode dec":"10125","Unicode hex":"278D"},{"Typeface name":"Wingdings","Dingbat dec":"144","Dingbat hex":"90","Unicode dec":"10126","Unicode hex":"278E"},{"Typeface name":"Wingdings","Dingbat dec":"145","Dingbat hex":"91","Unicode dec":"10127","Unicode hex":"278F"},{"Typeface name":"Wingdings","Dingbat dec":"146","Dingbat hex":"92","Unicode dec":"10128","Unicode hex":"2790"},{"Typeface name":"Wingdings","Dingbat dec":"147","Dingbat hex":"93","Unicode dec":"10129","Unicode hex":"2791"},{"Typeface name":"Wingdings","Dingbat dec":"148","Dingbat hex":"94","Unicode dec":"10130","Unicode hex":"2792"},{"Typeface name":"Wingdings","Dingbat dec":"149","Dingbat hex":"95","Unicode dec":"10131","Unicode hex":"2793"},{"Typeface name":"Wingdings","Dingbat dec":"150","Dingbat hex":"96","Unicode dec":"128610","Unicode heLine truncated
|
||||||
|
},{"Typeface name":"Wingdings 2","Dingbat dec":"166","Dingbat hex":"A6","Unicode dec":"128915","Unicode hex":"1F793"},{"Typeface name":"Wingdings 2","Dingbat dec":"167","Dingbat hex":"A7","Unicode dec":"128916","Unicode hex":"1F794"},{"Typeface name":"Wingdings 2","Dingbat dec":"168","Dingbat hex":"A8","Unicode dec":"9635","Unicode hex":"25A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"169","Dingbat hex":"A9","Unicode dec":"128917","Unicode hex":"1F795"},{"Typeface name":"Wingdings 2","Dingbat dec":"170","Dingbat hex":"AA","Unicode dec":"128918","Unicode hex":"1F796"},{"Typeface name":"Wingdings 2","Dingbat dec":"171","Dingbat hex":"AB","Unicode dec":"128919","Unicode hex":"1F797"},{"Typeface name":"Wingdings 2","Dingbat dec":"172","Dingbat hex":"AC","Unicode dec":"128920","Unicode hex":"1F798"},{"Typeface name":"Wingdings 2","Dingbat dec":"173","Dingbat hex":"AD","Unicode dec":"11049","Unicode hex":"2B29"},{"Typeface name":"Wingdings 2","Dingbat dec":"174","Dingbat hex":"AE","Unicode dec":"11045","Unicode hex":"2B25"},{"Typeface name":"Wingdings 2","Dingbat dec":"175","Dingbat hex":"AF","Unicode dec":"9671","Unicode hex":"25C7"},{"Typeface name":"Wingdings 2","Dingbat dec":"176","Dingbat hex":"B0","Unicode dec":"128922","Unicode hex":"1F79A"},{"Typeface name":"Wingdings 2","Dingbat dec":"177","Dingbat hex":"B1","Unicode dec":"9672","Unicode hex":"25C8"},{"Typeface name":"Wingdings 2","Dingbat dec":"178","Dingbat hex":"B2","Unicode dec":"128923","Unicode hex":"1F79B"},{"Typeface name":"Wingdings 2","Dingbat dec":"179","Dingbat hex":"B3","Unicode dec":"128924","Unicode hex":"1F79C"},{"Typeface name":"Wingdings 2","Dingbat dec":"180","Dingbat hex":"B4","Unicode dec":"128925","Unicode hex":"1F79D"},{"Typeface name":"Wingdings 2","Dingbat dec":"181","Dingbat hex":"B5","Unicode dec":"128926","Unicode hex":"1F79E"},{"Typeface name":"Wingdings 2","Dingbat dec":"182","Dingbat hex":"B6","Unicode dec":"11050","Unicode hex":"2B2A"},{"Typeface name":"Wingdings 2","Dingbat dec":"183","Dingbat hex":"B7","Unicode dec":"11047","Unicode hex":"2B27"},{"Typeface name":"Wingdings 2","Dingbat dec":"184","Dingbat hex":"B8","Unicode dec":"9674","Unicode hex":"25CA"},{"Typeface name":"Wingdings 2","Dingbat dec":"185","Dingbat hex":"B9","Unicode dec":"128928","Unicode hex":"1F7A0"},{"Typeface name":"Wingdings 2","Dingbat dec":"186","Dingbat hex":"BA","Unicode dec":"9686","Unicode hex":"25D6"},{"Typeface name":"Wingdings 2","Dingbat dec":"187","Dingbat hex":"BB","Unicode dec":"9687","Unicode hex":"25D7"},{"Typeface name":"Wingdings 2","Dingbat dec":"188","Dingbat hex":"BC","Unicode dec":"11210","Unicode hex":"2BCA"},{"Typeface name":"Wingdings 2","Dingbat dec":"189","Dingbat hex":"BD","Unicode dec":"11211","Unicode hex":"2BCB"},{"Typeface name":"Wingdings 2","Dingbat dec":"190","Dingbat hex":"BE","Unicode dec":"11200","Unicode hex":"2BC0"},{"Typeface name":"Wingdings 2","Dingbat dec":"191","Dingbat hex":"BF","Unicode dec":"11201","Unicode hex":"2BC1"},{"Typeface name":"Wingdings 2","Dingbat dec":"192","Dingbat hex":"C0","Unicode dec":"11039","Unicode hex":"2B1F"},{"Typeface name":"Wingdings 2","Dingbat dec":"193","Dingbat hex":"C1","Unicode dec":"11202","Unicode hex":"2BC2"},{"Typeface name":"Wingdings 2","Dingbat dec":"194","Dingbat hex":"C2","Unicode dec":"11043","Unicode hex":"2B23"},{"Typeface name":"Wingdings 2","Dingbat dec":"195","Dingbat hex":"C3","Unicode dec":"11042","Unicode hex":"2B22"},{"Typeface name":"Wingdings 2","Dingbat dec":"196","Dingbat hex":"C4","Unicode dec":"11203","Unicode hex":"2BC3"},{"Typeface name":"Wingdings 2","Dingbat dec":"197","Dingbat hex":"C5","Unicode dec":"11204","Unicode hex":"2BC4"},{"Typeface name":"Wingdings 2","Dingbat dec":"198","Dingbat hex":"C6","Unicode dec":"128929","Unicode hex":"1F7A1"},{"Typeface name":"Wingdings 2","Dingbat dec":"199","Dingbat hex":"C7","Unicode dec":"128930","Unicode hex":"1F7A2"},{"Typeface name":"Wingdings 2","Dingbat dec":"200","Dingbat hex":"C8","Unicode dec":"128931","Unicode hex":"1F7A3"},{"Typeface name":"Wingdings 2","Dingbat dec":"201","Dingbat hex":"C9","Unicode dec":"128932","Unicode hex":"1F7A4"},{"Typeface name":"Wingdings 2","Dingbat dec":"202","Dingbat hex":"CA","Unicode dec":"128933","Unicode hex":"1F7A5"},{"Typeface name":"Wingdings 2","Dingbat dec":"203","Dingbat hex":"CB","Unicode dec":"128934","Unicode hex":"1F7A6"},{"Typeface name":"Wingdings 2","Dingbat dec":"204","Dingbat hex":"CC","Unicode dec":"128935","Unicode hex":"1F7A7"},{"Typeface name":"Wingdings 2","Dingbat dec":"205","Dingbat hex":"CD","Unicode dec":"128936","Unicode hex":"1F7A8"},{"Typeface name":"Wingdings 2","Dingbat dec":"206","Dingbat hex":"CE","Unicode dec":"128937","Unicode hex":"1F7A9"},{"Typeface name":"Wingdings 2","Dingbat dec":"207","Dingbat hex":"CF","Unicode dec":"128938","Unicode hex":"1F7AA"},{"Typeface name":"Wingdings 2","Dingbat dec":"208","Dingbat hex":"D0","Unicode dec":"128939","Unicode hex":"1F7AB"},{"Typeface name":"Wingdings 2","Dingbat dec":"209","DinLine truncated
|
||||||
|
"Unicode hex":"1F881"},{"Typeface name":"Wingdings 3","Dingbat dec":"228","Dingbat hex":"E4","Unicode dec":"129155","Unicode hex":"1F883"},{"Typeface name":"Wingdings 3","Dingbat dec":"229","Dingbat hex":"E5","Unicode dec":"129156","Unicode hex":"1F884"},{"Typeface name":"Wingdings 3","Dingbat dec":"230","Dingbat hex":"E6","Unicode dec":"129157","Unicode hex":"1F885"},{"Typeface name":"Wingdings 3","Dingbat dec":"231","Dingbat hex":"E7","Unicode dec":"129159","Unicode hex":"1F887"},{"Typeface name":"Wingdings 3","Dingbat dec":"232","Dingbat hex":"E8","Unicode dec":"129158","Unicode hex":"1F886"},{"Typeface name":"Wingdings 3","Dingbat dec":"233","Dingbat hex":"E9","Unicode dec":"129168","Unicode hex":"1F890"},{"Typeface name":"Wingdings 3","Dingbat dec":"234","Dingbat hex":"EA","Unicode dec":"129170","Unicode hex":"1F892"},{"Typeface name":"Wingdings 3","Dingbat dec":"235","Dingbat hex":"EB","Unicode dec":"129169","Unicode hex":"1F891"},{"Typeface name":"Wingdings 3","Dingbat dec":"236","Dingbat hex":"EC","Unicode dec":"129171","Unicode hex":"1F893"},{"Typeface name":"Wingdings 3","Dingbat dec":"237","Dingbat hex":"ED","Unicode dec":"129172","Unicode hex":"1F894"},{"Typeface name":"Wingdings 3","Dingbat dec":"238","Dingbat hex":"EE","Unicode dec":"129174","Unicode hex":"1F896"},{"Typeface name":"Wingdings 3","Dingbat dec":"239","Dingbat hex":"EF","Unicode dec":"129173","Unicode hex":"1F895"},{"Typeface name":"Wingdings 3","Dingbat dec":"240","Dingbat hex":"F0","Unicode dec":"129175","Unicode hex":"1F897"}];exports["default"]=dingbats},{}],85:[function(require,module,exports){"use strict";function codePoint(typeface,codePoint){return dingbatsByCodePoint[typeface.toUpperCase()+"_"+codePoint]}function dec(typeface,dec){return codePoint(typeface,parseInt(dec,10))}function hex(typeface,hex){return codePoint(typeface,parseInt(hex,16))}function fromCodePointPolyfill(codePoint){if(65535>=codePoint)return String.fromCharCode(codePoint);var highSurrogate=Math.floor((codePoint-65536)/1024)+55296,lowSurrogate=(codePoint-65536)%1024+56320;return String.fromCharCode(highSurrogate,lowSurrogate)}var __importDefault=this&&this.__importDefault||function(mod){return mod&&mod.__esModule?mod:{"default":mod}};Object.defineProperty(exports,"__esModule",{value:!0}),exports.hex=exports.dec=exports.codePoint=void 0;for(var dingbats_1=__importDefault(require("./dingbats")),dingbatsByCodePoint={},fromCodePoint=String.fromCodePoint?String.fromCodePoint:fromCodePointPolyfill,_i=0,dingbats_2=dingbats_1["default"];_i<dingbats_2.length;_i++){var dingbat=dingbats_2[_i],codePoint_1=parseInt(dingbat["Unicode dec"],10),scalarValue={codePoint:codePoint_1,string:fromCodePoint(codePoint_1)};dingbatsByCodePoint[dingbat["Typeface name"].toUpperCase()+"_"+dingbat["Dingbat dec"]]=scalarValue}exports.codePoint=codePoint,exports.dec=dec,exports.hex=hex},{"./dingbats":84}],86:[function(require,module,exports){exports.read=function(buffer,offset,isLE,mLen,nBytes){var e,m,eLen=8*nBytes-mLen-1,eMax=(1<<eLen)-1,eBias=eMax>>1,nBits=-7,i=isLE?nBytes-1:0,d=isLE?-1:1,s=buffer[offset+i];for(i+=d,e=s&(1<<-nBits)-1,s>>=-nBits,nBits+=eLen;nBits>0;e=256*e+buffer[offset+i],i+=d,nBits-=8);for(m=e&(1<<-nBits)-1,e>>=-nBits,nBits+=mLen;nBits>0;m=256*m+buffer[offset+i],i+=d,nBits-=8);if(0===e)e=1-eBias;else{if(e===eMax)return m?NaN:(s?-1:1)*(1/0);m+=Math.pow(2,mLen),e-=eBias}return(s?-1:1)*m*Math.pow(2,e-mLen)},exports.write=function(buffer,value,offset,isLE,mLen,nBytes){var e,m,c,eLen=8*nBytes-mLen-1,eMax=(1<<eLen)-1,eBias=eMax>>1,rt=23===mLen?Math.pow(2,-24)-Math.pow(2,-77):0,i=isLE?0:nBytes-1,d=isLE?1:-1,s=0>value||0===value&&0>1/value?1:0;for(value=Math.abs(value),isNaN(value)||value===1/0?(m=isNaN(value)?1:0,e=eMax):(e=Math.floor(Math.log(value)/Math.LN2),value*(c=Math.pow(2,-e))<1&&(e--,c*=2),value+=e+eBias>=1?rt/c:rt*Math.pow(2,1-eBias),value*c>=2&&(e++,c/=2),e+eBias>=eMax?(m=0,e=eMax):e+eBias>=1?(m=(value*c-1)*Math.pow(2,mLen),e+=eBias):(m=value*Math.pow(2,eBias-1)*Math.pow(2,mLen),e=0));mLen>=8;buffer[offset+i]=255&m,i+=d,m/=256,mLen-=8);for(e=e<<mLen|m,eLen+=mLen;eLen>0;buffer[offset+i]=255&e,i+=d,e/=256,eLen-=8);buffer[offset+i-d]|=128*s}},{}],87:[function(require,module,exports){var toString={}.toString;module.exports=Array.isArray||function(arr){return"[object Array]"==toString.call(arr)}},{}],88:[function(require,module,exports){(function(global,Buffer){!function(t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).JSZip=t()}(function(){return function s(a,o,h){function u(r,t){if(!o[r]){if(!a[r]){var e="function"==typeof require&&require;if(!t&&e)return e(r,!0);if(l)return l(r,!0);var i=new Error("Cannot find module '"+r+"'");throw i.code="MODULE_NOT_FOUND",i}var n=o[r]={exports:{}};a[r][0].call(n.exports,function(t){var e=a[r][1][t];return u(e||t)},n,n.exports,s,a,o,h)}return o[r].Line truncated
|
||||||
|
h.delay(e,arguments,r)}),this},resume:function(){return h.delay(this._worker.resume,[],this._worker),this},pause:function(){return this._worker.pause(),this},toNodejsStream:function(t){if(h.checkSupport("nodestream"),"nodebuffer"!==this._outputType)throw new Error(this._outputType+" is not supported by this method");return new o(this,{objectMode:"nodebuffer"!==this._outputType},t)}},e.exports=f},{"../base64":1,"../external":6,"../nodejs/NodejsStreamOutputAdapter":13,"../support":30,"../utils":32,"./ConvertWorker":24,"./GenericWorker":28}],30:[function(t,e,r){"use strict";if(r.base64=!0,r.array=!0,r.string=!0,r.arraybuffer="undefined"!=typeof ArrayBuffer&&"undefined"!=typeof Uint8Array,r.nodebuffer="undefined"!=typeof Buffer,r.uint8array="undefined"!=typeof Uint8Array,"undefined"==typeof ArrayBuffer)r.blob=!1;else{var i=new ArrayBuffer(0);try{r.blob=0===new Blob([i],{type:"application/zip"}).size}catch(t){try{var n=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);n.append(i),r.blob=0===n.getBlob("application/zip").size}catch(t){r.blob=!1}}}try{r.nodestream=!!t("readable-stream").Readable}catch(t){r.nodestream=!1}},{"readable-stream":16}],31:[function(t,e,s){"use strict";function a(){i.call(this,"utf-8 decode"),this.leftOver=null}function l(){i.call(this,"utf-8 encode")}for(var o=t("./utils"),h=t("./support"),r=t("./nodejsUtils"),i=t("./stream/GenericWorker"),u=new Array(256),n=0;256>n;n++)u[n]=n>=252?6:n>=248?5:n>=240?4:n>=224?3:n>=192?2:1;u[254]=u[254]=1,s.utf8encode=function(t){return h.nodebuffer?r.newBufferFrom(t,"utf-8"):function(t){var e,r,i,n,s,a=t.length,o=0;for(n=0;a>n;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),o+=128>r?1:2048>r?2:65536>r?3:4;for(e=h.uint8array?new Uint8Array(o):new Array(o),n=s=0;o>s;n++)55296==(64512&(r=t.charCodeAt(n)))&&a>n+1&&56320==(64512&(i=t.charCodeAt(n+1)))&&(r=65536+(r-55296<<10)+(i-56320),n++),128>r?e[s++]=r:(2048>r?e[s++]=192|r>>>6:(65536>r?e[s++]=224|r>>>12:(e[s++]=240|r>>>18,e[s++]=128|r>>>12&63),e[s++]=128|r>>>6&63),e[s++]=128|63&r);return e}(t)},s.utf8decode=function(t){return h.nodebuffer?o.transformTo("nodebuffer",t).toString("utf-8"):function(t){var e,r,i,n,s=t.length,a=new Array(2*s);for(e=r=0;s>e;)if((i=t[e++])<128)a[r++]=i;else if(4<(n=u[i]))a[r++]=65533,e+=n-1;else{for(i&=2===n?31:3===n?15:7;n>1&&s>e;)i=i<<6|63&t[e++],n--;n>1?a[r++]=65533:65536>i?a[r++]=i:(i-=65536,a[r++]=55296|i>>10&1023,a[r++]=56320|1023&i)}return a.length!==r&&(a.subarray?a=a.subarray(0,r):a.length=r),o.applyFromCharCode(a)}(t=o.transformTo(h.uint8array?"uint8array":"array",t))},o.inherits(a,i),a.prototype.processChunk=function(t){var e=o.transformTo(h.uint8array?"uint8array":"array",t.data);if(this.leftOver&&this.leftOver.length){if(h.uint8array){var r=e;(e=new Uint8Array(r.length+this.leftOver.length)).set(this.leftOver,0),e.set(r,this.leftOver.length)}else e=this.leftOver.concat(e);this.leftOver=null}var i=function(t,e){var r;for((e=e||t.length)>t.length&&(e=t.length),r=e-1;r>=0&&128==(192&t[r]);)r--;return 0>r?e:0===r?e:r+u[t[r]]>e?r:e}(e),n=e;i!==e.length&&(h.uint8array?(n=e.subarray(0,i),this.leftOver=e.subarray(i,e.length)):(n=e.slice(0,i),this.leftOver=e.slice(i,e.length))),this.push({data:s.utf8decode(n),meta:t.meta})},a.prototype.flush=function(){this.leftOver&&this.leftOver.length&&(this.push({data:s.utf8decode(this.leftOver),meta:{}}),this.leftOver=null)},s.Utf8DecodeWorker=a,o.inherits(l,i),l.prototype.processChunk=function(t){this.push({data:s.utf8encode(t.data),meta:t.meta})},s.Utf8EncodeWorker=l},{"./nodejsUtils":14,"./stream/GenericWorker":28,"./support":30,"./utils":32}],32:[function(t,e,a){"use strict";function n(t){return t}function l(t,e){for(var r=0;r<t.length;++r)e[r]=255&t.charCodeAt(r);return e}function f(t){var e=65536,r=a.getTypeOf(t),i=!0;if("uint8array"===r?i=s.applyCanBeUsed.uint8array:"nodebuffer"===r&&(i=s.applyCanBeUsed.nodebuffer),i)for(;e>1;)try{return s.stringifyByChunk(t,r,e)}catch(t){e=Math.floor(e/2)}return s.stringifyByChar(t)}function d(t,e){for(var r=0;r<t.length;r++)e[r]=t[r];return e}var o=t("./support"),h=t("./base64"),r=t("./nodejsUtils"),i=t("set-immediate-shim"),u=t("./external");a.newBlob=function(e,r){a.checkSupport("blob");try{return new Blob([e],{type:r})}catch(t){try{var i=new(self.BlobBuilder||self.WebKitBlobBuilder||self.MozBlobBuilder||self.MSBlobBuilder);return i.append(e),i.getBlob(r)}catch(t){throw new Error("Bug : can't construct the Blob.")}}};var s={stringifyByChunk:function(t,e,r){var i=[],n=0,s=t.length;if(r>=s)return String.fromCharCode.apply(null,t);for(;s>n;)"array"===e||"nodebuffer"===e?i.push(String.fromCharCode.apply(null,t.slice(n,Math.min(n+r,s)))):i.push(String.fromCharCode.apply(null,t.subarray(n,Math.min(n+r,s)))),n+=r;return i.join("")},stringifyByChar:function(t){for(var e="",r=0;r<t.length;r++)e+=String.fromCharCode(t[r]);return e},applyCanBeUsed:{uint8array:function(){try{return o.uint8array&&1=Line truncated
|
||||||
|
t.pending_buf[t.pending++]=e>>>8&255,t.pending_buf[t.pending++]=255&e}function L(t,e){var r,i,n=t.max_chain_length,s=t.strstart,a=t.prev_length,o=t.nice_match,h=t.strstart>t.w_size-z?t.strstart-(t.w_size-z):0,u=t.window,l=t.w_mask,f=t.prev,d=t.strstart+S,c=u[s+a-1],p=u[s+a];t.prev_length>=t.good_match&&(n>>=2),o>t.lookahead&&(o=t.lookahead);do if(u[(r=e)+a]===p&&u[r+a-1]===c&&u[r]===u[s]&&u[++r]===u[s+1]){s+=2,r++;do;while(u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&u[++s]===u[++r]&&d>s);if(i=S-(d-s),s=d-S,i>a){if(t.match_start=e,o<=(a=i))break;c=u[s+a-1],p=u[s+a]}}while((e=f[e&l])>h&&0!=--n);return a<=t.lookahead?a:t.lookahead}function j(t){var e,r,i,n,s,a,o,h,u,l,f=t.w_size;do{if(n=t.window_size-t.lookahead-t.strstart,t.strstart>=f+(f-z)){for(d.arraySet(t.window,t.window,f,f,0),t.match_start-=f,t.strstart-=f,t.block_start-=f,e=r=t.hash_size;i=t.head[--e],t.head[e]=i>=f?i-f:0,--r;);for(e=r=f;i=t.prev[--e],t.prev[e]=i>=f?i-f:0,--r;);n+=f}if(0===t.strm.avail_in)break;if(a=t.strm,o=t.window,h=t.strstart+t.lookahead,u=n,l=void 0,l=a.avail_in,l>u&&(l=u),r=0===l?0:(a.avail_in-=l,d.arraySet(o,a.input,a.next_in,l,h),1===a.state.wrap?a.adler=c(a.adler,o,l,h):2===a.state.wrap&&(a.adler=p(a.adler,o,l,h)),a.next_in+=l,a.total_in+=l,l),t.lookahead+=r,t.lookahead+t.insert>=x)for(s=t.strstart-t.insert,t.ins_h=t.window[s],t.ins_h=(t.ins_h<<t.hash_shift^t.window[s+1])&t.hash_mask;t.insert&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[s+x-1])&t.hash_mask,t.prev[s&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=s,s++,t.insert--,!(t.lookahead+t.insert<x)););}while(t.lookahead<z&&0!==t.strm.avail_in)}function Z(t,e){for(var r,i;;){if(t.lookahead<z){if(j(t),t.lookahead<z&&e===l)return A;if(0===t.lookahead)break}if(r=0,t.lookahead>=x&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!==r&&t.strstart-r<=t.w_size-z&&(t.match_length=L(t,r)),t.match_length>=x)if(i=u._tr_tally(t,t.strstart-t.match_start,t.match_length-x),t.lookahead-=t.match_length,t.match_length<=t.max_lazy_match&&t.lookahead>=x){for(t.match_length--;t.strstart++,t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart,0!=--t.match_length;);t.strstart++}else t.strstart+=t.match_length,t.match_length=0,t.ins_h=t.window[t.strstart],t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+1])&t.hash_mask;else i=u._tr_tally(t,0,t.window[t.strstart]),t.lookahead--,t.strstart++;if(i&&(N(t,!1),0===t.strm.avail_out))return A}return t.insert=t.strstart<x-1?t.strstart:x-1,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}function W(t,e){for(var r,i,n;;){if(t.lookahead<z){if(j(t),t.lookahead<z&&e===l)return A;if(0===t.lookahead)break}if(r=0,t.lookahead>=x&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),t.prev_length=t.match_length,t.prev_match=t.match_start,t.match_length=x-1,0!==r&&t.prev_length<t.max_lazy_match&&t.strstart-r<=t.w_size-z&&(t.match_length=L(t,r),t.match_length<=5&&(1===t.strategy||t.match_length===x&&4096<t.strstart-t.match_start)&&(t.match_length=x-1)),t.prev_length>=x&&t.match_length<=t.prev_length){for(n=t.strstart+t.lookahead-x,i=u._tr_tally(t,t.strstart-1-t.prev_match,t.prev_length-x),t.lookahead-=t.prev_length-1,t.prev_length-=2;++t.strstart<=n&&(t.ins_h=(t.ins_h<<t.hash_shift^t.window[t.strstart+x-1])&t.hash_mask,r=t.prev[t.strstart&t.w_mask]=t.head[t.ins_h],t.head[t.ins_h]=t.strstart),0!=--t.prev_length;);if(t.match_available=0,t.match_length=x-1,t.strstart++,i&&(N(t,!1),0===t.strm.avail_out))return A}else if(t.match_available){if((i=u._tr_tally(t,0,t.window[t.strstart-1]))&&N(t,!1),t.strstart++,t.lookahead--,0===t.strm.avail_out)return A}else t.match_available=1,t.strstart++,t.lookahead--}return t.match_available&&(i=u._tr_tally(t,0,t.window[t.strstart-1]),t.match_available=0),t.insert=t.strstart<x-1?t.strstart:x-1,e===f?(N(t,!0),0===t.strm.avail_out?O:B):t.last_lit&&(N(t,!1),0===t.strm.avail_out)?A:I}function M(t,e,r,i,n){this.good_length=t,this.max_lazy=e,this.nice_length=r,this.max_chain=i,this.func=n}function H(){this.strm=null,this.status=0,this.pending_buf=null,this.pending_buf_size=0,this.pending_out=0,this.pending=0,this.wrap=0,this.gzhead=null,this.gzindex=0,this.method=v,this.last_flush=-1,this.w_size=0,this.w_bits=0,this.w_mask=0,this.window=null,this.window_size=0,this.prev=null,this.head=null,this.ins_h=0,this.hash_size=0,this.hash_bits=0,this.hash_mask=0,this.hash_shift=0,this.block_start=0,this.match_length=0,this.prev_match=0,this.match_available=0,this.strstart=0,this.match_start=0,this.lookahead=0,this.prev_length=0,this.max_chain_length=0,this.max_lazy_match=0,this.level=0,this.strategy=0,this.good_match=0,this.nice_match=0,this.dyn_ltree=new d.Buf16(2*w),this.dynLine truncated
|
||||||
|
s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4))}function V(t,e,r){var i,n,s=-1,a=e[1],o=0,h=7,u=4;for(0===a&&(h=138,u=3),i=0;r>=i;i++)if(n=a,a=e[2*(i+1)+1],!(++o<h&&n===a)){if(u>o)for(;L(t,n,t.bl_tree),0!=--o;);else 0!==n?(n!==s&&(L(t,n,t.bl_tree),o--),L(t,b,t.bl_tree),P(t,o-3,2)):10>=o?(L(t,v,t.bl_tree),P(t,o-3,3)):(L(t,y,t.bl_tree),P(t,o-11,7));s=n,u=(o=0)===a?(h=138,3):n===a?(h=6,3):(h=7,4)}}function J(t,e,r,i){P(t,(s<<1)+(i?1:0),3),function(t,e,r,i){M(t),i&&(U(t,r),U(t,~r)),n.arraySet(t.pending_buf,t.window,e,r,t.pending),t.pending+=r}(t,e,r,!0)}var n=t("../utils/common"),o=0,h=1,s=0,a=29,u=256,l=u+1+a,f=30,d=19,_=2*l+1,g=15,c=16,p=7,m=256,b=16,v=17,y=18,w=[0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0],k=[0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13],x=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7],S=[16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15],z=new Array(2*(l+2));i(z);var C=new Array(2*f);i(C);var E=new Array(512);i(E);var A=new Array(256);i(A);var I=new Array(a);i(I);var O,B,R,T=new Array(f);i(T);var q=!1;r._tr_init=function(t){q||(function(){var t,e,r,i,n,s=new Array(g+1);for(i=r=0;a-1>i;i++)for(I[i]=r,t=0;t<1<<w[i];t++)A[r++]=i;for(A[r-1]=i,i=n=0;16>i;i++)for(T[i]=n,t=0;t<1<<k[i];t++)E[n++]=i;for(n>>=7;f>i;i++)for(T[i]=n<<7,t=0;t<1<<k[i]-7;t++)E[256+n++]=i;for(e=0;g>=e;e++)s[e]=0;for(t=0;143>=t;)z[2*t+1]=8,t++,s[8]++;for(;255>=t;)z[2*t+1]=9,t++,s[9]++;for(;279>=t;)z[2*t+1]=7,t++,s[7]++;for(;287>=t;)z[2*t+1]=8,t++,s[8]++;for(Z(z,l+1,s),t=0;f>t;t++)C[2*t+1]=5,C[2*t]=j(t,5);O=new D(z,w,u+1,l,g),B=new D(C,k,0,f,g),R=new D(new Array(0),x,0,d,p)}(),q=!0),t.l_desc=new F(t.dyn_ltree,O),t.d_desc=new F(t.dyn_dtree,B),t.bl_desc=new F(t.bl_tree,R),t.bi_buf=0,t.bi_valid=0,W(t)},r._tr_stored_block=J,r._tr_flush_block=function(t,e,r,i){var n,s,a=0;0<t.level?(2===t.strm.data_type&&(t.strm.data_type=function(t){var e,r=4093624447;for(e=0;31>=e;e++,r>>>=1)if(1&r&&0!==t.dyn_ltree[2*e])return o;if(0!==t.dyn_ltree[18]||0!==t.dyn_ltree[20]||0!==t.dyn_ltree[26])return h;for(e=32;u>e;e++)if(0!==t.dyn_ltree[2*e])return h;return o}(t)),Y(t,t.l_desc),Y(t,t.d_desc),a=function(t){var e;for(X(t,t.dyn_ltree,t.l_desc.max_code),X(t,t.dyn_dtree,t.d_desc.max_code),Y(t,t.bl_desc),e=d-1;e>=3&&0===t.bl_tree[2*S[e]+1];e--);return t.opt_len+=3*(e+1)+5+5+4,e}(t),n=t.opt_len+3+7>>>3,(s=t.static_len+3+7>>>3)<=n&&(n=s)):n=s=r+5,n>=r+4&&-1!==e?J(t,e,r,i):4===t.strategy||s===n?(P(t,2+(i?1:0),3),K(t,z,C)):(P(t,4+(i?1:0),3),function(t,e,r,i){var n;for(P(t,e-257,5),P(t,r-1,5),P(t,i-4,4),n=0;i>n;n++)P(t,t.bl_tree[2*S[n]+1],3);V(t,t.dyn_ltree,e-1),V(t,t.dyn_dtree,r-1)}(t,t.l_desc.max_code+1,t.d_desc.max_code+1,a+1),K(t,t.dyn_ltree,t.dyn_dtree)),W(t),i&&M(t)},r._tr_tally=function(t,e,r){return t.pending_buf[t.d_buf+2*t.last_lit]=e>>>8&255,t.pending_buf[t.d_buf+2*t.last_lit+1]=255&e,t.pending_buf[t.l_buf+t.last_lit]=255&r,t.last_lit++,0===e?t.dyn_ltree[2*r]++:(t.matches++,e--,t.dyn_ltree[2*(A[r]+u+1)]++,t.dyn_dtree[2*N(e)]++),t.last_lit===t.lit_bufsize-1},r._tr_align=function(t){P(t,2,3),L(t,m,z),function(t){16===t.bi_valid?(U(t,t.bi_buf),t.bi_buf=0,t.bi_valid=0):8<=t.bi_valid&&(t.pending_buf[t.pending++]=255&t.bi_buf,t.bi_buf>>=8,t.bi_valid-=8)}(t)}},{"../utils/common":41}],53:[function(t,e,r){"use strict";e.exports=function(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg="",this.state=null,this.data_type=2,this.adler=0}},{}],54:[function(t,e,r){"use strict";e.exports="function"==typeof setImmediate?setImmediate:function(){var t=[].slice.apply(arguments);t.splice(1,0,0),setTimeout.apply(null,t)}},{}]},{},[10])(10)})}).call(this,"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{},require("buffer").Buffer)},{buffer:83}],89:[function(require,module,exports){exports.Parser=require("./lib/parser").Parser,exports.rules=require("./lib/rules"),exports.errors=require("./lib/errors"),exports.results=require("./lib/parsing-results"),exports.StringSource=require("./lib/StringSource"),exports.Token=require("./lib/Token"),exports.bottomUp=require("./lib/bottom-up"),exports.RegexTokeniser=require("./lib/regex-tokeniser").RegexTokeniser,exports.rule=function(ruleBuilder){var rule;return function(input){return rule||(rule=ruleBuilder()),rule(input)}}},{"./lib/StringSource":90,"./lib/Token":91,"./lib/bottom-up":93,"./lib/errors":94,"./lib/parser":96,"./lib/parsing-results":97,"./lib/regex-tokeniser":98,"./lib/rules":99}],90:[function(require,module,exports){var util=require("util"),StringSourceRange=(module.exports=function(string,description){var self={asString:function(){return string},range:function(startIndex,endIndex){return new StringSourceRange(string,description,startIndex,endIndex)}};return self},function(string,description,startIndex,endIndex){this._string=string,this._description=description,this._startIndex=startIndex,this._endIndex=endIndex});StringSourceRange.prototype.to=functioLine truncated
|
||||||
|
for(var length=path.length,i=0;length>i;i++){if(null==obj)return void 0;obj=obj[path[i]]}return length?obj:void 0}function get(object,path,defaultValue){var value=deepGet(object,toPath(path));return isUndefined(value)?defaultValue:value}function has(obj,path){path=toPath(path);for(var length=path.length,i=0;length>i;i++){var key=path[i];if(!has$1(obj,key))return!1;obj=obj[key]}return!!length}function identity(value){return value}function matcher(attrs){return attrs=extendOwn({},attrs),function(obj){return isMatch(obj,attrs)}}function property(path){return path=toPath(path),function(obj){return deepGet(obj,path)}}function optimizeCb(func,context,argCount){if(void 0===context)return func;switch(null==argCount?3:argCount){case 1:return function(value){return func.call(context,value)};case 3:return function(value,index,collection){return func.call(context,value,index,collection)};case 4:return function(accumulator,value,index,collection){return func.call(context,accumulator,value,index,collection)}}return function(){return func.apply(context,arguments)}}function baseIteratee(value,context,argCount){return null==value?identity:isFunction$1(value)?optimizeCb(value,context,argCount):isObject(value)&&!isArray(value)?matcher(value):property(value)}function iteratee(value,context){return baseIteratee(value,context,1/0)}function cb(value,context,argCount){return _$1.iteratee!==iteratee?_$1.iteratee(value,context):baseIteratee(value,context,argCount)}function mapObject(obj,iteratee,context){iteratee=cb(iteratee,context);for(var _keys=keys(obj),length=_keys.length,results={},index=0;length>index;index++){var currentKey=_keys[index];results[currentKey]=iteratee(obj[currentKey],currentKey,obj)}return results}function noop(){}function propertyOf(obj){return null==obj?noop:function(path){return get(obj,path)}}function times(n,iteratee,context){var accum=Array(Math.max(0,n));iteratee=optimizeCb(iteratee,context,1);for(var i=0;n>i;i++)accum[i]=iteratee(i);return accum}function random(min,max){return null==max&&(max=min,min=0),min+Math.floor(Math.random()*(max-min+1))}function createEscaper(map){var escaper=function(match){return map[match]},source="(?:"+keys(map).join("|")+")",testRegexp=RegExp(source),replaceRegexp=RegExp(source,"g");return function(string){return string=null==string?"":""+string,testRegexp.test(string)?string.replace(replaceRegexp,escaper):string}}function escapeChar(match){return"\\"+escapes[match]}function template(text,settings,oldSettings){!settings&&oldSettings&&(settings=oldSettings),settings=defaults({},settings,_$1.templateSettings);var matcher=RegExp([(settings.escape||noMatch).source,(settings.interpolate||noMatch).source,(settings.evaluate||noMatch).source].join("|")+"|$","g"),index=0,source="__p+='";text.replace(matcher,function(match,escape,interpolate,evaluate,offset){return source+=text.slice(index,offset).replace(escapeRegExp,escapeChar),index=offset+match.length,escape?source+="'+\n((__t=("+escape+"))==null?'':_.escape(__t))+\n'":interpolate?source+="'+\n((__t=("+interpolate+"))==null?'':__t)+\n'":evaluate&&(source+="';\n"+evaluate+"\n__p+='"),match}),source+="';\n";var argument=settings.variable;if(argument){if(!bareIdentifier.test(argument))throw new Error("variable is not a bare identifier: "+argument)}else source="with(obj||{}){\n"+source+"}\n",argument="obj";source="var __t,__p='',__j=Array.prototype.join,print=function(){__p+=__j.call(arguments,'');};\n"+source+"return __p;\n";var render;try{render=new Function(argument,"_",source)}catch(e){throw e.source=source,e}var template=function(data){return render.call(this,data,_$1)};return template.source="function("+argument+"){\n"+source+"}",template}function result(obj,path,fallback){path=toPath(path);var length=path.length;if(!length)return isFunction$1(fallback)?fallback.call(obj):fallback;for(var i=0;length>i;i++){var prop=null==obj?void 0:obj[path[i]];void 0===prop&&(prop=fallback,i=length),obj=isFunction$1(prop)?prop.call(obj):prop}return obj}function uniqueId(prefix){var id=++idCounter+"";return prefix?prefix+id:id}function chain(obj){var instance=_$1(obj);return instance._chain=!0,instance}function executeBound(sourceFunc,boundFunc,context,callingContext,args){if(!(callingContext instanceof boundFunc))return sourceFunc.apply(context,args);var self=baseCreate(sourceFunc.prototype),result=sourceFunc.apply(self,args);return isObject(result)?result:self}function flatten$1(input,depth,strict,output){if(output=output||[],depth||0===depth){if(0>=depth)return output.concat(input)}else depth=1/0;for(var idx=output.length,i=0,length=getLength(input);length>i;i++){var value=input[i];if(isArrayLike(value)&&(isArray(value)||isArguments$1(value)))if(depth>1)flatten$1(value,depth-1,strict,output),idx=output.length;else for(var j=0,len=value.length;len>j;)output[idx++]=value[j++];else strict||(output[idx++]=value)}return output}function memoize(func,hasher){var memoize=function(key){var cache=memoize.cache,address=""+(hasher?hasher.apply(this,argLine truncated
|
||||||
|
if(i>=len)return x;switch(x){case"%s":return String(args[i++]);case"%d":return Number(args[i++]);case"%j":try{return JSON.stringify(args[i++])}catch(_){return"[Circular]"}default:return x}}),x=args[i];len>i;x=args[++i])str+=isNull(x)||!isObject(x)?" "+x:" "+inspect(x);return str},exports.deprecate=function(fn,msg){function deprecated(){if(!warned){if(process.throwDeprecation)throw new Error(msg);process.traceDeprecation?console.trace(msg):console.error(msg),warned=!0}return fn.apply(this,arguments)}if(isUndefined(global.process))return function(){return exports.deprecate(fn,msg).apply(this,arguments)};if(process.noDeprecation===!0)return fn;var warned=!1;return deprecated};var debugEnviron,debugs={};exports.debuglog=function(set){if(isUndefined(debugEnviron)&&(debugEnviron=process.env.NODE_DEBUG||""),set=set.toUpperCase(),!debugs[set])if(new RegExp("\\b"+set+"\\b","i").test(debugEnviron)){var pid=process.pid;debugs[set]=function(){var msg=exports.format.apply(exports,arguments);console.error("%s %d: %s",set,pid,msg)}}else debugs[set]=function(){};return debugs[set]},exports.inspect=inspect,inspect.colors={bold:[1,22],italic:[3,23],underline:[4,24],inverse:[7,27],white:[37,39],grey:[90,39],black:[30,39],blue:[34,39],cyan:[36,39],green:[32,39],magenta:[35,39],red:[31,39],yellow:[33,39]},inspect.styles={special:"cyan",number:"yellow","boolean":"yellow",undefined:"grey","null":"bold",string:"green",date:"magenta",regexp:"red"},exports.isArray=isArray,exports.isBoolean=isBoolean,exports.isNull=isNull,exports.isNullOrUndefined=isNullOrUndefined,exports.isNumber=isNumber,exports.isString=isString,exports.isSymbol=isSymbol,exports.isUndefined=isUndefined,exports.isRegExp=isRegExp,exports.isObject=isObject,exports.isDate=isDate,exports.isError=isError,exports.isFunction=isFunction,exports.isPrimitive=isPrimitive,exports.isBuffer=require("./support/isBuffer");var months=["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];exports.log=function(){console.log("%s - %s",timestamp(),exports.format.apply(exports,arguments))},exports.inherits=require("inherits"),exports._extend=function(origin,add){if(!add||!isObject(add))return origin;for(var keys=Object.keys(add),i=keys.length;i--;)origin[keys[i]]=add[keys[i]];return origin}}).call(this,require("_process"),"undefined"!=typeof global?global:"undefined"!=typeof self?self:"undefined"!=typeof window?window:{})},{"./support/isBuffer":104,_process:101,inherits:103}],106:[function(require,module,exports){(function(){var assign,getValue,isArray,isEmpty,isFunction,isObject,isPlainObject,slice=[].slice,hasProp={}.hasOwnProperty;assign=function(){var i,key,len,source,sources,target;if(target=arguments[0],sources=2<=arguments.length?slice.call(arguments,1):[],isFunction(Object.assign))Object.assign.apply(null,arguments);else for(i=0,len=sources.length;len>i;i++)if(source=sources[i],null!=source)for(key in source)hasProp.call(source,key)&&(target[key]=source[key]);return target},isFunction=function(val){return!!val&&"[object Function]"===Object.prototype.toString.call(val)},isObject=function(val){var ref;return!!val&&("function"==(ref=typeof val)||"object"===ref)},isArray=function(val){return isFunction(Array.isArray)?Array.isArray(val):"[object Array]"===Object.prototype.toString.call(val)},isEmpty=function(val){var key;if(isArray(val))return!val.length;for(key in val)if(hasProp.call(val,key))return!1;return!0},isPlainObject=function(val){var ctor,proto;return isObject(val)&&(proto=Object.getPrototypeOf(val))&&(ctor=proto.constructor)&&"function"==typeof ctor&&ctor instanceof ctor&&Function.prototype.toString.call(ctor)===Function.prototype.toString.call(Object)},getValue=function(obj){return isFunction(obj.valueOf)?obj.valueOf():obj},module.exports.assign=assign,module.exports.isFunction=isFunction,module.exports.isObject=isObject,module.exports.isArray=isArray,module.exports.isEmpty=isEmpty,module.exports.isPlainObject=isPlainObject,module.exports.getValue=getValue}).call(this)},{}],107:[function(require,module,exports){(function(){var XMLAttribute;module.exports=XMLAttribute=function(){function XMLAttribute(parent,name,value){if(this.options=parent.options,this.stringify=parent.stringify,this.parent=parent,null==name)throw new Error("Missing attribute name. "+this.debugInfo(name));if(null==value)throw new Error("Missing attribute value. "+this.debugInfo(name));this.name=this.stringify.attName(name),this.value=this.stringify.attValue(value)}return XMLAttribute.prototype.clone=function(){return Object.create(this)},XMLAttribute.prototype.toString=function(options){return this.options.writer.set(options).attribute(this)},XMLAttribute.prototype.debugInfo=function(name){var ref,ref1;return name=name||this.name,null!=name||(null!=(ref=this.parent)?ref.name:void 0)?null==name?"parent: <"+this.parent.name+">":(null!=(ref1=this.parent)?ref1.name:void 0)?"attribute: {"+name+"}, parent: <"+this.parent.name+">":"attribute: {"+name+"}":""},XMLAttribute}()}).call(this)},{}Line truncated
|
||||||
|
len1=val.length;len1>k;k++)item=val[k],childNode={},childNode[key]=item,lastChild=this.element(childNode);else isObject(val)?(lastChild=this.element(key),lastChild.element(val)):lastChild=this.element(key,val)}else lastChild=!this.options.ignoreDecorators&&this.stringify.convertTextKey&&0===name.indexOf(this.stringify.convertTextKey)?this.text(text):!this.options.ignoreDecorators&&this.stringify.convertCDataKey&&0===name.indexOf(this.stringify.convertCDataKey)?this.cdata(text):!this.options.ignoreDecorators&&this.stringify.convertCommentKey&&0===name.indexOf(this.stringify.convertCommentKey)?this.comment(text):!this.options.ignoreDecorators&&this.stringify.convertRawKey&&0===name.indexOf(this.stringify.convertRawKey)?this.raw(text):!this.options.ignoreDecorators&&this.stringify.convertPIKey&&0===name.indexOf(this.stringify.convertPIKey)?this.instruction(name.substr(this.stringify.convertPIKey.length),text):this.node(name,attributes,text);if(null==lastChild)throw new Error("Could not create any elements with: "+name+". "+this.debugInfo());return lastChild},XMLNode.prototype.insertBefore=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.insertAfter=function(name,attributes,text){var child,i,removed;if(this.isRoot)throw new Error("Cannot insert elements at root level. "+this.debugInfo(name));return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.element(name,attributes,text),Array.prototype.push.apply(this.parent.children,removed),child},XMLNode.prototype.remove=function(){var i,ref1;if(this.isRoot)throw new Error("Cannot remove the root element. "+this.debugInfo());return i=this.parent.children.indexOf(this),[].splice.apply(this.parent.children,[i,i-i+1].concat(ref1=[])),ref1,this.parent},XMLNode.prototype.node=function(name,attributes,text){var child,ref1;return null!=name&&(name=getValue(name)),attributes||(attributes={}),attributes=getValue(attributes),isObject(attributes)||(ref1=[attributes,text],text=ref1[0],attributes=ref1[1]),child=new XMLElement(this,name,attributes),null!=text&&child.text(text),this.children.push(child),child},XMLNode.prototype.text=function(value){var child;return child=new XMLText(this,value),this.children.push(child),this},XMLNode.prototype.cdata=function(value){var child;return child=new XMLCData(this,value),this.children.push(child),this},XMLNode.prototype.comment=function(value){var child;return child=new XMLComment(this,value),this.children.push(child),this},XMLNode.prototype.commentBefore=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.commentAfter=function(value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.comment(value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.raw=function(value){var child;return child=new XMLRaw(this,value),this.children.push(child),this},XMLNode.prototype.instruction=function(target,value){var insTarget,insValue,instruction,j,len;if(null!=target&&(target=getValue(target)),null!=value&&(value=getValue(value)),Array.isArray(target))for(j=0,len=target.length;len>j;j++)insTarget=target[j],this.instruction(insTarget);else if(isObject(target))for(insTarget in target)hasProp.call(target,insTarget)&&(insValue=target[insTarget],this.instruction(insTarget,insValue));else isFunction(value)&&(value=value.apply()),instruction=new XMLProcessingInstruction(this,target,value),this.children.push(instruction);return this},XMLNode.prototype.instructionBefore=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.instructionAfter=function(target,value){var child,i,removed;return i=this.parent.children.indexOf(this),removed=this.parent.children.splice(i+1),child=this.parent.instruction(target,value),Array.prototype.push.apply(this.parent.children,removed),this},XMLNode.prototype.declaration=function(version,encoding,standalone){var doc,xmldec;return doc=this.document(),xmldec=new XMLDeclaration(doc,version,encoding,standalone),doc.children[0]instanceof XMLDeclaration?doc.children[0]=xmldec:doc.children.unshift(xmldec),doc.root()||doc},XMLNode.prototype.doctype=function(pubID,sysID){var child,doc,doctype,i,j,k,len,len1,ref1,ref2;for(doc=this.document(),doctype=new XMLDocType(doc,pubID,sysID),ref1=doc.children,i=j=0,len=ref1.length;len>jLine truncated
|
||||||
|
},{}],127:[function(require,module,exports){(function(){var XMLDocument,XMLDocumentCB,XMLStreamWriter,XMLStringWriter,assign,isFunction,ref;ref=require("./Utility"),assign=ref.assign,isFunction=ref.isFunction,XMLDocument=require("./XMLDocument"),XMLDocumentCB=require("./XMLDocumentCB"),XMLStringWriter=require("./XMLStringWriter"),XMLStreamWriter=require("./XMLStreamWriter"),module.exports.create=function(name,xmldec,doctype,options){var doc,root;if(null==name)throw new Error("Root element needs a name.");return options=assign({},xmldec,doctype,options),doc=new XMLDocument(options),root=doc.element(name),options.headless||(doc.declaration(options),(null!=options.pubID||null!=options.sysID)&&doc.doctype(options)),root},module.exports.begin=function(options,onData,onEnd){var ref1;return isFunction(options)&&(ref1=[options,onData],onData=ref1[0],onEnd=ref1[1],options={}),onData?new XMLDocumentCB(options,onData,onEnd):new XMLDocument(options)},module.exports.stringWriter=function(options){return new XMLStringWriter(options)},module.exports.streamWriter=function(stream,options){return new XMLStreamWriter(stream,options)}}).call(this)},{"./Utility":106,"./XMLDocument":116,"./XMLDocumentCB":117,"./XMLStreamWriter":122,"./XMLStringWriter":123}]},{},[21])(21)});
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 hhk-png
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
var MobiParser=(()=>{var Ut=Object.create;var Se=Object.defineProperty;var Mt=Object.getOwnPropertyDescriptor;var zt=Object.getOwnPropertyNames;var kt=Object.getPrototypeOf,Ht=Object.prototype.hasOwnProperty;var Gt=(r=>typeof require!="undefined"?require:typeof Proxy!="undefined"?new Proxy(r,{get:(e,t)=>(typeof require!="undefined"?require:e)[t]}):r)(function(r){if(typeof require!="undefined")return require.apply(this,arguments);throw Error('Dynamic require of "'+r+'" is not supported')});var Me=(r,e)=>()=>{try{return e||r((e={exports:{}}).exports,e),e.exports}catch(t){throw e=0,t}},jt=(r,e)=>{for(var t in e)Se(r,t,{get:e[t],enumerable:!0})},Ze=(r,e,t,s)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of zt(e))!Ht.call(r,o)&&o!==t&&Se(r,o,{get:()=>e[o],enumerable:!(s=Mt(e,o))||s.enumerable});return r};var ze=(r,e,t)=>(t=r!=null?Ut(kt(r)):{},Ze(e||!r||!r.__esModule?Se(t,"default",{value:r,enumerable:!0}):t,r)),Vt=r=>Ze(Se({},"__esModule",{value:!0}),r);var at=Me((sr,ot)=>{"use strict";function ie(r){if(typeof r!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(r))}function st(r,e){for(var t="",s=0,o=-1,l=0,a,u=0;u<=r.length;++u){if(u<r.length)a=r.charCodeAt(u);else{if(a===47)break;a=47}if(a===47){if(!(o===u-1||l===1))if(o!==u-1&&l===2){if(t.length<2||s!==2||t.charCodeAt(t.length-1)!==46||t.charCodeAt(t.length-2)!==46){if(t.length>2){var d=t.lastIndexOf("/");if(d!==t.length-1){d===-1?(t="",s=0):(t=t.slice(0,d),s=t.length-1-t.lastIndexOf("/")),o=u,l=0;continue}}else if(t.length===2||t.length===1){t="",s=0,o=u,l=0;continue}}e&&(t.length>0?t+="/..":t="..",s=2)}else t.length>0?t+="/"+r.slice(o+1,u):t=r.slice(o+1,u),s=u-o-1;o=u,l=0}else a===46&&l!==-1?++l:l=-1}return t}function on(r,e){var t=e.dir||e.root,s=e.base||(e.name||"")+(e.ext||"");return t?t===e.root?t+s:t+r+s:s}var he={resolve:function(){for(var e="",t=!1,s,o=arguments.length-1;o>=-1&&!t;o--){var l;o>=0?l=arguments[o]:(s===void 0&&(s=process.cwd()),l=s),ie(l),l.length!==0&&(e=l+"/"+e,t=l.charCodeAt(0)===47)}return e=st(e,!t),t?e.length>0?"/"+e:"/":e.length>0?e:"."},normalize:function(e){if(ie(e),e.length===0)return".";var t=e.charCodeAt(0)===47,s=e.charCodeAt(e.length-1)===47;return e=st(e,!t),e.length===0&&!t&&(e="."),e.length>0&&s&&(e+="/"),t?"/"+e:e},isAbsolute:function(e){return ie(e),e.length>0&&e.charCodeAt(0)===47},join:function(){if(arguments.length===0)return".";for(var e,t=0;t<arguments.length;++t){var s=arguments[t];ie(s),s.length>0&&(e===void 0?e=s:e+="/"+s)}return e===void 0?".":he.normalize(e)},relative:function(e,t){if(ie(e),ie(t),e===t||(e=he.resolve(e),t=he.resolve(t),e===t))return"";for(var s=1;s<e.length&&e.charCodeAt(s)===47;++s);for(var o=e.length,l=o-s,a=1;a<t.length&&t.charCodeAt(a)===47;++a);for(var u=t.length,d=u-a,m=l<d?l:d,v=-1,g=0;g<=m;++g){if(g===m){if(d>m){if(t.charCodeAt(a+g)===47)return t.slice(a+g+1);if(g===0)return t.slice(a+g)}else l>m&&(e.charCodeAt(s+g)===47?v=g:g===0&&(v=0));break}var p=e.charCodeAt(s+g),y=t.charCodeAt(a+g);if(p!==y)break;p===47&&(v=g)}var b="";for(g=s+v+1;g<=o;++g)(g===o||e.charCodeAt(g)===47)&&(b.length===0?b+="..":b+="/..");return b.length>0?b+t.slice(a+v):(a+=v,t.charCodeAt(a)===47&&++a,t.slice(a))},_makeLong:function(e){return e},dirname:function(e){if(ie(e),e.length===0)return".";for(var t=e.charCodeAt(0),s=t===47,o=-1,l=!0,a=e.length-1;a>=1;--a)if(t=e.charCodeAt(a),t===47){if(!l){o=a;break}}else l=!1;return o===-1?s?"/":".":s&&o===1?"//":e.slice(0,o)},basename:function(e,t){if(t!==void 0&&typeof t!="string")throw new TypeError('"ext" argument must be a string');ie(e);var s=0,o=-1,l=!0,a;if(t!==void 0&&t.length>0&&t.length<=e.length){if(t.length===e.length&&t===e)return"";var u=t.length-1,d=-1;for(a=e.length-1;a>=0;--a){var m=e.charCodeAt(a);if(m===47){if(!l){s=a+1;break}}else d===-1&&(l=!1,d=a+1),u>=0&&(m===t.charCodeAt(u)?--u===-1&&(o=a):(u=-1,o=d))}return s===o?o=d:o===-1&&(o=e.length),e.slice(s,o)}else{for(a=e.length-1;a>=0;--a)if(e.charCodeAt(a)===47){if(!l){s=a+1;break}}else o===-1&&(l=!1,o=a+1);return o===-1?"":e.slice(s,o)}},extname:function(e){ie(e);for(var t=-1,s=0,o=-1,l=!0,a=0,u=e.length-1;u>=0;--u){var d=e.charCodeAt(u);if(d===47){if(!l){s=u+1;break}continue}o===-1&&(l=!1,o=u+1),d===46?t===-1?t=u:a!==1&&(a=1):t!==-1&&(a=-1)}return t===-1||o===-1||a===0||a===1&&t===o-1&&t===s+1?"":e.slice(t,o)},format:function(e){if(e===null||typeof e!="object")throw new TypeError('The "pathObject" argument must be of type Object. Received type '+typeof e);return on("/",e)},parse:function(e){ie(e);var t={root:"",dir:"",base:"",ext:"",name:""};if(e.length===0)return t;var s=e.charCodeAt(0),o=s===47,l;o?(t.root="/",l=1):l=0;for(var a=-1,u=0,d=-1,m=!0,v=e.length-1,g=0;v>=l;--v){if(s=e.charCodeAt(v),s===47){if(!m){u=v+1;break}continue}d===-1&&(m=!1,d=v+1),s===46?a===-1?a=v:g!==1&&(g=1):a!==-1&&(g=-1)}return a===-1||d===-1||g===0||g===1&&a===d-1&&a===u+1?d!==-1&&(u===0&&o?t.base=t.name=e.slice(1,d):t.base=t.name=e.slice(u,d)):(u===0&&o?(t.name=e.slice(1,a),t.baseLine truncated
|
||||||
|
`||i==="\r"||i===" "}function R(i){return i==='"'||i==="'"}function X(i){return i===">"||I(i)}function F(i,n){return i.test(n)}function k(i,n){return!F(i,n)}var f=0;r.STATE={BEGIN:f++,BEGIN_WHITESPACE:f++,TEXT:f++,TEXT_ENTITY:f++,OPEN_WAKA:f++,SGML_DECL:f++,SGML_DECL_QUOTED:f++,DOCTYPE:f++,DOCTYPE_QUOTED:f++,DOCTYPE_DTD:f++,DOCTYPE_DTD_QUOTED:f++,COMMENT_STARTING:f++,COMMENT:f++,COMMENT_ENDING:f++,COMMENT_ENDED:f++,CDATA:f++,CDATA_ENDING:f++,CDATA_ENDING_2:f++,PROC_INST:f++,PROC_INST_BODY:f++,PROC_INST_ENDING:f++,OPEN_TAG:f++,OPEN_TAG_SLASH:f++,ATTRIB:f++,ATTRIB_NAME:f++,ATTRIB_NAME_SAW_WHITE:f++,ATTRIB_VALUE:f++,ATTRIB_VALUE_QUOTED:f++,ATTRIB_VALUE_CLOSED:f++,ATTRIB_VALUE_UNQUOTED:f++,ATTRIB_VALUE_ENTITY_Q:f++,ATTRIB_VALUE_ENTITY_U:f++,CLOSE_TAG:f++,CLOSE_TAG_SAW_WHITE:f++,SCRIPT:f++,SCRIPT_ENDING:f++},r.XML_ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'"}),r.ENTITIES=Object.assign(Object.create(null),{amp:"&",gt:">",lt:"<",quot:'"',apos:"'",AElig:198,Aacute:193,Acirc:194,Agrave:192,Aring:197,Atilde:195,Auml:196,Ccedil:199,ETH:208,Eacute:201,Ecirc:202,Egrave:200,Euml:203,Iacute:205,Icirc:206,Igrave:204,Iuml:207,Ntilde:209,Oacute:211,Ocirc:212,Ograve:210,Oslash:216,Otilde:213,Ouml:214,THORN:222,Uacute:218,Ucirc:219,Ugrave:217,Uuml:220,Yacute:221,aacute:225,acirc:226,aelig:230,agrave:224,aring:229,atilde:227,auml:228,ccedil:231,eacute:233,ecirc:234,egrave:232,eth:240,euml:235,iacute:237,icirc:238,igrave:236,iuml:239,ntilde:241,oacute:243,ocirc:244,ograve:242,oslash:248,otilde:245,ouml:246,szlig:223,thorn:254,uacute:250,ucirc:251,ugrave:249,uuml:252,yacute:253,yuml:255,copy:169,reg:174,nbsp:160,iexcl:161,cent:162,pound:163,curren:164,yen:165,brvbar:166,sect:167,uml:168,ordf:170,laquo:171,not:172,shy:173,macr:175,deg:176,plusmn:177,sup1:185,sup2:178,sup3:179,acute:180,micro:181,para:182,middot:183,cedil:184,ordm:186,raquo:187,frac14:188,frac12:189,frac34:190,iquest:191,times:215,divide:247,OElig:338,oelig:339,Scaron:352,scaron:353,Yuml:376,fnof:402,circ:710,tilde:732,Alpha:913,Beta:914,Gamma:915,Delta:916,Epsilon:917,Zeta:918,Eta:919,Theta:920,Iota:921,Kappa:922,Lambda:923,Mu:924,Nu:925,Xi:926,Omicron:927,Pi:928,Rho:929,Sigma:931,Tau:932,Upsilon:933,Phi:934,Chi:935,Psi:936,Omega:937,alpha:945,beta:946,gamma:947,delta:948,epsilon:949,zeta:950,eta:951,theta:952,iota:953,kappa:954,lambda:955,mu:956,nu:957,xi:958,omicron:959,pi:960,rho:961,sigmaf:962,sigma:963,tau:964,upsilon:965,phi:966,chi:967,psi:968,omega:969,thetasym:977,upsih:978,piv:982,ensp:8194,emsp:8195,thinsp:8201,zwnj:8204,zwj:8205,lrm:8206,rlm:8207,ndash:8211,mdash:8212,lsquo:8216,rsquo:8217,sbquo:8218,ldquo:8220,rdquo:8221,bdquo:8222,dagger:8224,Dagger:8225,bull:8226,hellip:8230,permil:8240,prime:8242,Prime:8243,lsaquo:8249,rsaquo:8250,oline:8254,frasl:8260,euro:8364,image:8465,weierp:8472,real:8476,trade:8482,alefsym:8501,larr:8592,uarr:8593,rarr:8594,darr:8595,harr:8596,crarr:8629,lArr:8656,uArr:8657,rArr:8658,dArr:8659,hArr:8660,forall:8704,part:8706,exist:8707,empty:8709,nabla:8711,isin:8712,notin:8713,ni:8715,prod:8719,sum:8721,minus:8722,lowast:8727,radic:8730,prop:8733,infin:8734,ang:8736,and:8743,or:8744,cap:8745,cup:8746,int:8747,there4:8756,sim:8764,cong:8773,asymp:8776,ne:8800,equiv:8801,le:8804,ge:8805,sub:8834,sup:8835,nsub:8836,sube:8838,supe:8839,oplus:8853,otimes:8855,perp:8869,sdot:8901,lceil:8968,rceil:8969,lfloor:8970,rfloor:8971,lang:9001,rang:9002,loz:9674,spades:9824,clubs:9827,hearts:9829,diams:9830}),Object.keys(r.ENTITIES).forEach(function(i){var n=r.ENTITIES[i],h=typeof n=="number"?String.fromCharCode(n):n;r.ENTITIES[i]=h});for(var W in r.STATE)r.STATE[r.STATE[W]]=W;f=r.STATE;function _(i,n,h){i[n]&&i[n](h)}function J(i){var n=i&&i.match(/(?:^|\s)encoding\s*=\s*(['"])([^'"]+)\1/i);return n?n[2]:null}function ce(i){return i?i.toLowerCase().replace(/[^a-z0-9]/g,""):null}function ne(i,n){let h=ce(i),c=ce(n);return!h||!c?!0:c==="utf16"?h==="utf16le"||h==="utf16be":h===c}function Y(i,n){if(!(!i.strict||!i.encoding||!n||n.name!=="xml")){var h=J(n.body);h&&!ne(i.encoding,h)&&N(i,"XML declaration encoding "+h+" does not match detected stream encoding "+i.encoding.toUpperCase())}}function A(i,n,h){i.textNode&&U(i),_(i,n,h)}function U(i){i.textNode=pe(i.opt,i.textNode),i.textNode&&_(i,"ontext",i.textNode),i.textNode=""}function pe(i,n){return i.trim&&(n=n.trim()),i.normalize&&(n=n.replace(/\s+/g," ")),n}function z(i,n){return U(i),i.trackPosition&&(n+=`
|
||||||
|
Line: `+i.line+`
|
||||||
|
Column: `+i.column+`
|
||||||
|
Char: `+i.c),n=new Error(n),i.error=n,_(i,"onerror",n),i}function xe(i){return i.sawRoot&&!i.closedRoot&&N(i,"Unclosed root tag"),i.state!==f.BEGIN&&i.state!==f.BEGIN_WHITESPACE&&i.state!==f.TEXT&&z(i,"Unexpected end"),U(i),i.c="",i.closed=!0,_(i,"onend"),t.call(i,i.strict,i.opt),i}function N(i,n){if(typeof i!="object"||!(i instanceof t))throw new Error("bad call to strictFail");i.strict&&z(i,n)}function me(i){i.strict||(i.tagName=i.tagName[i.looseCase]());var n=i.tags[i.tags.length-1]||i,h=i.tag={name:i.tagName,attributes:{}};i.opt.xmlns&&(h.ns=n.ns),i.attribList.length=0,A(i,"onopentagstart",h)}function re(i,n){var h=i.indexOf(":"),c=h<0?["",i]:i.split(":"),S=c[0],L=c[1];return n&&i==="xmlns"&&(S="xmlns",L=""),{prefix:S,local:L}}function fe(i){if(i.strict||(i.attribName=i.attribName[i.looseCase]()),i.attribList.indexOf(i.attribName)!==-1||i.tag.attributes.hasOwnProperty(i.attribName)){i.attribName=i.attribValue="";return}if(i.opt.xmlns){var n=re(i.attribName,!0),h=n.prefix,c=n.local;if(h==="xmlns")if(c==="xml"&&i.attribValue!==y)N(i,"xml: prefix must be bound to "+y+`
|
||||||
|
Actual: `+i.attribValue);else if(c==="xmlns"&&i.attribValue!==b)N(i,"xmlns: prefix must be bound to "+b+`
|
||||||
|
Actual: `+i.attribValue);else{var S=i.tag,L=i.tags[i.tags.length-1]||i;S.ns===L.ns&&(S.ns=Object.create(L.ns)),S.ns[c]=i.attribValue}i.attribList.push([i.attribName,i.attribValue])}else i.tag.attributes[i.attribName]=i.attribValue,A(i,"onattribute",{name:i.attribName,value:i.attribValue});i.attribName=i.attribValue=""}function q(i,n){if(i.opt.xmlns){var h=i.tag,c=re(i.tagName);h.prefix=c.prefix,h.local=c.local,h.uri=h.ns[c.prefix]||"",h.prefix&&!h.uri&&(N(i,"Unbound namespace prefix: "+JSON.stringify(i.tagName)),h.uri=c.prefix);var S=i.tags[i.tags.length-1]||i;h.ns&&S.ns!==h.ns&&Object.keys(h.ns).forEach(function($e){A(i,"onopennamespace",{prefix:$e,uri:h.ns[$e]})});for(var L=0,P=i.attribList.length;L<P;L++){var j=i.attribList[L],V=j[0],se=j[1],M=re(V,!0),oe=M.prefix,Bt=M.local,We=oe===""?"":h.ns[oe]||"",Ue={name:V,value:se,prefix:oe,local:Bt,uri:We};oe&&oe!=="xmlns"&&!We&&(N(i,"Unbound namespace prefix: "+JSON.stringify(oe)),Ue.uri=oe),i.tag.attributes[V]=Ue,A(i,"onattribute",Ue)}i.attribList.length=0}i.tag.isSelfClosing=!!n,i.sawRoot=!0,i.tags.push(i.tag),A(i,"onopentag",i.tag),n||(!i.noscript&&i.tagName.toLowerCase()==="script"?i.state=f.SCRIPT:i.state=f.TEXT,i.tag=null,i.tagName=""),i.attribName=i.attribValue="",i.attribList.length=0}function le(i){if(!i.tagName){N(i,"Weird empty close tag."),i.textNode+="</>",i.state=f.TEXT;return}if(i.script){if(i.tagName!=="script"){i.script+="</"+i.tagName+">",i.tagName="",i.state=f.SCRIPT;return}A(i,"onscript",i.script),i.script=""}var n=i.tags.length,h=i.tagName;i.strict||(h=h[i.looseCase]());for(var c=h;n--;){var S=i.tags[n];if(S.name!==c)N(i,"Unexpected close tag");else break}if(n<0){N(i,"Unmatched closing tag: "+i.tagName),i.textNode+="</"+i.tagName+">",i.state=f.TEXT;return}i.tagName=h;for(var L=i.tags.length;L-- >n;){var P=i.tag=i.tags.pop();i.tagName=i.tag.name,A(i,"onclosetag",i.tagName);var j={};for(var V in P.ns)j[V]=P.ns[V];var se=i.tags[i.tags.length-1]||i;i.opt.xmlns&&P.ns!==se.ns&&Object.keys(P.ns).forEach(function(M){var oe=P.ns[M];A(i,"onclosenamespace",{prefix:M,uri:oe})})}n===0&&(i.closedRoot=!0),i.tagName=i.attribValue=i.attribName="",i.attribList.length=0,i.state=f.TEXT}function ye(i){var n=i.entity,h=n.toLowerCase(),c,S="";return i.ENTITIES[n]?i.ENTITIES[n]:i.ENTITIES[h]?i.ENTITIES[h]:(n=h,n.charAt(0)==="#"&&(n.charAt(1)==="x"?(n=n.slice(2),c=parseInt(n,16),S=c.toString(16)):(n=n.slice(1),c=parseInt(n,10),S=c.toString(10))),n=n.replace(/^0+/,""),isNaN(c)||S.toLowerCase()!==n||c<0||c>1114111||!Ne(c)?(N(i,"Invalid character entity"),"&"+i.entity+";"):String.fromCodePoint(c))}function Ne(i){return i===9||i===10||i===13||i>=32&&i<=55295||i>=57344&&i<=65533||i>=65536&&i<=1114111}function Te(i,n){n==="<"?(i.state=f.OPEN_WAKA,i.startTagPosition=i.position):I(n)||(N(i,"Non-whitespace before first tag."),i.textNode=n,i.state=f.TEXT)}function be(i,n){var h="";return n<i.length&&(h=i.charAt(n)),h}function Ie(i){var n=this;if(this.error)throw this.error;if(n.closed)return z(n,"Cannot write after close. Assign an onready handler.");if(i===null)return xe(n);typeof i=="object"&&(i=i.toString());for(var h=0,c="";c=be(i,h++),n.c=c,!!c;)switch(n.trackPosition&&(n.position++,c===`
|
||||||
|
`?(n.line++,n.column=0):n.column++),n.state){case f.BEGIN:if(n.state=f.BEGIN_WHITESPACE,c==="\uFEFF")continue;Te(n,c);continue;case f.BEGIN_WHITESPACE:Te(n,c);continue;case f.TEXT:if(n.sawRoot&&!n.closedRoot){for(var L=h-1;c&&c!=="<"&&c!=="&";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
|
||||||
|
`?(n.line++,n.column=0):n.column++);n.textNode+=i.substring(L,h-1)}c==="<"&&!(n.sawRoot&&n.closedRoot&&!n.strict)?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):(!I(c)&&(!n.sawRoot||n.closedRoot)&&N(n,"Text data outside of root node."),c==="&"?n.state=f.TEXT_ENTITY:n.textNode+=c);continue;case f.SCRIPT:c==="<"?n.state=f.SCRIPT_ENDING:n.script+=c;continue;case f.SCRIPT_ENDING:c==="/"?n.state=f.CLOSE_TAG:(n.script+="<"+c,n.state=f.SCRIPT);continue;case f.OPEN_WAKA:if(c==="!")n.state=f.SGML_DECL,n.sgmlDecl="";else if(!I(c))if(F(w,c))n.state=f.OPEN_TAG,n.tagName=c;else if(c==="/")n.state=f.CLOSE_TAG,n.tagName="";else if(c==="?")n.state=f.PROC_INST,n.procInstName=n.procInstBody="";else{if(N(n,"Unencoded <"),n.startTagPosition+1<n.position){var S=n.position-n.startTagPosition;c=new Array(S).join(" ")+c}n.textNode+="<"+c,n.state=f.TEXT}continue;case f.SGML_DECL:if(n.sgmlDecl+c==="--"){n.state=f.COMMENT,n.comment="",n.sgmlDecl="";continue}n.doctype&&n.doctype!==!0&&n.sgmlDecl?(n.state=f.DOCTYPE_DTD,n.doctype+="<!"+n.sgmlDecl+c,n.sgmlDecl=""):g.test(n.sgmlDecl+c)?(A(n,"onopencdata"),n.state=f.CDATA,n.sgmlDecl="",n.cdata=""):p.test(n.sgmlDecl+c)?(n.state=f.DOCTYPE,(n.doctype||n.sawRoot)&&N(n,"Inappropriately located doctype declaration"),n.doctype="",n.sgmlDecl=""):c===">"?(A(n,"onsgmldeclaration",n.sgmlDecl),n.sgmlDecl="",n.state=f.TEXT):(R(c)&&(n.state=f.SGML_DECL_QUOTED),n.sgmlDecl+=c);continue;case f.SGML_DECL_QUOTED:c===n.q&&(n.state=f.SGML_DECL,n.q=""),n.sgmlDecl+=c;continue;case f.DOCTYPE:c===">"?(n.state=f.TEXT,A(n,"ondoctype",n.doctype),n.doctype=!0):(n.doctype+=c,c==="["?n.state=f.DOCTYPE_DTD:R(c)&&(n.state=f.DOCTYPE_QUOTED,n.q=c));continue;case f.DOCTYPE_QUOTED:n.doctype+=c,c===n.q&&(n.q="",n.state=f.DOCTYPE);continue;case f.DOCTYPE_DTD:c==="]"?(n.doctype+=c,n.state=f.DOCTYPE):c==="<"?(n.state=f.OPEN_WAKA,n.startTagPosition=n.position):R(c)?(n.doctype+=c,n.state=f.DOCTYPE_DTD_QUOTED,n.q=c):n.doctype+=c;continue;case f.DOCTYPE_DTD_QUOTED:n.doctype+=c,c===n.q&&(n.state=f.DOCTYPE_DTD,n.q="");continue;case f.COMMENT:c==="-"?n.state=f.COMMENT_ENDING:n.comment+=c;continue;case f.COMMENT_ENDING:c==="-"?(n.state=f.COMMENT_ENDED,n.comment=pe(n.opt,n.comment),n.comment&&A(n,"oncomment",n.comment),n.comment=""):(n.comment+="-"+c,n.state=f.COMMENT);continue;case f.COMMENT_ENDED:c!==">"?(N(n,"Malformed comment"),n.comment+="--"+c,n.state=f.COMMENT):n.doctype&&n.doctype!==!0?n.state=f.DOCTYPE_DTD:n.state=f.TEXT;continue;case f.CDATA:for(var L=h-1;c&&c!=="]";)c=be(i,h++),c&&n.trackPosition&&(n.position++,c===`
|
||||||
|
`?(n.line++,n.column=0):n.column++);n.cdata+=i.substring(L,h-1),c==="]"&&(n.state=f.CDATA_ENDING);continue;case f.CDATA_ENDING:c==="]"?n.state=f.CDATA_ENDING_2:(n.cdata+="]"+c,n.state=f.CDATA);continue;case f.CDATA_ENDING_2:c===">"?(n.cdata&&A(n,"oncdata",n.cdata),A(n,"onclosecdata"),n.cdata="",n.state=f.TEXT):c==="]"?n.cdata+="]":(n.cdata+="]]"+c,n.state=f.CDATA);continue;case f.PROC_INST:c==="?"?n.state=f.PROC_INST_ENDING:I(c)?n.state=f.PROC_INST_BODY:n.procInstName+=c;continue;case f.PROC_INST_BODY:if(!n.procInstBody&&I(c))continue;c==="?"?n.state=f.PROC_INST_ENDING:n.procInstBody+=c;continue;case f.PROC_INST_ENDING:if(c===">"){let se={name:n.procInstName,body:n.procInstBody};Y(n,se),A(n,"onprocessinginstruction",se),n.procInstName=n.procInstBody="",n.state=f.TEXT}else n.procInstBody+="?"+c,n.state=f.PROC_INST_BODY;continue;case f.OPEN_TAG:F(C,c)?n.tagName+=c:(me(n),c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:(I(c)||N(n,"Invalid character in tag name"),n.state=f.ATTRIB));continue;case f.OPEN_TAG_SLASH:c===">"?(q(n,!0),le(n)):(N(n,"Forward-slash in opening tag not followed by >"),n.state=f.ATTRIB);continue;case f.ATTRIB:if(I(c))continue;c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME:c==="="?n.state=f.ATTRIB_VALUE:c===">"?(N(n,"Attribute without value"),n.attribValue=n.attribName,fe(n),q(n)):I(c)?n.state=f.ATTRIB_NAME_SAW_WHITE:F(C,c)?n.attribName+=c:N(n,"Invalid attribute name");continue;case f.ATTRIB_NAME_SAW_WHITE:if(c==="=")n.state=f.ATTRIB_VALUE;else{if(I(c))continue;N(n,"Attribute without value"),n.tag.attributes[n.attribName]="",n.attribValue="",A(n,"onattribute",{name:n.attribName,value:""}),n.attribName="",c===">"?q(n):F(w,c)?(n.attribName=c,n.state=f.ATTRIB_NAME):(N(n,"Invalid attribute name"),n.state=f.ATTRIB)}continue;case f.ATTRIB_VALUE:if(I(c))continue;R(c)?(n.q=c,n.state=f.ATTRIB_VALUE_QUOTED):(n.opt.unquotedAttributeValues||z(n,"Unquoted attribute value"),n.state=f.ATTRIB_VALUE_UNQUOTED,n.attribValue=c);continue;case f.ATTRIB_VALUE_QUOTED:if(c!==n.q){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_Q:n.attribValue+=c;continue}fe(n),n.q="",n.state=f.ATTRIB_VALUE_CLOSED;continue;case f.ATTRIB_VALUE_CLOSED:I(c)?n.state=f.ATTRIB:c===">"?q(n):c==="/"?n.state=f.OPEN_TAG_SLASH:F(w,c)?(N(n,"No whitespace between attributes"),n.attribName=c,n.attribValue="",n.state=f.ATTRIB_NAME):N(n,"Invalid attribute name");continue;case f.ATTRIB_VALUE_UNQUOTED:if(!X(c)){c==="&"?n.state=f.ATTRIB_VALUE_ENTITY_U:n.attribValue+=c;continue}fe(n),c===">"?q(n):n.state=f.ATTRIB;continue;case f.CLOSE_TAG:if(n.tagName)c===">"?le(n):F(C,c)?n.tagName+=c:n.script?(n.script+="</"+n.tagName+c,n.tagName="",n.state=f.SCRIPT):(I(c)||N(n,"Invalid tagname in closing tag"),n.state=f.CLOSE_TAG_SAW_WHITE);else{if(I(c))continue;k(w,c)?n.script?(n.script+="</"+c,n.state=f.SCRIPT):N(n,"Invalid tagname in closing tag."):n.tagName=c}continue;case f.CLOSE_TAG_SAW_WHITE:if(I(c))continue;c===">"?le(n):N(n,"Invalid characters in closing tag");continue;case f.TEXT_ENTITY:case f.ATTRIB_VALUE_ENTITY_Q:case f.ATTRIB_VALUE_ENTITY_U:var P,j;switch(n.state){case f.TEXT_ENTITY:P=f.TEXT,j="textNode";break;case f.ATTRIB_VALUE_ENTITY_Q:P=f.ATTRIB_VALUE_QUOTED,j="attribValue";break;case f.ATTRIB_VALUE_ENTITY_U:P=f.ATTRIB_VALUE_UNQUOTED,j="attribValue";break}if(c===";"){var V=ye(n);n.opt.unparsedEntities&&!Object.values(r.XML_ENTITIES).includes(V)?((n.entityCount+=1)>n.opt.maxEntityCount&&z(n,"Parsed entity count exceeds max entity count"),(n.entityDepth+=1)>n.opt.maxEntityDepth&&z(n,"Parsed entity depth exceeds max entity depth"),n.entity="",n.state=P,n.write(V),n.entityDepth-=1):(n[j]+=V,n.entity="",n.state=P)}else F(n.entity.length?E:O,c)?n.entity+=c:(N(n,"Invalid character in entity name"),n[j]+="&"+n.entity+c,n.entity="",n.state=P);continue;default:throw new Error(n,"Unknown state: "+n.state)}return n.position>=n.bufferCheckPosition&&s(n),n}String.fromCodePoint||(function(){var i=String.fromCharCode,n=Math.floor,h=function(){var c=16384,S=[],L,P,j=-1,V=arguments.length;if(!V)return"";for(var se="";++j<V;){var M=Number(arguments[j]);if(!isFinite(M)||M<0||M>1114111||n(M)!==M)throw RangeError("Invalid code point: "+M);M<=65535?S.push(M):(M-=65536,L=(M>>10)+55296,P=M%1024+56320,S.push(L,P)),(j+1===V||S.length>c)&&(se+=i.apply(null,S),S.length=0)}return se};Object.defineProperty?Object.defineProperty(String,"fromCodePoint",{value:h,configurable:!0,writable:!0}):String.fromCodePoint=h})()})(typeof _e=="undefined"?_e.sax={}:_e)});var tr={};jt(tr,{initKf8File:()=>Rt,initMobiFile:()=>Pt});var $=Uint8Array,ue=Uint16Array,Xt=Int32Array,Qe=new $([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),Ke=new $([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),Yt=new $([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Je=function(r,e){for(var t=new ue(31),s=0;s<31;++s)t[s]=e+=1<<r[s-1];for(var o=new Xt(t[30]),s=1;s<30;++
Line truncated
|
||||||
|
`},headless:!1,chunkSize:1e4,emptyTag:"",cdata:!1}};function pn(r){return r.toLowerCase()}function mn(r){return typeof r=="object"&&r!==null&&Object.keys(r).length===0}function Fe(r,e,t){for(let s of r)e=s(e,t);return e}function de(r,e,t){let s=Object.create(null);s.value=t,s.writable=!0,s.enumerable=!0,s.configurable=!0,Object.defineProperty(r,e,s)}var Ve=class extends wt.EventEmitter{constructor(e){super(),Z(this,"options"),Z(this,"remaining",""),Z(this,"saxParser"),Z(this,"resultObject",null),Z(this,"EXPLICIT_CHARKEY"),Z(this,"errThrown",!1),Z(this,"ended",!1),Z(this,"processAsync",()=>{try{if(this.remaining.length<=this.options.chunkSize){let t=this.remaining;this.remaining="",this.saxParser=this.saxParser.write(t),this.saxParser.close()}else{let t=this.remaining.slice(0,this.options.chunkSize);this.remaining=this.remaining.slice(this.options.chunkSize),this.saxParser=this.saxParser.write(t),setTimeout(this.processAsync,0)}}catch(t){this.errThrown||(this.errThrown=!0,this.emit("error",t))}}),Z(this,"assignOrPush",(t,s,o)=>{s in t?(Array.isArray(t[s])||de(t,s,[t[s]]),t[s].push(o)):this.options.explicitArray?de(t,s,[o]):de(t,s,o)}),Z(this,"reset",()=>{this.removeAllListeners(),this.saxParser=Et.default.parser(this.options.strict,{trim:!1,normalize:!1,xmlns:this.options.xmlns}),this.errThrown=!1,this.saxParser.onerror=a=>{this.saxParser.resume(),this.errThrown||(this.errThrown=!0,this.emit("error",a))},this.saxParser.onend=()=>{this.ended||(this.ended=!0,this.emit("end",this.resultObject))},this.ended=!1,this.EXPLICIT_CHARKEY=this.options.explicitCharkey,this.resultObject=null;let t=[],s=this.options.attrkey,o=this.options.charkey;this.saxParser.onopentag=a=>{let u={};u[o]="",this.options.ignoreAttrs||Object.keys(a.attributes).forEach(d=>{!(s in u)&&!this.options.mergeAttrs&&(u[s]={});let m=this.options.attrValueProcessors?Fe(this.options.attrValueProcessors,a.attributes[d],d):a.attributes[d],v=this.options.attrNameProcessors?Fe(this.options.attrNameProcessors,d):d;this.options.mergeAttrs?this.assignOrPush(u,v,m):de(u[s],v,m)}),u["#name"]=this.options.tagNameProcessors?Fe(this.options.tagNameProcessors,a.name):a.name,this.options.xmlns&&(u[this.options.xmlnskey]={uri:a.uri,local:a.local}),t.push(u)},this.saxParser.onclosetag=()=>{let a=t.pop(),u=a["#name"];(!this.options.explicitChildren||!this.options.preserveChildrenOrder)&&delete a["#name"];let d;a.cdata===!0&&(d=a.cdata,delete a.cdata);let m=t[t.length-1],v="";if(a[o].match(/^\s*$/)&&!d?(v=a[o],delete a[o]):(this.options.trim&&(a[o]=a[o].trim()),this.options.normalize&&(a[o]=a[o].replace(/\s{2,}/g," ").trim()),a[o]=this.options.valueProcessors?Fe(this.options.valueProcessors,a[o],u):a[o],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])),mn(a)&&(typeof this.options.emptyTag=="function"?a=this.options.emptyTag():a=this.options.emptyTag!==""?this.options.emptyTag:v),this.options.validator){let g=`/${t.map(p=>p["#name"]).concat(u).join("/")}`;(()=>{try{a=this.options.validator(g,m&&m[u],a)}catch(p){this.emit("error",p)}})()}if(this.options.explicitChildren&&!this.options.mergeAttrs&&typeof a=="object")if(this.options.preserveChildrenOrder){if(m){m[this.options.childkey]=m[this.options.childkey]||[];let g={};Object.keys(a).forEach(p=>{de(g,p,a[p])}),m[this.options.childkey].push(g),delete a["#name"],Object.keys(a).length===1&&o in a&&!this.EXPLICIT_CHARKEY&&(a=a[o])}}else{let g={};this.options.attrkey in a&&(g[this.options.attrkey]=a[this.options.attrkey],delete a[this.options.attrkey]),!this.options.charsAsChildren&&this.options.charkey in a&&(g[this.options.charkey]=a[this.options.charkey],delete a[this.options.charkey]),Object.getOwnPropertyNames(a).length>0&&(g[this.options.childkey]=a),a=g}if(t.length>0)this.assignOrPush(m,u,a);else{if(this.options.explicitRoot){let g=a,p={};de(p,u,g),a=p}this.resultObject=a,this.ended=!0,this.emit("end",this.resultObject)}};let l=a=>{let u=t[t.length-1];if(u&&(u[o]+=a,this.options.explicitChildren&&this.options.preserveChildrenOrder&&this.options.charsAsChildren&&(this.options.includeWhiteChars||a.replace(/\\n/g,"").trim()!==""))){u[this.options.childkey]=u[this.options.childkey]||[];let d={"#name":"__text__"};d[o]=a,this.options.normalize&&(d[o]=d[o].replace(/\s{2,}/g," ").trim()),u[this.options.childkey].push(d)}return u};this.saxParser.ontext=l,this.saxParser.oncdata=a=>{let u=l(a);u&&(u.cdata=!0)}}),Z(this,"parseString",(t,s)=>{s&&typeof s=="function"&&(this.on("end",o=>{this.reset(),s(null,o)}),this.on("error",o=>{this.reset(),s(o)}));try{return t=t.toString(),t.trim()===""?(this.emit("end",null),!0):(t=vn(t),this.options.async?(this.remaining=t,setTimeout(this.processAsync,0),this.saxParser):this.saxParser.write(t).close())}catch(o){if(!this.errThrown&&!this.ended)this.emit("error",o),this.errThrown=!0;else if(this.ended)throw o}}),Z(this,"parseStringPromise",t=>new Promise((s,o)=>{this.parseString(t,(l,a)=>{l?o(l):s(a)})})),this.options={},Object.keys(bt["0.2"]).forEach(t=>{this.options[t]=b
Line truncated
|
||||||
|
/*! Bundled license information:
|
||||||
|
|
||||||
|
sax/lib/sax.js:
|
||||||
|
(*! http://mths.be/fromcodepoint v0.1.0 by @mathias *)
|
||||||
|
*/
|
||||||
Vendored
+22
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @licstart The following is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*
|
||||||
|
* Copyright 2023 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*/
|
||||||
|
!function webpackUniversalModuleDefinition(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=t.pdfjsLib=e():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf",[],(()=>t.pdfjsLib=e())):"object"==typeof exports?exports["pdfjs-dist/build/pdf"]=t.pdfjsLib=e():t["pdfjs-dist/build/pdf"]=t.pdfjsLib=e()}(globalThis,(()=>(()=>{"use strict";var __webpack_modules__=[,(t,e)=>{Object.defineProperty(e,"__esModule",{value:!0});e.VerbosityLevel=e.Util=e.UnknownErrorException=e.UnexpectedResponseException=e.TextRenderingMode=e.RenderingIntentFlag=e.PromiseCapability=e.PermissionFlag=e.PasswordResponses=e.PasswordException=e.PageActionEventType=e.OPS=e.MissingPDFException=e.MAX_IMAGE_SIZE_TO_CACHE=e.LINE_FACTOR=e.LINE_DESCENT_FACTOR=e.InvalidPDFException=e.ImageKind=e.IDENTITY_MATRIX=e.FormatError=e.FeatureTest=e.FONT_IDENTITY_MATRIX=e.DocumentActionEventType=e.CMapCompressionType=e.BaseException=e.BASELINE_FACTOR=e.AnnotationType=e.AnnotationReplyType=e.AnnotationPrefix=e.AnnotationMode=e.AnnotationFlag=e.AnnotationFieldFlag=e.AnnotationEditorType=e.AnnotationEditorPrefix=e.AnnotationEditorParamsType=e.AnnotationBorderStyleType=e.AnnotationActionEventType=e.AbortException=void 0;e.assert=function assert(t,e){t||unreachable(e)};e.bytesToString=bytesToString;e.createValidAbsoluteUrl=function createValidAbsoluteUrl(t,e=null,i=null){if(!t)return null;try{if(i&&"string"==typeof t){if(i.addDefaultProtocol&&t.startsWith("www.")){const e=t.match(/\./g);e?.length>=2&&(t=`http://${t}`)}if(i.tryConvertEncoding)try{t=stringToUTF8String(t)}catch{}}const s=e?new URL(t,e):new URL(t);if(function _isValidProtocol(t){switch(t?.protocol){case"http:":case"https:":case"ftp:":case"mailto:":case"tel:":return!0;default:return!1}}(s))return s}catch{}return null};e.getModificationDate=function getModificationDate(t=new Date){return[t.getUTCFullYear().toString(),(t.getUTCMonth()+1).toString().padStart(2,"0"),t.getUTCDate().toString().padStart(2,"0"),t.getUTCHours().toString().padStart(2,"0"),t.getUTCMinutes().toString().padStart(2,"0"),t.getUTCSeconds().toString().padStart(2,"0")].join("")};e.getUuid=function getUuid(){if("undefined"!=typeof crypto&&"function"==typeof crypto?.randomUUID)return crypto.randomUUID();const t=new Uint8Array(32);if("undefined"!=typeof crypto&&"function"==typeof crypto?.getRandomValues)crypto.getRandomValues(t);else for(let e=0;e<32;e++)t[e]=Math.floor(255*Math.random());return bytesToString(t)};e.getVerbosityLevel=function getVerbosityLevel(){return n};e.info=function info(t){n>=s.INFOS&&console.log(`Info: ${t}`)};e.isArrayBuffer=function isArrayBuffer(t){return"object"==typeof t&&void 0!==t?.byteLength};e.isArrayEqual=function isArrayEqual(t,e){if(t.length!==e.length)return!1;for(let i=0,s=t.length;i<s;i++)if(t[i]!==e[i])return!1;return!0};e.isNodeJS=void 0;e.normalizeUnicode=function normalizeUnicode(t){if(!l){l=/([\u00a0\u00b5\u037e\u0eb3\u2000-\u200a\u202f\u2126\ufb00-\ufb04\ufb06\ufb20-\ufb36\ufb38-\ufb3c\ufb3e\ufb40-\ufb41\ufb43-\ufb44\ufb46-\ufba1\ufba4-\ufba9\ufbae-\ufbb1\ufbd3-\ufbdc\ufbde-\ufbe7\ufbea-\ufbf8\ufbfc-\ufbfd\ufc00-\ufc5d\ufc64-\ufcf1\ufcf5-\ufd3d\ufd88\ufdf4\ufdfa-\ufdfb\ufe71\ufe77\ufe79\ufe7b\ufe7d]+)|(\ufb05+)/gu;h=new Map([["ſt","ſt"]])}return t.replaceAll(l,((t,e,i)=>e?e.normalize("NFKC"):h.get(i)))};e.objectFromMap=function objectFromMap(t){const e=Object.create(null);for(const[i,s]of t)e[i]=s;return e};e.objectSize=function objectSize(t){return Object.keys(t).length};e.setVerbosityLevel=function setVerbosityLevel(t){Number.isInteger(t)&&(n=t)};e.shadow=shadow;e.string32=function string32(t){return String.fromCharCode(t>>24&255,t>>16&255,t>>8&255,255&t)};e.stringToBytes=stringToBytes;e.stringToPDFString=function stringToPDFString(t){if(t[0]>="ï"){let e;"þ"===t[0]&&"ÿ"===t[1]?e="utf-16be":"ÿ"===t[0]&&"þ"===t[1]?e="utf-16le":"ï"===t[0]&&"»"===t[1]&&"¿"===t[2]&&(e="utf-8");if(e)try{const i=new TextDecoder(e,{fatal:!0}),s=stringToBytes(t);return i.decode(s)}catch(t){warn(`stringToPDFString: "${t}".`)}}const e=[];for(let i=0,s=t.length;i<s;i++){const s=o[t.charCodeAt(i)];e.push(s?String.fromCharCode(s):t.charAt(i))}return e.join("")};e.stringToUTF8String=stringToUTF8String;e.unreachable=unreachable;e.utf8StringToString=function utf8StringToString(t){return unescape(encodeURIComponent(t))};e.warn=warn;const i=!("object"!=typeof process||process+""!="[object process]"||process.versions.nw||process.versions.electron&&process.type&&"browser"!==process.type);e.isNodeJS=i;e.IDENTITY_MATRIX=[1,0,0,1,0,0];e.FONT_IDENTITY_MATRIX=[.001,0,0,.001,0,0];e.MAX_IMAGE_SIZE_TO_CACHE=1e7;e.LINE_FACTOR=1.35;e.LINE_DESCENT_FACTOR=.35;e.BASELINE_FACTOR=.25925925925925924;e.RenderingIntentFlag={ANY:1,DISPLAY:2,PRINT:4,SAVE:8,ANNOTATIONS_FORMS:16,ANNOTATIONS_STORAGE:32,ANNOTATIONS_DISABLE:64,OPLIST:256};e.AnnotationMode={DISABLE:0,ENABLE:1,ENABLE_FORMS:2,ENABLE_STORAGE:3};e.AnnotationEditorPrefix="pdfjs_internal_editor_";e.AnnotationEditorType={DISABLE:-1,NONE:0,FREETEXT:3,STLine truncated
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* @licstart The following is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*
|
||||||
|
* Copyright 2023 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*
|
||||||
|
* @licend The above is the entire license notice for the
|
||||||
|
* JavaScript code in this page
|
||||||
|
*/
|
||||||
|
!function webpackUniversalModuleDefinition(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=e.pdfjsWorker=t():"function"==typeof define&&define.amd?define("pdfjs-dist/build/pdf.worker",[],(()=>e.pdfjsWorker=t())):"object"==typeof exports?exports["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t():e["pdfjs-dist/build/pdf.worker"]=e.pdfjsWorker=t()}(globalThis,(()=>(()=>{"use strict";var e=[,(e,t,a)=>{Object.defineProperty(t,"__esModule",{value:!0});t.WorkerTask=t.WorkerMessageHandler=void 0;var r=a(2),n=a(3),i=a(4),s=a(6),o=a(10),c=a(68),l=a(73),h=a(104),u=a(105),d=a(72);class WorkerTask{constructor(e){this.name=e;this.terminated=!1;this._capability=new r.PromiseCapability}get finished(){return this._capability.promise}finish(){this._capability.resolve()}terminate(){this.terminated=!0}ensureNotTerminated(){if(this.terminated)throw new Error("Worker task was terminated")}}t.WorkerTask=WorkerTask;class WorkerMessageHandler{static setup(e,t){let a=!1;e.on("test",(function(t){if(!a){a=!0;e.send("test",t instanceof Uint8Array)}}));e.on("configure",(function(e){(0,r.setVerbosityLevel)(e.verbosity)}));e.on("GetDocRequest",(function(e){return WorkerMessageHandler.createDocumentHandler(e,t)}))}static createDocumentHandler(e,t){let a,f=!1,g=null;const p=new Set,m=(0,r.getVerbosityLevel)(),{docId:b,apiVersion:y}=e,w="3.11.174";if(y!==w)throw new Error(`The API version "${y}" does not match the Worker version "${w}".`);const S=[];for(const e in[])S.push(e);if(S.length)throw new Error("The `Array.prototype` contains unexpected enumerable properties: "+S.join(", ")+"; thus breaking e.g. `for...in` iteration of `Array`s.");const x=b+"_worker";let C=new h.MessageHandler(x,b,t);function ensureNotTerminated(){if(f)throw new Error("Worker was terminated")}function startWorkerTask(e){p.add(e)}function finishWorkerTask(e){e.finish();p.delete(e)}async function loadDocument(e){await a.ensureDoc("checkHeader");await a.ensureDoc("parseStartXRef");await a.ensureDoc("parse",[e]);await a.ensureDoc("checkFirstPage",[e]);await a.ensureDoc("checkLastPage",[e]);const t=await a.ensureDoc("isPureXfa");if(t){const e=new WorkerTask("loadXfaFonts");startWorkerTask(e);await Promise.all([a.loadXfaFonts(C,e).catch((e=>{})).then((()=>finishWorkerTask(e))),a.loadXfaImages()])}const[r,n]=await Promise.all([a.ensureDoc("numPages"),a.ensureDoc("fingerprints")]);return{numPages:r,fingerprints:n,htmlForXfa:t?await a.ensureDoc("htmlForXfa"):null}}function getPdfManager({data:e,password:t,disableAutoFetch:a,rangeChunkSize:i,length:o,docBaseUrl:c,enableXfa:l,evaluatorOptions:h}){const d={source:null,disableAutoFetch:a,docBaseUrl:c,docId:b,enableXfa:l,evaluatorOptions:h,handler:C,length:o,password:t,rangeChunkSize:i},f=new r.PromiseCapability;let p;if(e){try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}return f.promise}let m,y=[];try{m=new u.PDFWorkerStream(C)}catch(e){f.reject(e);return f.promise}const w=m.getFullReader();w.headersReady.then((function(){if(w.isRangeSupported){d.source=m;d.length=w.contentLength;d.disableAutoFetch||=w.isStreamingSupported;p=new s.NetworkPdfManager(d);for(const e of y)p.sendProgressiveData(e);y=[];f.resolve(p);g=null}})).catch((function(e){f.reject(e);g=null}));let S=0;new Promise((function(e,t){const readChunk=function({value:e,done:a}){try{ensureNotTerminated();if(a){p||function(){const e=(0,n.arrayBuffersToBytes)(y);o&&e.length!==o&&(0,r.warn)("reported HTTP length is different from actual");try{d.source=e;p=new s.LocalPdfManager(d);f.resolve(p)}catch(e){f.reject(e)}y=[]}();g=null;return}S+=e.byteLength;w.isStreamingSupported||C.send("DocProgress",{loaded:S,total:Math.max(S,w.contentLength||0)});p?p.sendProgressiveData(e):y.push(e);w.read().then(readChunk,t)}catch(e){t(e)}};w.read().then(readChunk,t)})).catch((function(e){f.reject(e);g=null}));g=function(e){m.cancelAllRequests(e)};return f.promise}C.on("GetPage",(function(e){return a.getPage(e.pageIndex).then((function(e){return Promise.all([a.ensure(e,"rotate"),a.ensure(e,"ref"),a.ensure(e,"userUnit"),a.ensure(e,"view")]).then((function([e,t,a,r]){return{rotate:e,ref:t,userUnit:a,view:r}}))}))}));C.on("GetPageIndex",(function(e){const t=i.Ref.get(e.num,e.gen);return a.ensureCatalog("getPageIndex",[t])}));C.on("GetDestinations",(function(e){return a.ensureCatalog("destinations")}));C.on("GetDestination",(function(e){return a.ensureCatalog("getDestination",[e.id])}));C.on("GetPageLabels",(function(e){return a.ensureCatalog("pageLabels")}));C.on("GetPageLayout",(function(e){return a.ensureCatalog("pageLayout")}));C.on("GetPageMode",(function(e){return a.ensureCatalog("pageMode")}));C.on("GetViewerPreferences",(function(e){return a.ensureCatalog("viewerPreferences")}));C.on("GetOpenAction",(function(e){return a.ensureCatalog("openAction")}));C.on("GetAttachments",(function(e){return a.ensureCatalog("attachments")}));C.on("GetDocJSActions",(function(e){return a.ensureCatalog("jsActions")}));C.on("GetPageJSActions",(function({pageInLine truncated
|
||||||
+540
@@ -0,0 +1,540 @@
|
|||||||
|
'use strict';
|
||||||
|
// Movies & TV from your Plex server (the same Plex sign-in and Plex Home user as Music). Browse, search, resume, play, mark watched.
|
||||||
|
// The player plays the file straight when the browser can, otherwise asks Plex to convert it as it plays (HLS, with hls.js where the browser has no HLS of its own).
|
||||||
|
const vd = { libs: null, onDeck: null };
|
||||||
|
const vdEnc = encodeURIComponent;
|
||||||
|
const vdArt = (thumb, w = 300, h = 450) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${h}&minSize=1&upscale=1&url=${vdEnc(thumb)}` : '';
|
||||||
|
const vdTime = ms => { const s = Math.round((ms || 0) / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`; };
|
||||||
|
const vdClock = s => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s % 60).padStart(2, '0'); };
|
||||||
|
const vdEp = m => m.type === 'episode' ? `${esc(m.grandparentTitle || '')} · S${m.parentIndex || '?'}E${m.index || '?'}` : '';
|
||||||
|
const vdPct = m => m.viewOffset && m.duration ? Math.min(100, Math.round(100 * m.viewOffset / m.duration)) : 0;
|
||||||
|
|
||||||
|
let vdLibCache = { t: 0, v: null };
|
||||||
|
async function vdLibs() {
|
||||||
|
if (vdLibCache.v && Date.now() - vdLibCache.t < 5 * 60e3) return vdLibCache.v;
|
||||||
|
const v = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show').map(d => ({ key: d.key, title: d.title })); vdLibCache = { t: Date.now(), v }; return v;
|
||||||
|
}
|
||||||
|
/** Where you are in Movies & TV, with a way back at every level: the library, the show, the season. (Also used in the player.) */
|
||||||
|
const vdCrumbs = m => {
|
||||||
|
const c = [`<a href="#/video/lib/${esc(m.librarySectionID || '')}">${esc(m.librarySectionTitle || 'Library')}</a>`];
|
||||||
|
if (m.type === 'season') c.push(`<a href="#/video/item/${m.parentRatingKey}">${esc(m.parentTitle || 'Show')}</a>`);
|
||||||
|
if (m.type === 'episode') { c.push(`<a href="#/video/item/${m.grandparentRatingKey}">${esc(m.grandparentTitle || 'Show')}</a>`); c.push(`<a href="#/video/item/${m.parentRatingKey}">Season ${m.parentIndex || ''}</a>`); }
|
||||||
|
return c.join('<i>›</i>');
|
||||||
|
};
|
||||||
|
/** The strip at the top of an item page: Back, the breadcrumb, and a pill for every library (Movies, TV Shows, ...). */
|
||||||
|
async function vdNav(m, back) {
|
||||||
|
let libs = []; try { libs = await vdLibs(); } catch {}
|
||||||
|
return `<div class="vnav"><a class="btn ghost" href="${back}">${icon('back')}Back</a><nav class="vcrumbs" aria-label="Where you are">${vdCrumbs(m)}</nav>
|
||||||
|
${libs.length ? `<div class="sgrp" role="navigation" aria-label="Libraries">${libs.map(l => `<a href="#/video/lib/${l.key}" class="${String(l.key) === String(m.librarySectionID) ? 'on' : ''}">${esc(l.title)}</a>`).join('')}</div>` : ''}</div>`;
|
||||||
|
}
|
||||||
|
async function viewVideo(args = []) {
|
||||||
|
const v = $('#view'), [kind, key] = args;
|
||||||
|
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
|
||||||
|
try {
|
||||||
|
const st = await jfetch('/api/plex/status');
|
||||||
|
if (!location.hash.startsWith('#/video')) return;
|
||||||
|
if (!st.signedIn || !st.server) { v.innerHTML = `<div class="wrap"><h1>Movies & TV</h1><p class="sub">Movies and TV shows come from your Plex server. Sign in to Plex (and choose the server) in the Music tab first; it is the same sign-in.</p><div class="bar"><a class="btn" href="#/music">Open Music</a></div></div>`; return; }
|
||||||
|
px.serverKey = st.server;
|
||||||
|
if (kind === 'watch') return videoWatch(key);
|
||||||
|
if (kind === 'item') return videoItem(key);
|
||||||
|
if (kind === 'lib') return videoHome(st, key);
|
||||||
|
return videoHome(st);
|
||||||
|
} catch (e) { v.innerHTML = `<div class="wrap"><h1>Movies & TV</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vretry">Try again</button></div></div>`; $('#vretry').onclick = () => route(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Home: Continue Watching, then a library at a time
|
||||||
|
const vdLeft = m => m.viewOffset && m.duration ? Math.max(1, Math.round((m.duration - m.viewOffset) / 60000)) : 0;
|
||||||
|
const vdCard = (m, wide, deck) => {
|
||||||
|
const href = m.type === 'episode' && !wide ? `#/video/item/${m.ratingKey}` : `#/video/item/${m.ratingKey}`;
|
||||||
|
const pct = vdPct(m), seen = m.viewCount > 0 && !m.viewOffset;
|
||||||
|
const thumb = wide ? (m.type === 'episode' ? m.thumb : m.art || m.thumb) : (m.parentThumb && m.type === 'season' ? m.thumb || m.parentThumb : m.thumb);
|
||||||
|
const html = `<a class="vcard${wide ? ' wide' : ''}" data-rk="${m.ratingKey}" data-type="${m.type}" data-show="${m.grandparentRatingKey || ''}" data-seen="${seen ? 1 : 0}" href="${href}" data-q="${esc((m.title + ' ' + (m.grandparentTitle || '')).toLowerCase())}">
|
||||||
|
<span class="vimg" style="aspect-ratio:${wide ? '16/9' : '2/3'}">${thumb ? `<img loading="lazy" alt="" src="${vdArt(thumb, wide ? 400 : 300, wide ? 225 : 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${seen ? `<em class="vseen" title="Watched">${icon('check')}</em>` : ''}${pct ? `<i class="vprog"><b style="width:${pct}%"></b></i>` : ''}</span>
|
||||||
|
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
|
||||||
|
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : html;
|
||||||
|
};
|
||||||
|
// ---- Watch later: a normal Plex video playlist called "Watch later" (so it follows the Plex person you use, and shows in Plex's own apps too)
|
||||||
|
const wl = { id: '' };
|
||||||
|
async function wlItems() {
|
||||||
|
if (!wl.id) { const p = ((await px.get('/playlists?playlistType=video')).Metadata || []).find(x => x.title === 'Watch later' && !x.smart); if (!p) return []; wl.id = p.ratingKey; }
|
||||||
|
return (await px.get(`/playlists/${wl.id}/items`)).Metadata || [];
|
||||||
|
}
|
||||||
|
async function wlAdd(rk) {
|
||||||
|
const uri = await plx.uri([rk]);
|
||||||
|
if (!wl.id) { await wlItems(); }
|
||||||
|
if (wl.id) return plx.call('PUT', `/playlists/${wl.id}/items?uri=${encodeURIComponent(uri)}`);
|
||||||
|
const r = await plx.call('POST', `/playlists?type=video&smart=0&title=${encodeURIComponent('Watch later')}&uri=${encodeURIComponent(uri)}`); wl.id = (((r.MediaContainer || {}).Metadata || [])[0] || {}).ratingKey || '';
|
||||||
|
}
|
||||||
|
async function wlRemove(rk) { const it = (await wlItems()).find(x => x.ratingKey === String(rk)); if (it) await plx.call('DELETE', `/playlists/${wl.id}/items/${it.playlistItemID}`); }
|
||||||
|
/** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */
|
||||||
|
async function vdRemove(m, quiet) {
|
||||||
|
try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); }
|
||||||
|
catch { try { await px.get(`/:/scrobble?key=${m.ratingKey}&identifier=com.plexapp.plugins.library`); if (!quiet) toast('Marked watched (this Plex server cannot just hide it)'); } catch (e) { toast(e.message); } }
|
||||||
|
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
||||||
|
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
||||||
|
}
|
||||||
|
async function videoHome(st, libKey) {
|
||||||
|
const v = $('#view');
|
||||||
|
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
|
||||||
|
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
|
||||||
|
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
|
||||||
|
store.set('video_lib', cur.key);
|
||||||
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||||
|
<input type="search" id="vq" placeholder="Search movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||||
|
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||||
|
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||||
|
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||||
|
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||||
|
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
|
||||||
|
const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
|
||||||
|
const vcPut = (k, items) => { try { localStorage.setItem(vcK(k), JSON.stringify(items.map(m => ({ ratingKey: m.ratingKey, type: m.type, title: m.title, year: m.year, thumb: m.thumb, art: m.art, parentThumb: m.parentThumb, grandparentTitle: m.grandparentTitle, grandparentRatingKey: m.grandparentRatingKey, parentIndex: m.parentIndex, index: m.index, viewOffset: m.viewOffset, viewCount: m.viewCount, duration: m.duration, leafCount: m.leafCount, viewedLeafCount: m.viewedLeafCount })))); } catch {} };
|
||||||
|
const rowOf = (id, title, items, deck) => { const el = $(id); if (!el) return; el.innerHTML = items.length ? `<h2>${title}${deck ? '<button class="btn ghost vclear" id="vclr" style="min-height:32px;margin-left:10px">Clear all</button>' : ''}</h2><div class="vrow">${items.map(m => vdCard(m, true, deck)).join('')}</div>` : ''; };
|
||||||
|
const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true);
|
||||||
|
const h = items[0], hb = $('#vhero'); // the most recent one gets a banner with a Resume button
|
||||||
|
if (hb) hb.innerHTML = h ? `<a class="vban" href="#/video/watch/${h.ratingKey}" aria-label="Resume ${esc(h.title)}">${(h.art || h.thumb) ? `<img alt="" src="${vdArt(h.art || h.thumb, 900, 380)}" onerror="this.remove()">` : ''}<span class="vbant"><small>${vdLeft(h) ? 'Pick up where you left off · ' + vdLeft(h) + ' min left' : 'Up next'}</small><b>${esc(h.type === 'episode' ? h.grandparentTitle : h.title)}</b>${h.type === 'episode' ? `<small>${vdEp(h).replace(/^.*· /, '')} · ${esc(h.title)}</small>` : ''}<em class="btn">${icon('play')}Resume</em></span></a>` : ''; if ($('#vclr')) $('#vclr').onclick = () => confirmSheet('Clear Continue watching?', 'Takes everything off this row. Your library and watch history stay as they are.', 'Clear all', async () => { for (const m of vd.deck.slice()) await vdRemove(m, true); loadDeck(); }); };
|
||||||
|
const loadDeck = () => px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=16').then(mc => {
|
||||||
|
const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); vcPut('deck', items); if ($('#vdeck')) drawDeck(items);
|
||||||
|
}).catch(() => {});
|
||||||
|
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
|
||||||
|
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
||||||
|
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
|
||||||
|
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
|
||||||
|
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
|
||||||
|
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
|
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
|
// sort and filter the library below
|
||||||
|
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
|
||||||
|
$('#vsort').value = fs; $('#vunwc').checked = fu;
|
||||||
|
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||||||
|
let start = 0, total = 0, all = [], gen = 0;
|
||||||
|
const load = async () => {
|
||||||
|
const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked ? '&unwatched=1' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
|
||||||
|
if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
|
||||||
|
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.remove());
|
||||||
|
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
|
||||||
|
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
|
||||||
|
if (!all.length) { $('#vgrid').innerHTML = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||||||
|
};
|
||||||
|
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
||||||
|
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); };
|
||||||
|
reload();
|
||||||
|
// press and hold (or right-click) a card: quick actions
|
||||||
|
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
|
||||||
|
const menu = card => {
|
||||||
|
const rk = card.dataset.rk, seen = card.dataset.seen === '1', acts = [{ label: seen ? 'Mark unwatched' : 'Mark watched', icon: 'check', run: async () => { try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${rk}&identifier=com.plexapp.plugins.library`); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); } } }];
|
||||||
|
if (card.dataset.show) acts.unshift({ label: 'Go to the show', icon: 'film', run: () => { location.hash = '#/video/item/' + card.dataset.show; } });
|
||||||
|
if (card.closest('.vdeckc')) acts.push({ label: 'Remove from Continue watching', icon: 'x', run: async () => { await vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: card.dataset.type }); loadDeck(); } });
|
||||||
|
actionSheet(card.querySelector('b').textContent, '', acts);
|
||||||
|
};
|
||||||
|
vv.addEventListener('contextmenu', e => { const c = e.target.closest('.vcard'); if (c && c.dataset.rk) { e.preventDefault(); menu(c); } });
|
||||||
|
vv.addEventListener('touchstart', e => { const c = e.target.closest('.vcard'); held = false; clearTimeout(hold); if (c && c.dataset.rk) hold = setTimeout(() => { held = true; menu(c); }, 550); }, { passive: true });
|
||||||
|
for (const ev of ['touchend', 'touchmove', 'touchcancel']) vv.addEventListener(ev, () => clearTimeout(hold), { passive: true });
|
||||||
|
vv.addEventListener('click', e => { if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; } const x = e.target.closest('.vx'); if (x) { e.preventDefault(); const rk = x.dataset.rm; vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: 'movie' }).then(loadDeck); } }, true);
|
||||||
|
let t; $('#vq').oninput = e => {
|
||||||
|
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
|
||||||
|
t = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=10`), rows = [];
|
||||||
|
for (const h of mc.Hub || []) if (['movie', 'show', 'episode'].includes(h.type)) for (const m of h.Metadata || []) rows.push(m);
|
||||||
|
if (!$('#vsr') || $('#vq').value.trim() !== q) return;
|
||||||
|
$('#vsr').innerHTML = rows.length ? `<h2>Results</h2><div class="vgrid">${rows.map(m => vdCard(m)).join('')}</div>` : '<p class="empty">Nothing found.</p>';
|
||||||
|
} catch (err) { if ($('#vsr')) $('#vsr').innerHTML = `<p class="err">${esc(err.message)}</p>`; }
|
||||||
|
}, 350);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- A movie, a show, a season or an episode
|
||||||
|
async function videoItem(id) {
|
||||||
|
const v = $('#view');
|
||||||
|
const mc = await px.get(`/library/metadata/${id}?includeOnDeck=1`), m = (mc.Metadata || [])[0];
|
||||||
|
if (!m) { v.innerHTML = '<div class="wrap"><p class="empty">That item is gone.</p></div>'; return; }
|
||||||
|
const back = m.type === 'season' ? `#/video/item/${m.parentRatingKey}` : m.type === 'episode' ? `#/video/item/${m.parentRatingKey || m.grandparentRatingKey}` : '#/video';
|
||||||
|
const names = (arr, k = 'tag', n = 8) => (arr || []).slice(0, n).map(x => esc(x[k])).join(', ');
|
||||||
|
const media = (m.Media || [])[0] || {}, res = media.videoResolution ? (/^\d+$/.test(media.videoResolution) ? media.videoResolution + 'p' : media.videoResolution.toUpperCase()) : '';
|
||||||
|
const meta = [m.year, m.contentRating, m.duration ? vdTime(m.duration) : '', res, m.audienceRating ? '★ ' + m.audienceRating : (m.rating ? '★ ' + m.rating : '')].filter(Boolean).join(' · ');
|
||||||
|
const playable = m.type === 'movie' || m.type === 'episode';
|
||||||
|
const next = m.OnDeck && m.OnDeck.Metadata && m.OnDeck.Metadata[0];
|
||||||
|
v.innerHTML = `<div class="wrap vdetail">${await vdNav(m, back)}
|
||||||
|
<div class="vhero">${m.art ? `<img class="vbg" alt="" src="${vdArt(m.art, 1000, 560)}" onerror="this.remove()">` : ''}
|
||||||
|
<div class="vhead"><span class="vimg vpost" style="aspect-ratio:2/3">${m.thumb ? `<img alt="" src="${vdArt(m.thumb, 300, 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}</span>
|
||||||
|
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
|
||||||
|
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||||||
|
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||||||
|
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||||||
|
<p class="vsum">${esc(m.summary || '')}</p>
|
||||||
|
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||||
|
<div id="vkids"></div></div>`;
|
||||||
|
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
|
||||||
|
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
|
||||||
|
}).catch(() => {});
|
||||||
|
if (m.type === 'movie' || m.type === 'show') px.get(`/library/metadata/${id}/extras`).catch(() => ({})).then(mc => { // Trailer: one from Plex when it has any, otherwise a YouTube search
|
||||||
|
const box = $('#vact'); if (!box || $('#vtr')) return;
|
||||||
|
const ex = (mc.Metadata || []).filter(x => (x.Media || []).length), tr = ex.filter(x => (x.subtype || '') === 'trailer'), list = tr.length ? tr : ex;
|
||||||
|
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vtr">${icon('play')}Trailer</button>`);
|
||||||
|
$('#vtr').onclick = () => {
|
||||||
|
const yt = () => window.open('https://www.youtube.com/results?search_query=' + vdEnc(`${m.title} ${m.year || ''} ${m.type === 'show' ? 'tv series' : 'movie'} official trailer`), '_blank', 'noopener');
|
||||||
|
if (!list.length) return yt();
|
||||||
|
const go = x => { store.set('video_back', location.hash); location.hash = '#/video/watch/' + x.ratingKey; };
|
||||||
|
if (list.length === 1) return go(list[0]);
|
||||||
|
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
|
||||||
|
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
|
||||||
|
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
|
||||||
|
$('#vwl').onclick = async () => { try { on ? await wlRemove(id) : await wlAdd(id); toast(on ? 'Removed from Watch later' : 'Added to Watch later'); route(); } catch (e) { toast(e.message); } };
|
||||||
|
}).catch(() => {});
|
||||||
|
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||||||
|
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||||||
|
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||||
|
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||||
|
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||||
|
if ($('#vseen')) $('#vseen').onclick = async () => {
|
||||||
|
const seen = m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount);
|
||||||
|
try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); }
|
||||||
|
};
|
||||||
|
if (m.type === 'show' && !next) px.get(`/library/metadata/${id}/allLeaves?X-Plex-Container-Start=0&X-Plex-Container-Size=1`).then(mc => { // nothing started yet: offer the first episode
|
||||||
|
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
|
||||||
|
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
|
||||||
|
}).catch(() => {});
|
||||||
|
if (m.type === 'show' || m.type === 'season' || m.type === 'collection') {
|
||||||
|
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
|
||||||
|
if (!$('#vkids')) return;
|
||||||
|
$('#vkids').innerHTML = m.type === 'show' || m.type === 'collection' ? `<h2>${m.type === 'show' ? 'Seasons' : 'In this collection'}</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
|
||||||
|
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- The player
|
||||||
|
let vp = null; // what is playing now
|
||||||
|
const canDirect = media => {
|
||||||
|
const c = (media.container || '').toLowerCase(), vc = (media.videoCodec || '').toLowerCase(), ac = (media.audioCodec || '').toLowerCase();
|
||||||
|
return ['mp4', 'm4v', 'mov'].includes(c) && vc === 'h264' && ['aac', 'mp3'].includes(ac);
|
||||||
|
};
|
||||||
|
const QUALITIES = [['auto', 'Auto', { kbps: 20000 }], ['original', 'Original (no conversion)', null], ['1080', '1080p · 10 Mbps', { kbps: 10000, res: '1920x1080' }], ['720', '720p · 4 Mbps', { kbps: 4000, res: '1280x720' }], ['480', '480p · 1.5 Mbps', { kbps: 1500, res: '854x480' }]];
|
||||||
|
let hlsLib = null;
|
||||||
|
const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.Hls) return ok(window.Hls); const s = document.createElement('script'); s.src = '/vendor/hls.min.js?v=1.7.3'; s.onload = () => ok(window.Hls); s.onerror = () => no(new Error('Could not load the video player library')); document.head.appendChild(s); }));
|
||||||
|
|
||||||
|
let vdHooked = false;
|
||||||
|
async function videoWatch(id) {
|
||||||
|
if (vp && vp.id === id && vp.el) { videoMount(); return; } // already playing this one (in the mini player): bring it back
|
||||||
|
const v = $('#view'), mc = await px.get(`/library/metadata/${id}?includeMarkers=1`), m = (mc.Metadata || [])[0];
|
||||||
|
if (!m || !(m.Media || []).length) { v.innerHTML = '<div class="wrap"><p class="empty">That item has no playable file.</p><div class="bar"><a class="btn ghost" href="#/video">Back</a></div></div>'; return; }
|
||||||
|
videoStop();
|
||||||
|
try { pl.a.pause(); } catch {}
|
||||||
|
if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp) videoStop(); }); } // one thing at a time: music starting ends the video
|
||||||
|
const media = m.Media[0], part = media.Part[0], streams = part.Stream || [], fromStart = store.get('video_from_start') === id;
|
||||||
|
if (fromStart) store.del('video_from_start');
|
||||||
|
const el = document.createElement('video'); el.playsInline = true; el.autoplay = true; el.id = 'vv';
|
||||||
|
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1)));
|
||||||
|
vp = { id, m, media, part, streams, el, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false, sibs: null, speed: 1 };
|
||||||
|
videoBindMedia(el);
|
||||||
|
vp.marks = (m.Marker || []).filter(x => x.type === 'intro' || x.type === 'credits').map(x => ({ type: x.type, a: x.startTimeOffset / 1000, b: x.endTimeOffset / 1000 }));
|
||||||
|
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
|
||||||
|
if (m.type === 'episode') px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`).then(c => { if (vp && vp.id === id) { vp.sibs = c.Metadata || []; vp.sibIdx = vp.sibs.findIndex(x => x.ratingKey === id); vdUi(); } }).catch(() => {});
|
||||||
|
videoBar(); videoMount(); videoStart(vp.pos); videoMediaSession();
|
||||||
|
}
|
||||||
|
const vdTitle = m => m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
|
||||||
|
/** Draw the player page and put the (persistent) video element into it. */
|
||||||
|
function videoMount() {
|
||||||
|
if (!vp) return; videoMini(false);
|
||||||
|
const v = $('#view'), m = vp.m, id = vp.id, ep = m.type === 'episode', title = vdTitle(m), el = vp.el;
|
||||||
|
document.body.classList.add('onvideo'); // (for a trailer or other extra, Back returns to the page you came from)
|
||||||
|
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
||||||
|
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
||||||
|
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
||||||
|
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
|
||||||
|
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
||||||
|
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
||||||
|
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
|
||||||
|
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||||
|
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
||||||
|
$('#vvol').value = Math.round(el.volume * 100);
|
||||||
|
const total = () => (vp.m.duration || 0) / 1000;
|
||||||
|
let drag = false, hideT = null;
|
||||||
|
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
|
||||||
|
vp.wake = wake; vp.drag = () => drag;
|
||||||
|
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
||||||
|
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
||||||
|
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
||||||
|
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
||||||
|
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
|
||||||
|
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
||||||
|
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
||||||
|
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
||||||
|
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
|
||||||
|
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
|
||||||
|
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
|
||||||
|
const range = $('#vrange');
|
||||||
|
range.oninput = () => { drag = true; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
|
||||||
|
let lastTap = 0, lastDbl = 0;
|
||||||
|
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||||
|
stage.addEventListener('pointerup', e => {
|
||||||
|
if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
|
||||||
|
if (e.pointerType === 'touch') {
|
||||||
|
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
||||||
|
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
|
||||||
|
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
|
||||||
|
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
|
||||||
|
});
|
||||||
|
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
|
||||||
|
if (m.type === 'clip' || m.extraType) { const bk = $('#vback'); if (bk) bk.setAttribute('href', store.get('video_back') || '#/video'); } // a trailer: Back returns to its movie
|
||||||
|
vdUi(); wake();
|
||||||
|
}
|
||||||
|
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
|
||||||
|
function videoBindMedia(el) {
|
||||||
|
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
|
||||||
|
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once
|
||||||
|
const first = el.buffered.length ? el.buffered.start(0) : el.currentTime;
|
||||||
|
vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; }
|
||||||
|
if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); vdMarks(); });
|
||||||
|
el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); });
|
||||||
|
el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); });
|
||||||
|
el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
|
||||||
|
el.addEventListener('playing', () => { msg(''); const s = $('#vspin'); if (s) s.hidden = true; vdReport('playing'); });
|
||||||
|
el.addEventListener('waiting', () => { const s = $('#vspin'); if (s) s.hidden = false; vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
|
||||||
|
el.addEventListener('canplay', () => { const s = $('#vspin'); if (s) s.hidden = true; if (vp) vp.waitSince = 0; });
|
||||||
|
el.addEventListener('ended', () => videoEnded());
|
||||||
|
el.addEventListener('error', () => { if (vp && vp.el === el && vp.mode === 'direct') { msg('The browser cannot play this file directly. Converting it…'); vp.q = 'auto'; vp.forceHls = true; videoStart(vp.pos); } else msg('Could not play this. Try a lower quality in Quality & tracks.'); });
|
||||||
|
}
|
||||||
|
/** Refresh whatever controls are on screen (the player page's, and the mini player's) from the video's state. */
|
||||||
|
function vdUi() {
|
||||||
|
if (!vp || !vp.el) return; const el = vp.el, t = (vp.m.duration || 0) / 1000 || el.duration || 0, p = vp.base + el.currentTime, drag = vp.drag && vp.drag();
|
||||||
|
const r = $('#vrange');
|
||||||
|
if (r) { if (!drag) r.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = drag ? r.value / 1000 * t : p; $('#vcur').textContent = vdClock(shown); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
|
||||||
|
const pp = $('#vpp'); if (pp) { pp.innerHTML = icon(el.paused ? 'play' : 'pause'); pp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||||
|
const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = !vp.sibs || vp.sibIdx <= 0; if (nx) nx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||||
|
const bp = $('#vbpp'); if (bp) { bp.innerHTML = icon(el.paused ? 'play' : 'pause'); bp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||||
|
const bi = vp.barDrag ? null : $('#vbp'); if (bi) bi.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
|
||||||
|
const bt = vp.barDrag ? null : $('#vbtm'); if (bt) bt.textContent = vdClock(p) + ' / ' + vdClock(t);
|
||||||
|
const bpv = $('#vbprev'), bnx = $('#vbnext'); if (bpv) bpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (bnx) bnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||||
|
}
|
||||||
|
/** "Skip intro" / "Skip credits" while the position is inside one of Plex's markers for this title. */
|
||||||
|
function vdMarks() {
|
||||||
|
if (!vp || !vp.marks || !vp.marks.length) return;
|
||||||
|
const p = vp.base + vp.el.currentTime, mk = vp.marks.find(x => p >= x.a - 1 && p < x.b - 1), st = $('#vstage'); let b = $('#vmark');
|
||||||
|
if (!mk || !st) { if (b) b.remove(); return; }
|
||||||
|
if (!b) { b = document.createElement('button'); b.id = 'vmark'; b.className = 'btn vmark'; st.appendChild(b); }
|
||||||
|
b.textContent = mk.type === 'intro' ? 'Skip intro' : 'Skip credits';
|
||||||
|
b.onclick = e => { e.stopPropagation(); if (mk.type === 'credits' && vp.m.duration && mk.b >= vp.m.duration / 1000 - 3) { videoSeek(Math.max(0, vp.m.duration / 1000 - 0.5)); } else videoSeek(mk.b); b.remove(); };
|
||||||
|
}
|
||||||
|
/** The mini player: the video keeps playing in a small window while you use other tabs. */
|
||||||
|
function videoMini(on) {
|
||||||
|
let box = document.getElementById('vmini');
|
||||||
|
if (!on) { if (box) box.hidden = true; return; }
|
||||||
|
if (!vp || !vp.el) return;
|
||||||
|
if (!box) {
|
||||||
|
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video (click to open the player)');
|
||||||
|
box.innerHTML = '<div class="vmslot" title="Open the player"></div>';
|
||||||
|
document.body.appendChild(box); box.querySelector('.vmslot').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||||
|
}
|
||||||
|
box.hidden = false; vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
|
||||||
|
const st = document.querySelector('.vstage.vfull'); if (st) st.classList.remove('vfull');
|
||||||
|
vdUi();
|
||||||
|
}
|
||||||
|
/** The bottom bar (the same one Music uses) shows the video's controls on every tab: poster, title, time, previous and next episode, back and forward 10 s, play/pause, expand, close. */
|
||||||
|
const videoBarOn = () => !!(vp && vp.el);
|
||||||
|
function videoBar() {
|
||||||
|
const el = $('#player'); if (!el || !vp) return; const m = vp.m, ep = m.type === 'episode';
|
||||||
|
el.hidden = false;
|
||||||
|
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
||||||
|
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
||||||
|
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
||||||
|
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
||||||
|
const q = s => el.querySelector(s);
|
||||||
|
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
||||||
|
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
||||||
|
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
||||||
|
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||||
|
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
||||||
|
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
|
||||||
|
const sk = q('#vbseek'), frac = e => { const r = sk.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); }, total = () => (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
|
||||||
|
const preview = f => { const t = total(); q('#vbp').style.width = (f * 100) + '%'; q('#vbtm').textContent = vdClock(f * t) + ' / ' + vdClock(t); };
|
||||||
|
sk.onpointerdown = e => {
|
||||||
|
if (!vp) return; sk.setPointerCapture(e.pointerId); vp.barDrag = true; let f = frac(e); preview(f);
|
||||||
|
sk.onpointermove = ev => { f = frac(ev); preview(f); };
|
||||||
|
sk.onpointerup = ev => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; f = frac(ev); vp.barDrag = false; videoSeek(f * total()); vdUi(); };
|
||||||
|
sk.onpointercancel = () => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; vp.barDrag = false; vdUi(); };
|
||||||
|
};
|
||||||
|
vdUi();
|
||||||
|
}
|
||||||
|
/** Lock-screen and media-key controls. */
|
||||||
|
function videoMediaSession() {
|
||||||
|
if (!('mediaSession' in navigator) || !vp) return;
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({ title: vp.m.title, artist: vp.m.type === 'episode' ? vp.m.grandparentTitle : 'Movie', artwork: vp.m.thumb ? [{ src: vdArt(vp.m.thumb, 300, 450), sizes: '300x450' }] : [] });
|
||||||
|
const set = (a, f) => { try { navigator.mediaSession.setActionHandler(a, f); } catch {} };
|
||||||
|
set('play', () => vp && vp.el.play()); set('pause', () => vp && vp.el.pause()); set('seekbackward', () => videoSkip(-10)); set('seekforward', () => videoSkip(10));
|
||||||
|
set('previoustrack', vp.m.type === 'episode' ? () => videoGo(-1) : null); set('nexttrack', vp.m.type === 'episode' ? () => videoGo(1) : null);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
/** Jump to a point in the whole film or episode. A converted stream only covers what Plex has made from its start point, so a seek outside it restarts the conversion there. */
|
||||||
|
function videoSeek(abs) {
|
||||||
|
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
|
||||||
|
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
|
||||||
|
if (vp.mode === 'direct') { el.currentTime = abs; return; }
|
||||||
|
const rel = abs - vp.base, sk = el.seekable;
|
||||||
|
// Only inside what the converted stream really has (its range may begin late when it started part-way); anything else restarts the conversion there.
|
||||||
|
const inside = sk && sk.length ? rel >= sk.start(0) - 0.5 && rel <= sk.end(sk.length - 1) - 2 : rel >= 0 && isFinite(el.duration) && rel < el.duration - 2;
|
||||||
|
if (inside) el.currentTime = rel; else videoStart(abs);
|
||||||
|
}
|
||||||
|
function videoSkip(d) {
|
||||||
|
if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
|
||||||
|
const bp = $('#vbp'), bt = $('#vbtm'); if (t) { if (bp) bp.style.width = Math.min(100, 100 * to / t) + '%'; if (bt) bt.textContent = vdClock(to) + ' / ' + vdClock(t); } // (the bar moves before the video has finished seeking)
|
||||||
|
videoSeek(to);
|
||||||
|
if (vp.flash) vp.flash((d < 0 ? '−' : '+') + Math.abs(d) + ' s');
|
||||||
|
}
|
||||||
|
/** Previous or next episode (or, for the previous one, back to the start if you are more than 5 seconds in). */
|
||||||
|
function videoGo(dir) {
|
||||||
|
if (!vp || !vp.sibs) return;
|
||||||
|
if (dir < 0 && vp.base + vp.el.currentTime > 5) { videoSeek(0); if (vp.flash) vp.flash('Restart'); return; }
|
||||||
|
const t = vp.sibs[vp.sibIdx + dir]; if (t) location.hash = `#/video/watch/${t.ratingKey}`;
|
||||||
|
}
|
||||||
|
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
||||||
|
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||||
|
function videoFullscreen() {
|
||||||
|
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
|
||||||
|
const native = document.fullscreenElement || document.webkitFullscreenElement;
|
||||||
|
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
|
||||||
|
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
|
||||||
|
const req = box.requestFullscreen || box.webkitRequestFullscreen;
|
||||||
|
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
|
||||||
|
if (!req) return fallback();
|
||||||
|
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
|
||||||
|
const p = req.call(box); if (p && p.catch) p.catch(fallback);
|
||||||
|
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
|
||||||
|
} catch { fallback(); }
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
|
||||||
|
/** Start (or restart) playing from `sec` with the current quality and tracks. */
|
||||||
|
async function videoStart(sec) {
|
||||||
|
if (!vp) return; const el = vp.el, { m, media, part } = vp; sec = Math.max(0, sec || 0); if (m.duration && sec * 1000 > m.duration - 15000) sec = 0;
|
||||||
|
if (vp.hls) { try { vp.hls.destroy(); } catch {} vp.hls = null; }
|
||||||
|
fetch('/plex/video/:/transcode/universal/stop?session=' + vp.session).catch(() => {});
|
||||||
|
vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
|
||||||
|
const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub));
|
||||||
|
if (direct) {
|
||||||
|
vp.mode = 'direct'; vp.base = 0; vp.learn = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
|
||||||
|
const t = $('#vmsg'); if (t) t.textContent = '';
|
||||||
|
} else {
|
||||||
|
vp.mode = 'hls'; vp.base = sec; vp.learn = sec || 0.001;
|
||||||
|
const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' });
|
||||||
|
if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); }
|
||||||
|
const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
|
||||||
|
if (el.canPlayType('application/vnd.apple.mpegurl')) { el.src = url; }
|
||||||
|
else { try { const Hls = await loadHls(); if (!vp || vp.el !== el) return; if (!Hls.isSupported()) throw new Error('This browser cannot play converted video'); const h = vp.hls = new Hls({ maxBufferLength: 40, enableWorker: true }); h.loadSource(url); h.attachMedia(el);
|
||||||
|
h.on(Hls.Events.ERROR, (e, d) => { if (d.fatal) { if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { const tt = $('#vmsg'); if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) h.recoverMediaError(); else { const tt = $('#vmsg'); if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; } } }); }
|
||||||
|
catch (e) { const tt = $('#vmsg'); if (tt) tt.textContent = e.message; return; } }
|
||||||
|
}
|
||||||
|
el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
const vdReport = state => { if (!vp) return; const pos = (vp.base + (vp.el ? vp.el.currentTime : 0)) * 1000; tlSend(state, vp.id, pos, vp.m.duration || 0); };
|
||||||
|
function videoSettings() {
|
||||||
|
if (!vp) return;
|
||||||
|
const aud = vp.streams.filter(s => s.streamType === 2), sub = vp.streams.filter(s => s.streamType === 3);
|
||||||
|
const here = () => vp.base + (vp.el ? vp.el.currentTime : 0);
|
||||||
|
const el = openSheet('Quality & tracks', vp.mode === 'direct' ? 'Playing the original file' : 'Plex is converting this for your browser', `
|
||||||
|
<label for="vq2">Quality</label><select id="vq2">${QUALITIES.map(([k, l]) => `<option value="${k}" ${k === vp.q ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||||||
|
${aud.length > 1 ? `<label for="va2">Audio</label><select id="va2"><option value="0">Default</option>${aud.map(s => `<option value="${s.id}" ${String(s.id) === String(vp.audio) ? 'selected' : ''}>${esc(s.displayTitle || s.language || 'Track ' + s.id)}</option>`).join('')}</select>` : ''}
|
||||||
|
${sub.length ? `<label for="vs2">Subtitles</label><select id="vs2"><option value="0">Off</option>${sub.map(s => `<option value="${s.id}" ${String(s.id) === String(vp.sub) ? 'selected' : ''}>${esc(s.displayTitle || s.language || 'Track ' + s.id)}</option>`).join('')}</select><p class="sub" style="margin:4px 0 0">Subtitles are drawn into the picture by Plex, so choosing one converts the video.</p>` : ''}
|
||||||
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vapply" style="flex:1;justify-content:center">Apply</button></div>`);
|
||||||
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
el.querySelector('#vapply').onclick = () => {
|
||||||
|
vp.q = el.querySelector('#vq2').value; store.set('video_q', vp.q); vp.audio = el.querySelector('#va2') ? +el.querySelector('#va2').value : 0; vp.sub = el.querySelector('#vs2') ? +el.querySelector('#vs2').value : 0; vp.forceHls = false;
|
||||||
|
const at = here(); closeSheet(); videoStart(at);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** The end: mark watched and, for TV, offer the next episode. */
|
||||||
|
async function videoEnded() {
|
||||||
|
if (!vp || vp.done) return; vp.done = true; const { id, m } = vp;
|
||||||
|
px.get(`/:/scrobble?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); tlSend('stopped', id, m.duration || 0, m.duration || 0);
|
||||||
|
if (m.type !== 'episode') return;
|
||||||
|
try {
|
||||||
|
const leaves = vp.sibs || (await px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`)).Metadata || [], i = leaves.findIndex(x => x.ratingKey === id), nx = leaves[i + 1];
|
||||||
|
if (!nx || !vp || vp.id !== id) return;
|
||||||
|
const box = $('#vnextbox'); if (!box) return; let n = 10; vp.wake && vp.wake();
|
||||||
|
box.innerHTML = `<div class="vnext"><b>Up next: S${nx.parentIndex}E${nx.index} · ${esc(nx.title)}</b><span id="vcount">in ${n}s</span><button class="btn" id="vgo">Play now</button><button class="btn ghost" id="vcx">Stop</button></div>`;
|
||||||
|
const go = () => { clearInterval(tm); location.hash = `#/video/watch/${nx.ratingKey}`; };
|
||||||
|
const tm = setInterval(() => { n--; const c = $('#vcount'); if (!c) return clearInterval(tm); c.textContent = 'in ' + n + 's'; if (n <= 0) go(); }, 1000);
|
||||||
|
$('#vgo').onclick = go; $('#vcx').onclick = () => { clearInterval(tm); box.innerHTML = ''; };
|
||||||
|
} catch { /* no next episode */ }
|
||||||
|
}
|
||||||
|
/** Stop playing: tell Plex where we got to and stop its conversion. Also runs whenever you leave the player. */
|
||||||
|
function videoStop() {
|
||||||
|
if (!vp) { document.body.classList.remove('vfullwin'); return; } const s = vp; vp = null; document.body.classList.remove('onvideo', 'vfullwin'); try { if (document.fullscreenElement) document.exitFullscreen(); } catch {} clearInterval(s.timer);
|
||||||
|
try { if (!s.done) tlSend('stopped', s.id, (s.base + (s.el ? s.el.currentTime : s.pos)) * 1000, s.m.duration || 0); } catch {}
|
||||||
|
try { if (s.hls) s.hls.destroy(); if (s.el) { s.el.pause(); s.el.removeAttribute('src'); s.el.load(); } } catch {}
|
||||||
|
fetch('/plex/video/:/transcode/universal/stop?session=' + s.session).catch(() => {});
|
||||||
|
const mb = document.getElementById('vmini'); if (mb) { mb.hidden = true; mb.querySelector('.vmslot').innerHTML = ''; }
|
||||||
|
try { renderMini(); } catch {} // the bar goes back to Music (or hides)
|
||||||
|
}
|
||||||
|
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoMini(true); });
|
||||||
|
addEventListener('pagehide', () => { if (vp) { try { navigator.sendBeacon && navigator.sendBeacon('/plex/video/:/transcode/universal/stop?session=' + vp.session); } catch {} vdReport('stopped'); } });
|
||||||
|
addEventListener('keydown', e => {
|
||||||
|
if (!vp || !vp.el || !location.hash.startsWith('#/video/watch/') || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') && e.target.type !== 'range' || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
|
||||||
|
const el = vp.el, k = e.key; if (vp.wake) vp.wake();
|
||||||
|
if (e.target.type === 'range' && (k === 'ArrowLeft' || k === 'ArrowRight')) return; // (the sliders use their own arrows)
|
||||||
|
if (k === ' ' || k === 'k') { e.preventDefault(); if (e.target.tagName === 'BUTTON') addEventListener('keyup', ev => ev.preventDefault(), { once: true, capture: true }); el.paused ? el.play() : el.pause(); } // (a focused button would also click on key-up: that is the double toggle)
|
||||||
|
else if (k === 'ArrowRight' || k === 'l') { e.preventDefault(); videoSkip(k === 'l' || e.shiftKey ? 30 : 10); } else if (k === 'ArrowLeft' || k === 'j') { e.preventDefault(); videoSkip(k === 'j' || e.shiftKey ? -30 : -10); }
|
||||||
|
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
|
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
|
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
||||||
|
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
|
||||||
|
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
||||||
|
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
|
||||||
|
// ---- Edit a title's details the way Plex's Edit dialog does: only what changed is saved, and each saved field is locked so a Plex refresh keeps it.
|
||||||
|
const VD_TYPE = { movie: 1, show: 2, season: 3, episode: 4 };
|
||||||
|
async function videoEdit(m) {
|
||||||
|
const kind = m.type, rich = kind === 'movie' || kind === 'show', genres = (m.Genre || []).map(g => g.tag);
|
||||||
|
const F = [['title', 'Title'], ['titleSort', 'Sort title'], ['originalTitle', 'Original title'], ['originallyAvailableAt', kind === 'show' ? 'First aired' : 'Release date', 'date'], ['contentRating', 'Content rating'], ...(rich ? [['studio', 'Studio'], ['tagline', 'Tagline']] : []), ['summary', 'Summary', 'area']]
|
||||||
|
.filter(([k]) => kind !== 'season' || ['title', 'titleSort', 'summary'].includes(k));
|
||||||
|
const old = k => String(m[k] || ''), dateOf = v => String(v || '').slice(0, 10);
|
||||||
|
const el = openSheet('Edit ' + (m.title || ''), '', `<div class="sgrp" role="tablist" style="margin-bottom:8px"><a href="#" data-t="d" aria-selected="true" class="on">Details</a><a href="#" data-t="p" aria-selected="false">Poster</a><a href="#" data-t="a" aria-selected="false">Background</a></div>
|
||||||
|
<form id="vf"><div id="vtab-d">${F.map(([k, l, t]) => `<label for="ve-${k}">${l}</label>${t === 'area' ? `<textarea id="ve-${k}" rows="5">${esc(old(k))}</textarea>` : `<input type="${t || 'text'}" id="ve-${k}" value="${esc(t === 'date' ? dateOf(m[k]) : old(k))}" autocomplete="off">`}`).join('')}
|
||||||
|
${rich ? `<label for="ve-genre">Genres <small>(separate with commas)</small></label><input type="text" id="ve-genre" value="${esc(genres.join(', '))}" autocomplete="off">` : ''}
|
||||||
|
<p class="sub" style="margin:10px 0 0">Changed fields are locked in Plex, so scans and refreshes will not put the old values back.</p></div>
|
||||||
|
<div id="vtab-p" hidden></div><div id="vtab-a" hidden></div>
|
||||||
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vs" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ve"></div></form>`);
|
||||||
|
el.querySelector('.sheet').classList.add('wide');
|
||||||
|
el.querySelector('#sx').onclick = closeSheet;
|
||||||
|
const loaded = {};
|
||||||
|
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
|
||||||
|
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||||
|
try {
|
||||||
|
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
|
||||||
|
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
|
||||||
|
<label>Add from an image link</label><div class="bar" style="margin-top:4px"><input type="url" class="vurl" placeholder="https://…" autocomplete="off" style="flex:1"><button type="button" class="btn ghost vadd">Add</button></div><div class="err vie"></div>`;
|
||||||
|
const put = async (url, add) => {
|
||||||
|
const r = await fetch(`/plex/library/metadata/${m.ratingKey}/${add ? what : what === 'posters' ? 'poster' : 'art'}?url=${encodeURIComponent(url)}`, { method: add ? 'POST' : 'PUT' });
|
||||||
|
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change images' : 'Plex would not take that (' + r.status + ')');
|
||||||
|
};
|
||||||
|
box.querySelectorAll('.vpick').forEach(b => b.onclick = async () => { try { await put(list[+b.dataset.i].ratingKey || list[+b.dataset.i].key); box.querySelectorAll('.vpick').forEach(x => x.classList.toggle('on', x === b)); toast('Image changed'); changed = true; } catch (e) { box.querySelector('.vie').textContent = e.message; } });
|
||||||
|
box.querySelector('.vadd').onclick = async () => { const u = box.querySelector('.vurl').value.trim(); if (!/^https:\/\//.test(u)) { box.querySelector('.vie').textContent = 'Use a link that starts with https://'; return; } try { await put(u, true); toast('Image added'); changed = true; loaded[tab] = false; images(tab, what); } catch (e) { box.querySelector('.vie').textContent = e.message; } };
|
||||||
|
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||||
|
};
|
||||||
|
let changed = false;
|
||||||
|
el.querySelectorAll('.sgrp a[data-t]').forEach(a => a.onclick = ev => {
|
||||||
|
ev.preventDefault(); const t = a.dataset.t;
|
||||||
|
el.querySelectorAll('.sgrp a[data-t]').forEach(x => { x.classList.toggle('on', x === a); x.setAttribute('aria-selected', x === a); });
|
||||||
|
['d', 'p', 'a'].forEach(k => { el.querySelector('#vtab-' + k).hidden = k !== t; }); el.querySelector('#vs').hidden = t !== 'd';
|
||||||
|
if (t !== 'd' && !loaded[t]) { loaded[t] = true; images(t, t === 'p' ? 'posters' : 'arts'); }
|
||||||
|
});
|
||||||
|
el.querySelector('#vf').onsubmit = async ev => {
|
||||||
|
ev.preventDefault(); const btn = el.querySelector('#vs'); btn.disabled = true; el.querySelector('#ve').textContent = '';
|
||||||
|
const q = new URLSearchParams({ type: VD_TYPE[kind] || 1, id: m.ratingKey, includeExternalMedia: 1 }); let n = 0;
|
||||||
|
for (const [k, , t] of F) { const v = el.querySelector('#ve-' + k).value.trim(); if (v !== (t === 'date' ? dateOf(m[k]) : old(k))) { q.set(k + '.value', v); q.set(k + '.locked', 1); n++; } }
|
||||||
|
if (rich) { const g = [...new Set(el.querySelector('#ve-genre').value.split(',').map(x => x.trim()).filter(Boolean))]; if (g.join('|') !== genres.join('|')) { g.forEach((x, i) => q.set(`genre[${i}].tag.tag`, x)); q.set('genre.locked', 1); n++; } }
|
||||||
|
if (!n) { closeSheet(); if (changed) route(); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/plex/library/sections/${m.librarySectionID}/all?${q}`, { method: 'PUT' });
|
||||||
|
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change details' : 'Plex would not save that (' + r.status + ')');
|
||||||
|
closeSheet(); toast('Saved in Plex'); route();
|
||||||
|
} catch (e) { btn.disabled = false; el.querySelector('#ve').textContent = e.message; }
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ function keys() {
|
|||||||
return vapid;
|
return vapid;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = { tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
|
const DEFAULTS = { mail: true, mailSeen: null, tasks: true, tasksAt: '08:00', events: true, eventMins: 15, tz: 'UTC', subs: [], sent: [], tasksDay: '' };
|
||||||
const file = dir => path.join(dir, 'push.json');
|
const file = dir => path.join(dir, 'push.json');
|
||||||
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
|
const load = dir => { try { return { ...DEFAULTS, ...JSON.parse(fs.readFileSync(file(dir), 'utf8')) }; } catch { return { ...DEFAULTS, subs: [], sent: [] }; } };
|
||||||
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
|
const save = (dir, o) => atomicWrite(file(dir), JSON.stringify(o));
|
||||||
@@ -75,6 +75,19 @@ async function tick(now = new Date(), force = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (checkEvents && st.mail && users.can(u, 'mail')) { // new unread mail (the first look only notes what is already there)
|
||||||
|
try {
|
||||||
|
const now2 = (await mail.newest()).filter(m => !m.read), seen = st.mailSeen;
|
||||||
|
if (!Array.isArray(seen)) { st.mailSeen = now2.map(m => m.id); dirty = true; }
|
||||||
|
else {
|
||||||
|
const fresh = now2.filter(m => !seen.includes(m.id));
|
||||||
|
if (fresh.length) {
|
||||||
|
st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true;
|
||||||
|
await send(dir, st, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.warn('[push] mail check failed:', e.message); }
|
||||||
|
}
|
||||||
if (checkEvents && st.events && users.can(u, 'calendar')) {
|
if (checkEvents && st.events && users.can(u, 'calendar')) {
|
||||||
try {
|
try {
|
||||||
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
const from = now.getTime(), to = from + (st.eventMins + 5) * 60e3;
|
||||||
@@ -100,7 +113,7 @@ function start() { if (!timer) { timer = setInterval(() => tick().catch(e => con
|
|||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
|
const p = new URL(req.url, 'http://x').pathname, dir = users.dir(), st = load(dir);
|
||||||
const pub = () => ({ tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
|
const pub = () => ({ mail: st.mail, tasks: st.tasks, tasksAt: st.tasksAt, events: st.events, eventMins: st.eventMins, devices: st.subs.length });
|
||||||
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
|
if (p === '/api/push/key' && req.method === 'GET') return json(res, 200, { publicKey: keys().publicKey, ...pub() });
|
||||||
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' });
|
||||||
const b = JSON.parse((await readBody(req)) || '{}');
|
const b = JSON.parse((await readBody(req)) || '{}');
|
||||||
@@ -115,6 +128,7 @@ async function handle(req, res) {
|
|||||||
if (p === '/api/push/prefs') {
|
if (p === '/api/push/prefs') {
|
||||||
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
if (typeof b.tasks === 'boolean') st.tasks = b.tasks;
|
||||||
if (typeof b.events === 'boolean') st.events = b.events;
|
if (typeof b.events === 'boolean') st.events = b.events;
|
||||||
|
if (typeof b.mail === 'boolean') st.mail = b.mail;
|
||||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
|
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(b.tasksAt || '')) st.tasksAt = b.tasksAt;
|
||||||
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
|
if ([5, 10, 15, 30, 60].includes(b.eventMins)) st.eventMins = b.eventMins;
|
||||||
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
if (b.tz && validTz(b.tz)) st.tz = b.tz;
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Internet radio. The station list comes from the community Radio Browser directory (free, no key), with The Journey FM (Liberty University, Lynchburg VA)
|
||||||
|
// always on top. The hub fetches each stream itself and passes it on: many stations only offer plain http, which a browser refuses to play inside an https page,
|
||||||
|
// and the hub also reads the "now playing" text out of the stream so the player can show the song. Each person keeps their own list of saved stations.
|
||||||
|
const fs = require('fs'), path = require('path'), http = require('http'), https = require('https'), dns = require('dns'), net = require('net');
|
||||||
|
const users = require('./users');
|
||||||
|
|
||||||
|
const FEATURED = [{
|
||||||
|
id: 'journeyfm', name: 'The Journey FM', url: process.env.JOURNEYFM_STREAM_URL || 'http://ic.liberty.edu:8000/WRVL', homepage: 'https://www.myjourneyfm.com/', favicon: '', tags: 'christian,contemporary christian,worship',
|
||||||
|
country: 'United States', state: 'Virginia', codec: 'MP3', bitrate: 192, featured: true,
|
||||||
|
}];
|
||||||
|
const JOURNEY_NOW = () => process.env.JOURNEYFM_NOW_URL || 'https://www.myjourneyfm.com/wp-json/custom/v2/recentlyPlayed';
|
||||||
|
const DIRECTORY = () => process.env.RADIO_BROWSER_URL ? [process.env.RADIO_BROWSER_URL.replace(/\/+$/, '')] : ['https://de1.api.radio-browser.info', 'https://at1.api.radio-browser.info', 'https://nl1.api.radio-browser.info'];
|
||||||
|
const UA = 'NE-Hub/1.0 (self-hosted)';
|
||||||
|
const PLAYABLE = /^(MP3|AAC|AAC\+|OGG|VORBIS|OPUS)$/i;
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// ---- the hub never connects to a private or local address on behalf of a station (the address is checked after it is looked up, so a name that points inside cannot slip through)
|
||||||
|
const isPrivate = a => {
|
||||||
|
if (process.env.RADIO_ALLOW_PRIVATE === '1') return false;
|
||||||
|
if (net.isIPv4(a)) { const [x, y] = a.split('.').map(Number); return x === 0 || x === 10 || x === 127 || (x === 169 && y === 254) || (x === 172 && y >= 16 && y <= 31) || (x === 192 && y === 168) || (x === 100 && y >= 64 && y <= 127) || x >= 224; }
|
||||||
|
if (net.isIPv6(a)) { const l = a.toLowerCase(); return l === '::1' || l === '::' || l.startsWith('fe80') || l.startsWith('fc') || l.startsWith('fd') || (l.startsWith('::ffff:') && isPrivate(l.slice(7))); }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const safeLookup = (host, opts, cb) => { if (typeof opts === 'function') { cb = opts; opts = {}; }
|
||||||
|
dns.lookup(host, { ...opts, all: true }, (err, list) => { if (err) return cb(err); const ok = list.filter(x => !isPrivate(x.address)); if (!ok.length) return cb(new Error('That address is not allowed'));
|
||||||
|
if (opts.all) return cb(null, ok); cb(null, ok[0].address, ok[0].family); }); };
|
||||||
|
function open(url, { headers = {}, timeout = 12000, hops = 4 } = {}) { // GET with redirects; resolves with the response (not yet read)
|
||||||
|
return new Promise((ok, no) => {
|
||||||
|
let u; try { u = new URL(url); } catch { return no(fail(400, 'Bad address')); }
|
||||||
|
if (!/^https?:$/.test(u.protocol)) return no(fail(400, 'Bad address'));
|
||||||
|
const lit = u.hostname.replace(/^\[|\]$/g, ''); if (net.isIP(lit) && isPrivate(lit)) return no(fail(502, 'That address is not allowed')); // (an address written as numbers never goes through the lookup below)
|
||||||
|
const req = (u.protocol === 'https:' ? https : http).get(u, { headers: { 'user-agent': UA, ...headers }, lookup: safeLookup, timeout }, res => {
|
||||||
|
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) { res.resume(); if (!hops) return no(fail(502, 'Too many redirects')); return open(new URL(res.headers.location, u).href, { headers, timeout, hops: hops - 1 }).then(ok, no); }
|
||||||
|
ok(res);
|
||||||
|
});
|
||||||
|
req.on('timeout', () => req.destroy(fail(504, 'The station did not answer'))); req.on('error', e => no(e.code && e.code >= 400 ? e : fail(502, 'Could not reach that address')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function readText(url, max = 2e6, opts) { const res = await open(url, opts); if (res.statusCode >= 400) { res.resume(); throw fail(502, 'Answered ' + res.statusCode); } const c = []; let n = 0; for await (const d of res) { n += d.length; if (n > max) { res.destroy(); throw fail(502, 'Too large'); } c.push(d); } return Buffer.concat(c).toString('utf8'); }
|
||||||
|
|
||||||
|
// ---- Radio Browser
|
||||||
|
async function directory(p) { let last; for (const base of DIRECTORY()) { try { return JSON.parse(await readText(base + p, 2e6, { timeout: 8000 })); } catch (e) { last = e; } } throw fail(502, 'The radio directory is not answering' + (last && last.message ? ' (' + last.message + ')' : '')); }
|
||||||
|
const tidy = s => ({
|
||||||
|
id: s.stationuuid, name: String(s.name || '').replace(/\s+/g, ' ').trim().slice(0, 80), url: s.url_resolved || s.url, homepage: /^https?:/.test(s.homepage || '') ? s.homepage : '', favicon: /^https?:/.test(s.favicon || '') ? s.favicon : '',
|
||||||
|
tags: String(s.tags || '').split(',').map(x => x.trim()).filter(Boolean).slice(0, 4).join(','), country: s.country || '', state: s.state || '', codec: s.codec || '', bitrate: +s.bitrate || 0, votes: +s.votes || 0,
|
||||||
|
});
|
||||||
|
const usable = s => s && s.stationuuid && /^https?:\/\//.test(s.url_resolved || s.url || '') && !s.hls && PLAYABLE.test(s.codec || '') && String(s.name || '').trim();
|
||||||
|
const known = new Map(); // station id -> { t, st }
|
||||||
|
async function station(id) {
|
||||||
|
const f = FEATURED.find(x => x.id === id) || mine().find(x => x.id === id); if (f) return f;
|
||||||
|
if (!/^[\w-]{8,64}$/.test(id)) throw fail(400, 'Bad station');
|
||||||
|
const c = known.get(id); if (c && Date.now() - c.t < 3600e3) return c.st;
|
||||||
|
const [s] = await directory('/json/stations/byuuid/' + id); if (!usable(s)) throw fail(404, 'Station not found');
|
||||||
|
const st = tidy(s); if (known.size > 500) known.clear(); known.set(id, { t: Date.now(), st }); return st;
|
||||||
|
}
|
||||||
|
const favFile = () => path.join(users.dir(), 'radio.json');
|
||||||
|
const mine = () => { try { const l = JSON.parse(fs.readFileSync(favFile(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||||
|
const saveMine = l => { const f = favFile(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 1e5) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||||
|
|
||||||
|
// ---- now playing: the Journey's own feed, or the song title carried inside the stream
|
||||||
|
const nowMap = new Map(); // person:station -> { title, t }
|
||||||
|
const clean = s => String(s || '').replace(/\s+/g, ' ').trim().slice(0, 160);
|
||||||
|
const titleCase = s => s && s === s.toUpperCase() ? s.toLowerCase().replace(/(^|[\s(&\/-])([a-z])/g, (m, a, b) => a + b.toUpperCase()) : s;
|
||||||
|
async function journeyNow(recent) {
|
||||||
|
const rows = JSON.parse(await readText(JOURNEY_NOW() + (recent ? '/getRecentlyPlayed' : '/getNowPlaying'), 1e6, { timeout: 8000 }));
|
||||||
|
return (Array.isArray(rows) ? rows : []).filter(r => r && r.TITLE && r.ARTIST).map(r => ({ title: titleCase(clean(r.TITLE)), artist: titleCase(clean(r.ARTIST)), at: r.ACTIVITY_DATE || '' })); // rows with no artist are the station's own sound bites
|
||||||
|
}
|
||||||
|
/** Pull the song title out of a stream: every `metaint` bytes of audio are followed by a small text block (StreamTitle='...';), which must not reach the player. */
|
||||||
|
function icyFilter(metaint, onTitle) {
|
||||||
|
let toMeta = metaint, need = -1, buf = Buffer.alloc(0);
|
||||||
|
return chunk => {
|
||||||
|
const out = []; let i = 0;
|
||||||
|
while (i < chunk.length) {
|
||||||
|
if (need >= 0) { const take = Math.min(need - buf.length, chunk.length - i); buf = Buffer.concat([buf, chunk.subarray(i, i + take)]); i += take; if (buf.length === need) { const m = /StreamTitle='([^]*?)';/.exec(buf.toString('utf8')); if (m) onTitle(clean(m[1])); need = -1; buf = Buffer.alloc(0); toMeta = metaint; } }
|
||||||
|
else if (toMeta === 0) { need = chunk[i] * 16; i++; if (need === 0) { need = -1; toMeta = metaint; } }
|
||||||
|
else { const take = Math.min(toMeta, chunk.length - i); out.push(chunk.subarray(i, i + take)); i += take; toMeta -= take; }
|
||||||
|
}
|
||||||
|
return Buffer.concat(out);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/radio'.length) || '/', me = users.current();
|
||||||
|
try {
|
||||||
|
let m;
|
||||||
|
if (p === '/search' && req.method === 'GET') {
|
||||||
|
const q = clean(u.searchParams.get('q')).slice(0, 60), tag = clean(u.searchParams.get('tag')).slice(0, 30), off = Math.min(500, Math.max(0, +u.searchParams.get('offset') || 0));
|
||||||
|
const qs = new URLSearchParams({ hidebroken: 'true', order: 'clickcount', reverse: 'true', limit: '30', offset: String(off), is_https: u.searchParams.get('https') === '1' ? 'true' : '' }); if (!qs.get('is_https')) qs.delete('is_https');
|
||||||
|
if (q) qs.set('name', q); if (tag) qs.set('tag', tag); if (!q && !tag) qs.set('tag', 'christian');
|
||||||
|
const rows = await directory('/json/stations/search?' + qs), items = rows.filter(usable).map(tidy);
|
||||||
|
for (const st of items) known.set(st.id, { t: Date.now(), st }); if (known.size > 500) known.clear(); // saving or playing one of these needs no second look-up
|
||||||
|
return json(res, 200, { items, more: rows.length >= 30 });
|
||||||
|
}
|
||||||
|
if (p === '/stations' && req.method === 'GET') return json(res, 200, { featured: FEATURED, mine: mine() });
|
||||||
|
if (p === '/stations' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req), s = await station(String(b.id || '')); if (s.featured) return json(res, 200, { saved: true });
|
||||||
|
const l = mine().filter(x => x.id !== s.id); l.push(s); saveMine(l.slice(-200)); return json(res, 200, { saved: true });
|
||||||
|
}
|
||||||
|
if ((m = /^\/stations\/([\w-]+)$/.exec(p)) && req.method === 'DELETE') { saveMine(mine().filter(x => x.id !== m[1])); return json(res, 200, { removed: true }); }
|
||||||
|
if ((m = /^\/now\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
if (m[1] === 'journeyfm') { let now = null, recent = []; try { recent = await journeyNow(true); now = recent[0] || null; } catch {} return json(res, 200, { title: now ? now.title : '', artist: now ? now.artist : '', recent: recent.slice(0, 12) }); }
|
||||||
|
const n = nowMap.get(me.id + ':' + m[1]); return json(res, 200, { title: n && Date.now() - n.t < 600e3 ? n.title : '', artist: '', recent: [] });
|
||||||
|
}
|
||||||
|
if (p === '/icon' && req.method === 'GET') { // station logos come through the hub too (they are often plain http, and fetching them directly would tell their servers who is listening)
|
||||||
|
const res2 = await open(u.searchParams.get('u') || '', { timeout: 8000 }), type = String(res2.headers['content-type'] || '').split(';')[0].toLowerCase();
|
||||||
|
if (res2.statusCode !== 200 || !/^image\/(png|jpeg|gif|webp|x-icon|vnd\.microsoft\.icon|svg\+xml)$/.test(type)) { res2.resume(); throw fail(404, 'No icon'); }
|
||||||
|
const c = []; let n = 0; for await (const d of res2) { n += d.length; if (n > 5e5) { res2.destroy(); throw fail(404, 'No icon'); } c.push(d); }
|
||||||
|
res.writeHead(200, { 'content-type': type === 'image/svg+xml' ? 'image/svg+xml' : type, 'cache-control': 'private, max-age=604800', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'", 'x-content-type-options': 'nosniff' }); return res.end(Buffer.concat(c));
|
||||||
|
}
|
||||||
|
if ((m = /^\/play\/([\w-]+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
const s = await station(m[1]), up = await open(s.url, { headers: { 'icy-metadata': '1' }, timeout: 15000 });
|
||||||
|
if (up.statusCode >= 400) { up.resume(); throw fail(502, s.name + ' answered ' + up.statusCode); }
|
||||||
|
const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||||
|
if (/mpegurl|x-scpls|text\/|html/.test(type)) { up.resume(); throw fail(502, s.name + ' sent a playlist, not a stream'); }
|
||||||
|
const metaint = +up.headers['icy-metaint'] || 0, key = me.id + ':' + s.id, filter = metaint ? icyFilter(metaint, t => nowMap.set(key, { title: t, t: Date.now() })) : null;
|
||||||
|
res.writeHead(200, { 'content-type': type.startsWith('audio/') || type === 'application/ogg' ? type : 'audio/mpeg', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'accept-ranges': 'none' });
|
||||||
|
const close = () => { try { up.destroy(); } catch {} }; res.on('close', close); up.on('error', () => res.destroy());
|
||||||
|
up.on('data', d => { if (!res.write(filter ? filter(d) : d)) { up.pause(); res.once('drain', () => up.resume()); } }); up.on('end', () => res.end());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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 : 502, { error: e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, icyFilter, isPrivate };
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
// "Save to Files": copy books, free audiobook chapters and Verse By Verse lessons and handouts into the person's own Seafile. The hub does the downloading and the
|
||||||
|
// uploading itself (using the Seafile sign-in already saved for that person), so nothing big passes through the phone, and a whole study keeps going in the background
|
||||||
|
// while you use the app. Files that are already there are skipped, so running it again just fills in what is missing.
|
||||||
|
const fs = require('fs'), crypto = require('crypto');
|
||||||
|
const settings = require('./settings'), users = require('./users'), books = require('./books'), vbv = require('./vbv'), librivox = require('./librivox');
|
||||||
|
|
||||||
|
const SEAFILE = () => (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
|
||||||
|
const MAX_FILE = 500 * 1024 * 1024;
|
||||||
|
const TABS = { 'vbv-notes': 'podcast', 'vbv-lesson': 'podcast', 'vbv-study': 'podcast', book: 'read', 'librivox-book': 'audio', 'librivox-chapter': 'audio' };
|
||||||
|
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 safe = (s, max = 100) => String(s || '').replace(/[\u0000-\u001f\\/:*?"<>|]+/g, ' ').replace(/\s+/g, ' ').replace(/^[. ]+|[. ]+$/g, '').slice(0, max).trim() || 'Untitled';
|
||||||
|
const num = (n, of) => String(n + 1).padStart(Math.max(2, String(of).length), '0');
|
||||||
|
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'); } };
|
||||||
|
|
||||||
|
// ---- Seafile, as the person
|
||||||
|
function seafile(token) {
|
||||||
|
const call = async (p, init = {}) => { const r = await fetch(SEAFILE() + p, { ...init, headers: { ...init.headers, Authorization: 'Token ' + token }, signal: AbortSignal.timeout(init.timeout || 30000) });
|
||||||
|
if (r.status === 401) throw fail(401, 'Your Files sign-in has expired. Open Files and sign in again.'); return r; };
|
||||||
|
const enc = encodeURIComponent;
|
||||||
|
return {
|
||||||
|
async list(repo, dir) { const r = await call(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`); if (r.status === 404) return null; if (!r.ok) throw fail(502, 'Files answered ' + r.status); return (await r.json()).map(x => ({ name: x.name, dir: x.type === 'dir' })); },
|
||||||
|
async mkdir(repo, dir) { const r = await call(`/api2/repos/${enc(repo)}/dir/?p=${enc(dir)}`, { method: 'POST', body: new URLSearchParams({ operation: 'mkdir' }), headers: { 'content-type': 'application/x-www-form-urlencoded' } }); if (!r.ok && r.status !== 409) throw fail(r.status === 403 ? 403 : 502, r.status === 403 ? 'You cannot add files to that library' : 'Could not create the folder (' + r.status + ')'); },
|
||||||
|
async upload(repo, dir, name, buf) {
|
||||||
|
const lr = await call(`/api2/repos/${enc(repo)}/upload-link/?p=${enc(dir)}`); if (!lr.ok) throw fail(lr.status === 403 ? 403 : 502, lr.status === 403 ? 'You cannot add files to that library' : 'Files would not accept the upload (' + lr.status + ')');
|
||||||
|
const link = await lr.json(), fd = new FormData(); fd.append('parent_dir', dir); fd.append('replace', '0'); fd.append('file', new Blob([buf]), name);
|
||||||
|
const up = await fetch(new URL(link, SEAFILE()), { method: 'POST', body: fd, signal: AbortSignal.timeout(15 * 60e3) }); if (!up.ok) throw fail(502, 'The upload failed (' + up.status + ')');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function download(url, allowed, ua) {
|
||||||
|
if (!allowed(url)) throw fail(502, 'That address is not allowed');
|
||||||
|
let r = await fetch(url, { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(10 * 60e3) });
|
||||||
|
for (let i = 0; i < 4 && [301, 302, 303, 307, 308].includes(r.status); i++) { const to = new URL(r.headers.get('location') || '', url).href; if (!allowed(to)) throw fail(502, 'That address is not allowed'); url = to; r = await fetch(url, { headers: { 'user-agent': ua }, redirect: 'manual', signal: AbortSignal.timeout(10 * 60e3) }); }
|
||||||
|
if (!r.ok) throw fail(502, 'The download failed (' + r.status + ')'); if ((+r.headers.get('content-length') || 0) > MAX_FILE) throw fail(413, 'That file is too large');
|
||||||
|
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > MAX_FILE) throw fail(413, 'That file is too large'); chunks.push(c); } return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- what to save: a list of files ({ folder, name, load }), worked out before anything starts so the person gets an answer at once
|
||||||
|
async function plan(me, b) {
|
||||||
|
const kind = String(b.kind || ''), out = [];
|
||||||
|
if (kind === 'book') {
|
||||||
|
const f = books.fileOf(String(b.id || '')); if (!f) throw fail(404, 'Book not found');
|
||||||
|
out.push({ folder: ['Books'], name: safe(f.author ? `${f.author} - ${f.title}` : f.title, 120) + '.' + f.ext, load: async () => fs.promises.readFile(f.path) });
|
||||||
|
return { label: f.title, out };
|
||||||
|
}
|
||||||
|
if (kind === 'librivox-book' || kind === 'librivox-chapter') {
|
||||||
|
const bk = await librivox.book(String(b.id || '')), only = kind === 'librivox-chapter' ? +b.n : -1;
|
||||||
|
bk.chapters.forEach(c => { if (only >= 0 && c.n !== only) return; out.push({ folder: ['Audiobooks', safe(bk.title)], name: `${num(c.n, bk.chapters.length)} - ${safe(c.title, 80)}.mp3`, load: () => download(c.url, librivox.allowed, 'NE-Hub/1.0') }); });
|
||||||
|
if (!out.length) throw fail(404, 'No chapters to save'); return { label: bk.title, out };
|
||||||
|
}
|
||||||
|
if (kind.startsWith('vbv-')) {
|
||||||
|
const slug = String(b.slug || ''), st = await vbv.studyInfo(slug), folder = ['Verse By Verse', safe(st.title)], what = b.what || 'both';
|
||||||
|
const lessons = kind === 'vbv-study' ? st.lessons : st.lessons.filter(l => l.n === +b.n); if (!lessons.length) throw fail(404, 'No such lesson');
|
||||||
|
const wantAudio = kind === 'vbv-notes' ? false : what !== 'notes', wantNotes = kind === 'vbv-notes' ? true : what !== 'audio';
|
||||||
|
for (const l of lessons) {
|
||||||
|
const base = `${num(l.n, st.lessons.length)} - ${safe(l.title, 80)}`;
|
||||||
|
if (wantAudio) out.push({ folder, name: base + '.mp3', load: async () => download(await vbv.audioUrl(slug, l.n), vbv.allowed, vbv.UA) });
|
||||||
|
if (wantNotes) out.push({ folder, name: base + ' - Notes.pdf', optional: true, load: async () => download(await vbv.notesUrl(slug, l.n), vbv.allowed, vbv.UA) });
|
||||||
|
}
|
||||||
|
return { label: kind === 'vbv-study' ? st.title : `${st.title}, lesson ${+b.n + 1}`, out };
|
||||||
|
}
|
||||||
|
throw fail(400, 'Unknown thing to save');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- jobs
|
||||||
|
const jobs = new Map();
|
||||||
|
setInterval(() => { for (const [k, j] of jobs) if (Date.now() - j.t > 6 * 3600e3 && j.state !== 'running') jobs.delete(k); }, 600e3).unref();
|
||||||
|
const view = j => ({ id: j.id, state: j.state, label: j.label, total: j.total, done: j.done, skipped: j.skipped, missing: j.missing, failed: j.failed, current: j.current, error: j.error, errors: j.errors.slice(0, 5), where: j.where, repo: j.repo, dir: j.dir });
|
||||||
|
|
||||||
|
async function run(j, token, items) {
|
||||||
|
const sf = seafile(token), made = new Set(); let existing = new Map();
|
||||||
|
const ensure = async folder => { let p = j.dir === '/' ? '' : j.dir; for (const part of folder) { p += '/' + part; if (made.has(p)) continue; const names = await sf.list(j.repo, p); if (names === null) await sf.mkdir(j.repo, p); made.add(p); } return (p || '/'); };
|
||||||
|
try {
|
||||||
|
for (const it of items) {
|
||||||
|
if (j.cancelled) { j.state = 'cancelled'; return; }
|
||||||
|
j.current = it.name; let target;
|
||||||
|
try {
|
||||||
|
target = await ensure(it.folder); if (!existing.has(target)) existing.set(target, new Set(((await sf.list(j.repo, target)) || []).map(x => x.name.toLowerCase())));
|
||||||
|
if (existing.get(target).has(it.name.toLowerCase())) { j.skipped++; continue; }
|
||||||
|
let buf; try { buf = await it.load(); } catch (e) { if (it.optional && e.code === 404) { j.missing++; continue; } throw e; }
|
||||||
|
try { await sf.upload(j.repo, target, it.name, buf); } catch (e) { if (e.code === 401 || e.code === 403) throw e; await sf.upload(j.repo, target, it.name, buf); } // one more try
|
||||||
|
existing.get(target).add(it.name.toLowerCase()); j.done++;
|
||||||
|
} catch (e) { if (e.code === 401 || e.code === 403) { j.state = 'error'; j.error = e.message; return; } j.failed++; j.errors.push(`${it.name}: ${e.message}`); }
|
||||||
|
}
|
||||||
|
j.state = j.failed && !j.done && !j.skipped ? 'error' : 'done'; if (j.state === 'error') j.error = j.errors[0] || 'Nothing could be saved';
|
||||||
|
} catch (e) { j.state = 'error'; j.error = e.message; } finally { j.current = ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/save'.length) || '/', me = users.current();
|
||||||
|
try {
|
||||||
|
let m;
|
||||||
|
if (p === '/' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req), tab = TABS[b.kind]; if (!tab) throw fail(400, 'Unknown thing to save'); if (!users.can(me, tab)) throw fail(403, 'You do not have access to that');
|
||||||
|
const token = settings.get('sf_token'); if (!token) throw fail(401, 'Open Files and sign in first, then try again');
|
||||||
|
const repo = String(b.repo || ''), dir = '/' + String(b.dir || '/').split('/').filter(x => x && x !== '.' && x !== '..').join('/'); if (!/^[\w-]{8,64}$/.test(repo)) throw fail(400, 'Choose a library and folder');
|
||||||
|
if ([...jobs.values()].filter(j => j.user === me.id && j.state === 'running').length >= 2) throw fail(429, 'Two saves are already running. Wait for one to finish.');
|
||||||
|
const { label, out } = await plan(me, b);
|
||||||
|
const j = { id: crypto.randomBytes(6).toString('hex'), user: me.id, t: Date.now(), state: 'running', label, total: out.length, done: 0, skipped: 0, missing: 0, failed: 0, current: '', error: '', errors: [], repo, dir, where: out[0].folder.join('/'), cancelled: false };
|
||||||
|
jobs.set(j.id, j); run(j, token, out); return json(res, 200, view(j));
|
||||||
|
}
|
||||||
|
if ((m = /^\/job\/(\w+)$/.exec(p))) {
|
||||||
|
const j = jobs.get(m[1]); if (!j || j.user !== me.id) throw fail(404, 'Not found');
|
||||||
|
if (req.method === 'DELETE') { j.cancelled = true; return json(res, 200, view(j)); } return json(res, 200, view(j));
|
||||||
|
}
|
||||||
|
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, safe, seafile };
|
||||||
@@ -4,6 +4,7 @@ const http = require('http');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const atomicWrite = require('./atomic');
|
const atomicWrite = require('./atomic');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const zlib = require('zlib');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { Readable } = require('stream');
|
const { Readable } = require('stream');
|
||||||
|
|
||||||
@@ -34,7 +35,15 @@ const admin = require('./admin');
|
|||||||
const backup = require('./backup');
|
const backup = require('./backup');
|
||||||
const push = require('./push');
|
const push = require('./push');
|
||||||
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
|
const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (MS_CLIENT_ID)
|
||||||
|
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
|
||||||
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
||||||
|
const abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL)
|
||||||
|
const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile
|
||||||
|
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
|
||||||
|
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
|
||||||
|
const librivox = require('./librivox'); // free audiobooks from LibriVox
|
||||||
|
const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes
|
||||||
|
const podcast = require('./podcast'); // Podgrab podcasts (PODCAST_URL, PODGRAB_PASSWORD)
|
||||||
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||||
const vault = require('./vault')('nehub-plex');
|
const vault = require('./vault')('nehub-plex');
|
||||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||||
@@ -121,10 +130,17 @@ function serveStatic(req, res) {
|
|||||||
headers.etag = `W/"${data.length}-${crypto.createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
|
headers.etag = `W/"${data.length}-${crypto.createHash('sha1').update(data).digest('hex').slice(0, 16)}"`;
|
||||||
if (req.headers['if-none-match'] === headers.etag) { res.writeHead(304, { etag: headers.etag, 'cache-control': 'no-cache' }); return res.end(); }
|
if (req.headers['if-none-match'] === headers.etag) { res.writeHead(304, { etag: headers.etag, 'cache-control': 'no-cache' }); return res.end(); }
|
||||||
}
|
}
|
||||||
|
// text files are sent gzipped when the browser accepts it (the app's scripts are several hundred KB), and the compressed copy is kept per version
|
||||||
|
if (/^(text\/|application\/(json|manifest))|svg/.test(headers['content-type']) && data.length > 1024 && /\bgzip\b/.test(req.headers['accept-encoding'] || '')) {
|
||||||
|
const key = file + ':' + data.length + ':' + (headers.etag || ''); let z = gzCache.get(key);
|
||||||
|
if (!z) { z = zlib.gzipSync(data, { level: 6 }); if (gzCache.size > 200) gzCache.clear(); gzCache.set(key, z); }
|
||||||
|
headers['content-encoding'] = 'gzip'; headers.vary = 'Accept-Encoding'; data = z;
|
||||||
|
}
|
||||||
res.writeHead(200, headers);
|
res.writeHead(200, headers);
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const gzCache = new Map();
|
||||||
|
|
||||||
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
|
// Changes whenever the app's files change, so an open page can tell that a newer version is available.
|
||||||
const BUILD = (() => { try { return crypto.createHash('sha1').update(fs.readdirSync(PUBLIC).filter(f => /\.(js|css|html)$/.test(f)).sort().map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
|
const BUILD = (() => { try { return crypto.createHash('sha1').update(fs.readdirSync(PUBLIC).filter(f => /\.(js|css|html)$/.test(f)).sort().map(f => fs.readFileSync(path.join(PUBLIC, f))).join('')).digest('hex').slice(0, 12); } catch { return String(Date.now()); } })();
|
||||||
@@ -233,6 +249,7 @@ async function plexTvAny(path, token, opts = {}) {
|
|||||||
}
|
}
|
||||||
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
|
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
|
||||||
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
|
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
|
||||||
|
const plexPinFails = new Map(); // hub person + Plex person -> wrong guesses
|
||||||
async function plexHomeUsers(token) {
|
async function plexHomeUsers(token) {
|
||||||
const { json, text } = await plexTvAny('/api/home/users', token);
|
const { json, text } = await plexTvAny('/api/home/users', token);
|
||||||
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
|
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
|
||||||
@@ -311,7 +328,7 @@ async function plexApi(req, res) {
|
|||||||
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
|
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
|
||||||
try {
|
try {
|
||||||
if (u.pathname === '/api/plex/status') {
|
if (u.pathname === '/api/plex/status') {
|
||||||
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null });
|
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null, canEdit: !!token && await plexCanEdit(st) });
|
||||||
}
|
}
|
||||||
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
|
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
|
||||||
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
|
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
|
||||||
@@ -355,19 +372,32 @@ async function plexApi(req, res) {
|
|||||||
const owner = st.mainToken || token;
|
const owner = st.mainToken || token;
|
||||||
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
|
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
|
||||||
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
|
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
|
||||||
if (target.admin && !st.mainToken) { plexSave({ ...st, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); } // already the account that signed in
|
const meNow = st.user ? String(st.user.id) : (target.admin && !st.mainToken ? target.id : '');
|
||||||
|
const same = meNow === target.id; // already using Plex as this person: nothing to prove
|
||||||
|
// Anyone with a PIN in Plex (the owner included) needs it every time you switch to them; plex.tv checks it, and wrong guesses are slowed down here.
|
||||||
|
const gate = (users.current() ? users.current().id : '') + ':' + target.id, fails = plexPinFails.get(gate) || { n: 0, until: 0 };
|
||||||
|
if (target.protected && !same) {
|
||||||
|
if (fails.until > Date.now()) return json(res, 429, { error: `Too many wrong PINs. Try again in ${Math.ceil((fails.until - Date.now()) / 60000)} min.` });
|
||||||
|
if (!pin) return json(res, 403, { error: 'That PIN is not right' });
|
||||||
|
}
|
||||||
let userToken;
|
let userToken;
|
||||||
if (target.admin && st.mainToken) userToken = st.mainToken; // back to the account that signed in: no PIN needed
|
if (same) userToken = target.admin && st.mainToken ? st.mainToken : st.token;
|
||||||
|
else if (target.admin && st.mainToken && !target.protected) userToken = st.mainToken; // back to the account that signed in, which has no PIN
|
||||||
else {
|
else {
|
||||||
try {
|
try {
|
||||||
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
|
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
|
||||||
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
|
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
|
||||||
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
|
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.status === 401 || e.status === 403) return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
if (e.status === 401 || e.status === 403) {
|
||||||
|
if (target.protected) { fails.n++; if (fails.n >= 5) { fails.until = Date.now() + 10 * 60e3; fails.n = 0; } plexPinFails.set(gate, fails); }
|
||||||
|
return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
if (!userToken && target.admin && st.mainToken) userToken = st.mainToken; // plex.tv accepted the PIN for the owner and gave no new token: the one that signed in is theirs
|
||||||
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
|
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
|
||||||
|
plexPinFails.delete(gate);
|
||||||
}
|
}
|
||||||
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
|
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
|
||||||
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
|
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
|
||||||
@@ -385,15 +415,49 @@ async function plexApi(req, res) {
|
|||||||
} catch (e) { json(res, 502, { error: e.message }); }
|
} catch (e) { json(res, 502, { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only playlists may be changed through the proxy (create, add, remove, rename, delete); everything else on the Plex server stays read-only.
|
// Only playlists and a title's own details (the fields Plex's Edit dialog has, and its poster/background choice) may be changed through the proxy;
|
||||||
const PLEX_WRITABLE = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
// everything else on the Plex server stays read-only. Plex itself refuses edits from anyone who is not allowed, and the hub checks too (plexCanEdit).
|
||||||
|
const PLEX_PLAYLISTS = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
||||||
|
const EDIT_FIELDS = 'title|titleSort|originalTitle|originallyAvailableAt|contentRating|studio|tagline|summary';
|
||||||
|
const EDIT_TAGS = 'genre|label|collection';
|
||||||
|
const EDIT_KEY = new RegExp(`^(type|id|includeExternalMedia|(${EDIT_FIELDS})\\.(value|locked)|(${EDIT_TAGS})\\.locked|(${EDIT_TAGS})\\[\\d{1,3}\\]\\.tag\\.tag)$`);
|
||||||
|
/** Is this request one of the allowed edits of a title's details? */
|
||||||
|
function plexEditRequest(method, url) {
|
||||||
|
const u = new URL(url, 'http://x'), p = u.pathname;
|
||||||
|
if (u.href.length > 12000) return false;
|
||||||
|
let m;
|
||||||
|
if (method === 'PUT' && (m = /^\/plex\/library\/sections\/\d+\/all$/.exec(p))) { // save fields: ?type=1&id=<key>&title.value=..&title.locked=1
|
||||||
|
const q = u.searchParams;
|
||||||
|
return /^[1-4]$/.test(q.get('type') || '') && /^\d+$/.test(q.get('id') || '') && [...q.keys()].every(k => EDIT_KEY.test(k));
|
||||||
|
}
|
||||||
|
if ((m = /^\/plex\/library\/metadata\/\d+\/(poster|art|posters|arts)$/.exec(p))) { // choose or add a poster / background
|
||||||
|
const img = u.searchParams.get('url') || '';
|
||||||
|
return [...u.searchParams.keys()].every(k => k === 'url') && /^(https:\/\/[^\s]{4,2000}|\/library\/metadata\/\d+\/(file|thumb|art)[^\s]*|(upload|metadata|media):\/\/[\w./%-]{1,300})$/.test(img)
|
||||||
|
&& (m[1].length > 3 ? ['POST', 'PUT'] : ['PUT']).includes(method) && (method === 'PUT' ? /^(poster|art)$/.test(m[1]) : /^(posters|arts)$/.test(m[1]));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Taking something off your own Continue Watching row changes nothing about the library, so anyone signed in may do it.
|
||||||
|
const PLEX_PERSONAL = /^\/plex\/actions\/removeFromContinueWatching\?ratingKey=\d+$/;
|
||||||
|
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || (method === 'PUT' && PLEX_PERSONAL.test(url)) || plexEditRequest(method, url);
|
||||||
|
const editOk = new Map(); // token + server -> { t, ok }
|
||||||
|
/** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */
|
||||||
|
async function plexCanEdit(st) {
|
||||||
|
if (!st.token || !st.sid) return false;
|
||||||
|
const k = st.token + '|' + st.sid, c = editOk.get(k);
|
||||||
|
if (c && Date.now() - c.t < 5 * 60e3) return c.ok;
|
||||||
|
let ok = false;
|
||||||
|
try { const rs = await plexTv(RESOURCES, st.token); ok = !!rs.find(x => x.clientIdentifier === st.sid && truthy(x.owned)); } catch { return false; }
|
||||||
|
editOk.set(k, { t: Date.now(), ok });
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
async function plexProxy(req, res) {
|
async function plexProxy(req, res) {
|
||||||
const st = plexLoad();
|
const st = plexLoad();
|
||||||
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
|
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
res.on('close', () => ac.abort());
|
res.on('close', () => ac.abort());
|
||||||
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
|
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
|
||||||
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
|
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), req.url.startsWith('/plex/video/') ? 45000 : 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
|
||||||
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, method: req.method,
|
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal, method: req.method,
|
||||||
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
|
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
|
||||||
let up = null;
|
let up = null;
|
||||||
@@ -410,6 +474,10 @@ async function plexProxy(req, res) {
|
|||||||
const encoded = !!up.headers.get('content-encoding');
|
const encoded = !!up.headers.get('content-encoding');
|
||||||
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { if (encoded && h === 'content-length') continue; const v = up.headers.get(h); if (v) out[h] = v; }
|
for (const h of ['content-type', 'content-length', 'content-range', 'accept-ranges']) { if (encoded && h === 'content-length') continue; const v = up.headers.get(h); if (v) out[h] = v; }
|
||||||
if (req.url.startsWith('/plex/photo/')) out['cache-control'] = 'private, max-age=86400';
|
if (req.url.startsWith('/plex/photo/')) out['cache-control'] = 'private, max-age=86400';
|
||||||
|
if (/mpegurl/i.test(up.headers.get('content-type') || '') && up.body) { // a playlist: its absolute addresses must stay under /plex so the browser asks the hub, not the site root
|
||||||
|
const text = (await up.text()).replace(/^\/(?!plex\/)/gm, '/plex/').replace(/URI="\/(?!plex\/)/g, 'URI="/plex/');
|
||||||
|
delete out['content-length']; res.writeHead(up.status, out); return res.end(text);
|
||||||
|
}
|
||||||
res.writeHead(up.status, out);
|
res.writeHead(up.status, out);
|
||||||
if (up.body) Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res); else res.end();
|
if (up.body) Readable.fromWeb(up.body).on('error', () => res.destroy()).pipe(res); else res.end();
|
||||||
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
|
} catch (e) { if (!res.headersSent) { res.writeHead(502); res.end(); } else res.destroy(); }
|
||||||
@@ -437,6 +505,8 @@ function storageInfo() {
|
|||||||
}
|
}
|
||||||
const STARTED = Date.now();
|
const STARTED = Date.now();
|
||||||
push.start();
|
push.start();
|
||||||
|
transcripts.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.TRANSCRIBE_API_URL || '', model: process.env.TRANSCRIBE_MODEL || '', port: PORT });
|
||||||
|
transcripts.startAuto({ on: process.env.TRANSCRIBE_AUTO !== '0', keep: process.env.TRANSCRIBE_KEEP }); // new and existing episodes are transcribed in the background (TRANSCRIBE_AUTO=0 turns it off; TRANSCRIBE_KEEP = how many of each show's newest)
|
||||||
lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' });
|
lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' });
|
||||||
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
|
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
|
||||||
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
|
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
|
||||||
@@ -472,10 +542,16 @@ const OO_OK = !!JWT_SECRET || process.env.ALLOW_UNSIGNED_OO === '1';
|
|||||||
const ACCESS = [
|
const ACCESS = [
|
||||||
[/^\/sf\//, ['files', 'office']],
|
[/^\/sf\//, ['files', 'office']],
|
||||||
[/^\/api\/oo\/config$/, ['office', 'files']],
|
[/^\/api\/oo\/config$/, ['office', 'files']],
|
||||||
[/^\/(api\/plex\/|plex\/)/, ['music']],
|
[/^\/(api\/plex\/|plex\/)/, ['music', 'video']],
|
||||||
[/^\/api\/audiobooks\//, ['audio']],
|
[/^\/api\/(audiobooks|ab)\//, ['audio']],
|
||||||
|
[/^\/api\/podcast\//, ['podcast']],
|
||||||
|
[/^\/api\/vbv\//, ['podcast']],
|
||||||
|
[/^\/api\/save\//, ['files']],
|
||||||
|
[/^\/api\/read\//, ['read']],
|
||||||
|
[/^\/api\/radio\//, ['radio']],
|
||||||
[/^\/api\/signal\//, ['signal']],
|
[/^\/api\/signal\//, ['signal']],
|
||||||
[/^\/api\/lyrics$/, ['music']],
|
[/^\/api\/lyrics$/, ['music']],
|
||||||
|
[/^\/api\/transcript$/, ['podcast', 'audio']],
|
||||||
[/^\/api\/mail\//, ['mail']],
|
[/^\/api\/mail\//, ['mail']],
|
||||||
[/^\/api\/cal\//, ['calendar']],
|
[/^\/api\/cal\//, ['calendar']],
|
||||||
[/^\/api\/tasks/, ['tasks']],
|
[/^\/api\/tasks/, ['tasks']],
|
||||||
@@ -526,20 +602,31 @@ async function handleRequest(req, res) {
|
|||||||
return users.runAs(user, async () => {
|
return users.runAs(user, async () => {
|
||||||
const rule = ACCESS.find(([re]) => re.test(pathname));
|
const rule = ACCESS.find(([re]) => re.test(pathname));
|
||||||
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
||||||
if ((pathname === '/api/system' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
||||||
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
||||||
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
||||||
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
||||||
const c = t => users.can(user, t);
|
const c = t => users.can(user, t);
|
||||||
res.writeHead(200, { 'content-type': 'application/json' });
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
||||||
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
podcast: c('podcast') ? PODCAST_URL : '', transcribe: (c('podcast') || c('audio')) && process.env.TRANSCRIBE_API_URL ? '1' : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', mail: c('mail') ? '1' : '', outlook: c('mail') && mail.outlookOn() ? '1' : '', mailAdmin: user.role === 'admin' ? '1' : '', editor: (c('office') || c('files')) && OO_OK, jwt: !!JWT_SECRET, ooReady: OO_OK, build: BUILD }));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
if (req.method === 'POST' && req.url === '/api/oo/config') return OO_OK ? ooConfig(req, res) : forbidden(res, 'In-app editing needs ONLYOFFICE_JWT_SECRET to be set on the server');
|
||||||
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
if (pathname === '/api/backup') return user.role === 'admin' ? backup.handle(req, res, user.name) : forbidden(res, 'Only an admin can do this');
|
||||||
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
||||||
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
if (pathname.startsWith('/api/push/')) return push.handle(req, res);
|
||||||
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
|
if (pathname === '/api/audit') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify({ events: require('./audit').recent(200) })); }
|
||||||
|
if (pathname === '/api/health') { // everything an admin wants to know at a glance
|
||||||
|
const fs = require('fs'), dir = path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), out = { at: Date.now() };
|
||||||
|
try { const f = fs.statfsSync(dir); out.disk = { free: f.bavail * f.bsize, total: f.blocks * f.bsize }; } catch { out.disk = null; }
|
||||||
|
const bk = require('./audit').recent(2000).find(x => x.event === 'backup downloaded'); out.backup = bk ? { t: bk.t, who: bk.who } : null;
|
||||||
|
out.uptime = Math.round((Date.now() - STARTED) / 1000); out.node = process.version;
|
||||||
|
const ps = plexLoad();
|
||||||
|
out.plex = !ps.srv ? { configured: false } : await (async () => { const t = Date.now(); try { const r = await fetch(ps.srv + '/identity', { headers: { 'X-Plex-Token': ps.stk || ps.token || '', Accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); return { configured: true, ok: r.ok, ms: Date.now() - t, user: ps.user ? ps.user.title : '' }; } catch (e) { return { configured: true, ok: false, error: e.name === 'TimeoutError' ? 'No answer in 6 seconds' : e.message }; } })();
|
||||||
|
try { out.mail = await mail.health(); } catch { out.mail = []; }
|
||||||
|
out.lyrics = lyrics.enabled(); out.signal = signal.enabled(); out.transcripts = transcripts.status();
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(out));
|
||||||
|
}
|
||||||
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
if (pathname === '/api/system') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(storageInfo())); }
|
||||||
if (pathname === '/api/settings') return settings.handle(req, res);
|
if (pathname === '/api/settings') return settings.handle(req, res);
|
||||||
if (pathname === '/api/search') return search.handle(req, res);
|
if (pathname === '/api/search') return search.handle(req, res);
|
||||||
@@ -549,11 +636,19 @@ async function handleRequest(req, res) {
|
|||||||
const to = audiobooksLogin(user);
|
const to = audiobooksLogin(user);
|
||||||
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
}
|
}
|
||||||
|
if (pathname === '/api/transcript') return transcripts.handle(req, res);
|
||||||
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
|
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
|
||||||
|
if (pathname.startsWith('/api/ab/free/')) return librivox.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/ab/')) return abapi.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/read/')) return books.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
|
||||||
|
if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res);
|
||||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||||
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
||||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
|
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || (req.method === 'PUT' && PLEX_PERSONAL.test(req.url)) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res);
|
||||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||||
return serveStatic(req, res);
|
return serveStatic(req, res);
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-2
@@ -9,7 +9,7 @@ const users = require('./users');
|
|||||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
||||||
|
|
||||||
// Only these keys are accepted. (The music queue stays in the browser.)
|
// Only these keys are accepted. (The music queue stays in the browser.)
|
||||||
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links']);
|
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||||
const SECRET = new Set(['ab_creds', 'sf_token']);
|
const SECRET = new Set(['ab_creds', 'sf_token']);
|
||||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||||
|
|
||||||
@@ -47,4 +47,7 @@ async function handle(req, res) {
|
|||||||
} catch (e) { json(res, 500, { error: e.message }); }
|
} catch (e) { json(res, 500, { error: e.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, KEYS };
|
// One saved value for the current person, read on the server (secret ones are decrypted).
|
||||||
|
const get = k => { const v = load()[k]; return SECRET.has(k) && v && v._sealed ? unseal(v) : v; };
|
||||||
|
|
||||||
|
module.exports = { handle, KEYS, get };
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Book store: free, DRM-free books from Project Gutenberg (Gutendex), Standard Ebooks (OPDS), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an
|
||||||
|
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
|
||||||
|
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
|
||||||
|
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||||
|
const users = require('./users'), auth = require('./auth'), books = require('./books');
|
||||||
|
|
||||||
|
const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
|
||||||
|
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
|
||||||
|
const env = (k, d) => (process.env[k] || d).replace(/\/+$/, '');
|
||||||
|
const BUILTIN = {
|
||||||
|
gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENDEX_URL', 'https://gutendex.com'), hosts: ['gutendex.com', 'gutenberg.org'] },
|
||||||
|
standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org/feeds/opds'), hosts: ['standardebooks.org'], opds: true },
|
||||||
|
archive: { name: 'Internet Archive', base: () => env('ARCHIVE_URL', 'https://archive.org'), hosts: ['archive.org'], needsQuery: true },
|
||||||
|
};
|
||||||
|
const FORMAT_OF = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', 'application/x-mobipocket-ebook': 'mobi', 'application/vnd.amazon.mobi8-ebook': 'azw3', 'application/vnd.amazon.ebook': 'azw' };
|
||||||
|
const PREFER = ['epub', 'azw3', 'mobi', 'azw', 'pdf'];
|
||||||
|
|
||||||
|
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 readBody = async (req, max = 1e5) => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > max) throw fail(413, 'Too large'); c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { throw fail(400, 'Bad request'); } };
|
||||||
|
|
||||||
|
// ---- catalogues an admin added (shared by everyone who may use Books). Sign-ins are encrypted at rest with a key from the server secret.
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), SRC_FILE = () => path.join(DATA_DIR, 'store-sources.json');
|
||||||
|
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-store', 32));
|
||||||
|
const seal = v => { const iv = crypto.randomBytes(12), c = crypto.createCipheriv('aes-256-gcm', key(), iv), d = Buffer.concat([c.update(JSON.stringify(v), 'utf8'), c.final()]); return Buffer.concat([iv, c.getAuthTag(), d]).toString('base64'); };
|
||||||
|
const unseal = s => { try { const b = Buffer.from(s, 'base64'), d = crypto.createDecipheriv('aes-256-gcm', key(), b.subarray(0, 12)); d.setAuthTag(b.subarray(12, 28)); return JSON.parse(Buffer.concat([d.update(b.subarray(28)), d.final()]).toString('utf8')); } catch { return null; } };
|
||||||
|
const customs = () => { try { const l = JSON.parse(fs.readFileSync(SRC_FILE(), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||||
|
const saveCustoms = l => { const f = SRC_FILE(), tmp = f + '.tmp'; fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(tmp, JSON.stringify(l), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||||
|
const sourceOf = id => {
|
||||||
|
if (BUILTIN[id]) { const b = BUILTIN[id]; return { id, name: b.name, base: b.base(), builtin: true, opds: !!b.opds, needsQuery: !!b.needsQuery, hosts: [...b.hosts, new URL(b.base()).hostname] }; }
|
||||||
|
const c = customs().find(x => x.id === id); if (!c) throw fail(404, 'Unknown catalogue');
|
||||||
|
const cred = c.auth ? unseal(c.auth) : null; return { id, name: c.name, base: c.url, builtin: false, opds: true, cred };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- talking to a site: only its own addresses are ever fetched (redirects included), a size limit applies, and a catalogue's sign-in goes nowhere else
|
||||||
|
const sameSite = (src, u) => { const h = u.hostname; return src.builtin ? src.hosts.some(x => h === x || h.endsWith('.' + x)) : u.origin === new URL(src.base).origin; };
|
||||||
|
async function get(src, url, { accept = '*/*', timeout = 10000 } = {}) {
|
||||||
|
let u = new URL(url);
|
||||||
|
for (let hop = 0; hop < 6; hop++) {
|
||||||
|
if (!/^https?:$/.test(u.protocol) || !sameSite(src, u)) throw fail(502, 'That address is not part of ' + src.name);
|
||||||
|
const headers = { 'user-agent': UA, accept }; if (src.cred && u.origin === new URL(src.base).origin) headers.authorization = 'Basic ' + Buffer.from(src.cred.user + ':' + src.cred.pass).toString('base64');
|
||||||
|
const r = await fetch(u, { headers, redirect: 'manual', signal: AbortSignal.timeout(timeout) });
|
||||||
|
if ([301, 302, 303, 307, 308].includes(r.status) && r.headers.get('location')) { u = new URL(r.headers.get('location'), u); try { await r.body?.cancel(); } catch {} continue; }
|
||||||
|
if (r.status === 401 || r.status === 403) throw fail(502, src.name + (src.cred ? ' refused the saved sign-in' : ' needs a sign-in'));
|
||||||
|
if (!r.ok) throw fail(502, src.name + ' answered ' + r.status);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
throw fail(502, 'Too many redirects from ' + src.name);
|
||||||
|
}
|
||||||
|
async function limited(r, max) {
|
||||||
|
const len = +r.headers.get('content-length') || 0; if (len > max) throw fail(413, 'That file is too large');
|
||||||
|
const chunks = []; let n = 0; for await (const c of r.body) { n += c.length; if (n > max) throw fail(413, 'That file is too large'); chunks.push(c); } return Buffer.concat(chunks);
|
||||||
|
}
|
||||||
|
const getText = async (src, url, accept) => (await limited(await get(src, url, { accept }), MAX_TEXT)).toString('utf8');
|
||||||
|
const getJson = async (src, url) => { try { return JSON.parse(await getText(src, url, 'application/json')); } catch (e) { if (e.code) throw e; throw fail(502, src.name + ' sent something unexpected'); } };
|
||||||
|
|
||||||
|
// ---- OPDS (Atom) feeds, read with a small parser (no XML library in the hub)
|
||||||
|
const unent = s => String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[e.toLowerCase()] || (e[1] === 'x' || e[1] === 'X' ? String.fromCodePoint(parseInt(e.slice(2), 16)) : e[0] === '#' ? String.fromCodePoint(+e.slice(1)) : m)));
|
||||||
|
const tag = (x, t) => { const m = new RegExp('<' + t + '(?:\\s[^>]*)?>([\\s\\S]*?)</' + t + '>', 'i').exec(x); return m ? unent(m[1]).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() : ''; };
|
||||||
|
const attrs = s => { const o = {}; for (const m of s.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) o[m[1].toLowerCase()] = unent(m[2] != null ? m[2] : m[3]); return o; };
|
||||||
|
const linksOf = (x, base) => [...x.matchAll(/<link\b([^>]*?)\/?>/gi)].map(m => attrs(m[1])).filter(a => a.href).map(a => ({ ...a, href: new URL(a.href, base).href }));
|
||||||
|
function parseFeed(xml, base) {
|
||||||
|
const head = xml.replace(/<entry[\s>][\s\S]*?<\/entry>/gi, ''), feed = { links: linksOf(head, base), entries: [] };
|
||||||
|
for (const m of xml.matchAll(/<entry[\s>][\s\S]*?<\/entry>/gi)) {
|
||||||
|
const e = m[0], links = linksOf(e, base), acq = links.filter(l => /opds-spec\.org\/acquisition/.test(l.rel || '') && FORMAT_OF[(l.type || '').split(';')[0].trim()]).map(l => ({ href: l.href, format: FORMAT_OF[l.type.split(';')[0].trim()] })).sort((a, b) => PREFER.indexOf(a.format) - PREFER.indexOf(b.format));
|
||||||
|
const img = links.find(l => /opds-spec\.org\/image$/.test(l.rel || '')) || links.find(l => /opds-spec\.org\/image/.test(l.rel || ''));
|
||||||
|
const nav = links.find(l => /atom\+xml/.test(l.type || '') && !/opds-spec\.org\/acquisition/.test(l.rel || ''));
|
||||||
|
feed.entries.push({ title: tag(e, 'title'), author: tag((/<author[\s>][\s\S]*?<\/author>/i.exec(e) || [''])[0], 'name'), summary: tag(e, 'summary') || tag(e, 'content'), acq: acq[0] || null, cover: img ? img.href : '', nav: nav ? nav.href : '' });
|
||||||
|
}
|
||||||
|
return feed;
|
||||||
|
}
|
||||||
|
const unbrace = u => u.replace(/%7B/gi, '{').replace(/%7D/gi, '}');
|
||||||
|
const rootCache = new Map();
|
||||||
|
async function opdsRoot(src) { const c = rootCache.get(src.id + src.base); if (c && Date.now() - c.t < 600000) return c.v; const v = parseFeed(await getText(src, src.base, 'application/atom+xml, application/xml;q=0.9, */*;q=0.5'), src.base); rootCache.set(src.id + src.base, { t: Date.now(), v }); return v; }
|
||||||
|
async function opdsSearchUrl(src, q) {
|
||||||
|
const root = await opdsRoot(src), enc = encodeURIComponent(q), fill = t => t.replace(/\{searchTerms\??\}/g, enc).replace(/\{[^}]*\?\}/g, '').replace(/\{[^}]*\}/g, '');
|
||||||
|
const atom = root.links.find(l => l.rel === 'search' && /atom\+xml/.test(l.type || '') && /\{searchTerms/.test(unbrace(l.href)));
|
||||||
|
if (atom) return fill(unbrace(atom.href));
|
||||||
|
const osd = root.links.find(l => l.rel === 'search' && /opensearchdescription/.test(l.type || ''));
|
||||||
|
if (osd) { const x = await getText(src, osd.href, 'application/xml'); for (const m of x.matchAll(/<Url\b([^>]*?)\/?>/gi)) { const a = attrs(m[1]); if (/atom\+xml/.test(a.type || '') && a.template) return fill(unbrace(new URL(a.template.replace(/\{/g, '%7B').replace(/\}/g, '%7D'), osd.href).href)); } }
|
||||||
|
if (src.id === 'standardebooks') return src.base + '/all?query=' + enc;
|
||||||
|
throw fail(502, src.name + ' does not offer a search');
|
||||||
|
}
|
||||||
|
async function opdsSearch(src, q, page) {
|
||||||
|
let url;
|
||||||
|
if (q) url = await opdsSearchUrl(src, q);
|
||||||
|
else { const root = await opdsRoot(src); const nav = root.entries.filter(e => e.nav); url = root.entries.some(e => e.acq) ? src.base : (nav.find(e => /new|recent|latest|popular/i.test(e.title)) || nav[0] || {}).nav || src.base; }
|
||||||
|
let feed = parseFeed(await getText(src, url, 'application/atom+xml'), url);
|
||||||
|
for (let i = 1; i < page; i++) { const next = feed.links.find(l => l.rel === 'next'); if (!next) return { items: [], more: false }; url = next.href; feed = parseFeed(await getText(src, url, 'application/atom+xml'), url); }
|
||||||
|
const items = feed.entries.filter(e => e.acq).map(e => ({ ref: e.acq.href, format: e.acq.format, title: e.title, author: e.author, summary: e.summary.slice(0, 600), cover: e.cover }));
|
||||||
|
return { items, more: !!feed.links.find(l => l.rel === 'next') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Project Gutenberg through Gutendex, and the Internet Archive
|
||||||
|
const tidyName = n => { const m = /^([^,]+),\s*(.+)$/.exec(n || ''); return m ? (m[2] + ' ' + m[1]).replace(/\s*\(.*?\)/g, '').trim() : String(n || ''); };
|
||||||
|
async function gutenbergSearch(src, q, page) {
|
||||||
|
const j = await getJson(src, `${src.base}/books/?${q ? 'search=' + encodeURIComponent(q) + '&' : ''}page=${page}`);
|
||||||
|
const items = (j.results || []).filter(b => (b.formats || {})['application/epub+zip']).map(b => ({ ref: String(b.id), format: 'epub', title: b.title, author: (b.authors || []).map(a => tidyName(a.name)).join(', '), summary: (b.subjects || []).slice(0, 3).join(' · '), cover: (b.formats || {})['image/jpeg'] || '' }));
|
||||||
|
return { items, more: !!j.next };
|
||||||
|
}
|
||||||
|
async function gutenbergResolve(src, ref) {
|
||||||
|
if (!/^\d{1,9}$/.test(ref)) throw fail(400, 'Bad book');
|
||||||
|
const b = await getJson(src, `${src.base}/books/${ref}`), url = (b.formats || {})['application/epub+zip']; if (!url) throw fail(404, 'No EPUB for that book');
|
||||||
|
return { url, format: 'epub' };
|
||||||
|
}
|
||||||
|
async function archiveSearch(src, q, page) {
|
||||||
|
const query = `(${q}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
|
||||||
|
const j = await getJson(src, `${src.base}/advancedsearch.php?q=${encodeURIComponent(query)}&fl[]=identifier&fl[]=title&fl[]=creator&rows=20&page=${page}&output=json`);
|
||||||
|
const docs = (j.response && j.response.docs) || [], total = (j.response && j.response.numFound) || 0;
|
||||||
|
return { items: docs.filter(d => /^[\w.-]+$/.test(d.identifier || '')).map(d => ({ ref: d.identifier, format: 'epub', title: [].concat(d.title || '')[0] || d.identifier, author: [].concat(d.creator || []).join(', '), summary: '', cover: `${src.base}/services/img/${d.identifier}` })), more: page * 20 < total };
|
||||||
|
}
|
||||||
|
async function archiveResolve(src, ref) {
|
||||||
|
if (!/^[\w.-]+$/.test(ref)) throw fail(400, 'Bad book');
|
||||||
|
const m = await getJson(src, `${src.base}/metadata/${ref}`);
|
||||||
|
if (m.metadata && String(m.metadata['access-restricted-item']) === 'true') throw fail(403, 'That book is borrow-only (it has DRM) and cannot be added');
|
||||||
|
const files = (m.files || []).filter(f => /\.epub$/i.test(f.name || '')); const f = files.find(x => /^EPUB$/i.test(x.format)) || files[0]; if (!f) throw fail(404, 'No EPUB file for that item');
|
||||||
|
return { url: `${src.base}/download/${ref}/${f.name.split('/').map(encodeURIComponent).join('/')}`, format: 'epub' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchOne(src, q, page) {
|
||||||
|
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
|
||||||
|
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
|
||||||
|
return opdsSearch(src, q, page);
|
||||||
|
}
|
||||||
|
const stamp = (src, it) => ({ ...it, source: src.id, sourceName: src.name, cover: it.cover ? `/api/read/store/cover?s=${encodeURIComponent(src.id)}&u=${encodeURIComponent(it.cover)}` : '', coverUrl: it.cover || '' });
|
||||||
|
const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)].map(id => { const s = sourceOf(id); return { id, name: s.name, builtin: s.builtin, needsQuery: !!s.needsQuery }; });
|
||||||
|
|
||||||
|
async function handle(req, res, p) {
|
||||||
|
const me = users.current();
|
||||||
|
try {
|
||||||
|
if (p === '/sources' && req.method === 'GET') return json(res, 200, listSources());
|
||||||
|
if (p === '/sources' && req.method === 'POST') { // add an OPDS catalogue (admin)
|
||||||
|
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can add a catalogue');
|
||||||
|
const b = await readBody(req), name = String(b.name || '').trim().slice(0, 60), url = String(b.url || '').trim();
|
||||||
|
if (!name || !/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'Give the catalogue a name and its address (starting with http)');
|
||||||
|
const id = 'c' + crypto.randomBytes(5).toString('hex'), entry = { id, name, url, auth: b.user ? seal({ user: String(b.user), pass: String(b.pass || '') }) : '' };
|
||||||
|
const l = customs(); l.push(entry); saveCustoms(l);
|
||||||
|
try { const src = sourceOf(id), xml = await getText(src, url, 'application/atom+xml'); if (!/<feed[\s>]/i.test(xml)) throw fail(502, 'That does not look like an OPDS catalogue'); }
|
||||||
|
catch (e) { saveCustoms(customs().filter(x => x.id !== id)); throw fail(e.code || 502, e.code ? e.message : 'Could not read that catalogue: ' + e.message); }
|
||||||
|
return json(res, 200, { id, name });
|
||||||
|
}
|
||||||
|
let m;
|
||||||
|
if ((m = /^\/sources\/(\w+)$/.exec(p)) && req.method === 'DELETE') {
|
||||||
|
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove a catalogue');
|
||||||
|
saveCustoms(customs().filter(x => x.id !== m[1])); return json(res, 200, { removed: true });
|
||||||
|
}
|
||||||
|
if (p === '/search' && req.method === 'GET') {
|
||||||
|
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all';
|
||||||
|
const ids = want === 'all' ? listSources().map(s => s.id) : [want], lists = [], errors = {}; let more = false;
|
||||||
|
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
|
||||||
|
lists.sort((a, b) => ids.indexOf(a[0]) - ids.indexOf(b[0]));
|
||||||
|
const items = []; for (let i = 0; lists.some(l => l[1][i]); i++) for (const l of lists) if (l[1][i]) items.push(l[1][i]); // one from each catalogue in turn
|
||||||
|
return json(res, 200, { items, errors, more });
|
||||||
|
}
|
||||||
|
if (p === '/cover' && req.method === 'GET') {
|
||||||
|
const u = new URL(req.url, 'http://x'), src = sourceOf(u.searchParams.get('s') || ''), r = await get(src, u.searchParams.get('u') || '', { accept: 'image/*' }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||||
|
if (!/^image\/(jpeg|png|gif|webp)$/.test(type)) throw fail(502, 'Not a picture');
|
||||||
|
const buf = await limited(r, MAX_COVER); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
|
||||||
|
}
|
||||||
|
if (p === '/add' && req.method === 'POST') {
|
||||||
|
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
|
||||||
|
let target;
|
||||||
|
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
|
||||||
|
else if (src.id === 'archive') target = await archiveResolve(src, ref);
|
||||||
|
else { if (!sameSite(src, new URL(ref, src.base))) throw fail(400, 'Bad book address'); target = { url: ref, format: String(b.format || 'epub') }; }
|
||||||
|
const ext = books.TYPES[target.format] ? target.format : 'epub';
|
||||||
|
const buf = await limited(await get(src, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK);
|
||||||
|
let cover; const cu = String(b.cover || ''); if (cu) { try { const r = await get(src, cu, { accept: 'image/*' }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); cover = { type, data: await limited(r, MAX_COVER) }; } catch {} }
|
||||||
|
let book; try { book = books.addBook(buf, ext, { title: String(b.title || '').slice(0, 200), author: String(b.author || '').slice(0, 200) }, cover); } catch (e) { throw fail(400, e.message); }
|
||||||
|
return json(res, 200, book);
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'Not found' });
|
||||||
|
} catch (e) { return json(res, e.code && e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.name === 'TimeoutError' ? 'The site did not answer in time' : e.message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, parseFeed };
|
||||||
@@ -152,6 +152,12 @@ async function handle(req, res) {
|
|||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) throw new Error('Bad date range');
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) throw new Error('Bad date range');
|
||||||
return json(res, 200, (await allTasks()).filter(x => !x.done && !x.parent && x.due && x.due >= from && x.due <= to).slice(0, 500));
|
return json(res, 200, (await allTasks()).filter(x => !x.done && !x.parent && x.due && x.due >= from && x.due <= to).slice(0, 500));
|
||||||
}
|
}
|
||||||
|
if (p === '/api/tasks/upcoming' && req.method === 'GET') { // for the Home card: what is overdue, due today or due in the next week, from every list
|
||||||
|
const today = q.get('today') || new Date().toISOString().slice(0, 10); if (!/^\d{4}-\d{2}-\d{2}$/.test(today)) throw new Error('Bad date');
|
||||||
|
const end = new Date(today + 'T00:00:00Z'); end.setUTCDate(end.getUTCDate() + 7); const last = end.toISOString().slice(0, 10);
|
||||||
|
const rows = (await allTasks()).filter(x => !x.done && !x.parent && x.due && x.due <= last).sort((a, b) => a.due.localeCompare(b.due) || a.title.localeCompare(b.title));
|
||||||
|
return json(res, 200, { total: rows.length, tasks: rows.slice(0, 8) });
|
||||||
|
}
|
||||||
if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today
|
if (p === '/api/tasks/summary' && req.method === 'GET') { // for the Home card: open tasks that are overdue or due today
|
||||||
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
|
const today = q.get('today') || new Date().toISOString().slice(0, 10), out = { overdue: 0, today: 0, open: 0 };
|
||||||
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
|
const count = list => list.forEach(t => { if (t.done) return; out.open++; if (t.due && t.due < today) out.overdue++; else if (t.due === today) out.today++; });
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Audiobooks through the hub: the hub signs in to the player as the person, streams audio with ranges, keeps progress per person, and blocks everything else.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-ab-'));
|
||||||
|
process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'ab-'.repeat(12);
|
||||||
|
const seen = []; let progress = {};
|
||||||
|
const player = http.createServer((req, res) => {
|
||||||
|
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
||||||
|
seen.push({ m: req.method, u: req.url, cookie: req.headers.cookie, range: req.headers.range });
|
||||||
|
const j = (c, o, h = {}) => { res.writeHead(c, { 'content-type': 'application/json', ...h }); res.end(JSON.stringify(o)); };
|
||||||
|
if (req.url === '/login') { const o = JSON.parse(b); return o.username === 'nate' && o.password === 'pw' ? j(200, { user: 'nate' }, { 'set-cookie': 'oa_session=tok1; Path=/; HttpOnly' }) : j(401, { error: 'wrong' }); }
|
||||||
|
if (!/oa_session=tok1/.test(req.headers.cookie || '')) return j(401, { error: 'not signed in' });
|
||||||
|
if (req.url === '/api/books') return j(200, [{ id: 'b1', title: 'Book One', author: 'A. Writer' }]);
|
||||||
|
if (req.url === '/admin/api/books/b1/files') return j(200, ['b1.m4b', 'b1.mp3']);
|
||||||
|
if (req.url === '/api/books/b1/files/b1.mp3') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
|
||||||
|
if (req.url === '/sync/progress' && req.method === 'POST') { progress = { ...progress, ...JSON.parse(b) }; return j(200, progress); }
|
||||||
|
if (req.url === '/sync/progress') return j(200, progress);
|
||||||
|
j(404, { error: 'no' });
|
||||||
|
});
|
||||||
|
}).listen(0, async () => {
|
||||||
|
process.env.AUDIOBOOKS_URL = 'http://127.0.0.1:' + player.address().port;
|
||||||
|
const users = require('../users'), abapi = require('../abapi');
|
||||||
|
const me = { id: 'ab-user', name: 'Nate' };
|
||||||
|
fs.mkdirSync(path.join(dir, 'users', me.id), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'users', me.id, 'settings.json'), JSON.stringify({ ab_creds: { u: 'nate', p: 'pw' } }));
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-nobody'] ? { id: 'nobody', name: 'N' } : me, () => abapi.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/ab' + p, { redirect: 'manual', ...o });
|
||||||
|
try {
|
||||||
|
let r = await get('/books'); assert.equal(r.status, 200); assert.equal((await r.json())[0].title, 'Book One');
|
||||||
|
assert.ok(/oa_session=tok1/.test(seen.at(-1).cookie), 'signed in as the person');
|
||||||
|
r = await get('/books/b1/filelist'); assert.deepEqual(await r.json(), ['b1.m4b', 'b1.mp3']);
|
||||||
|
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
|
||||||
|
r = await get('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ b1: { pos: 12, dur: 100, t: 5 } }) }); assert.equal(r.status, 200);
|
||||||
|
r = await get('/progress'); assert.equal((await r.json()).b1.pos, 12);
|
||||||
|
await new Promise(ok => setTimeout(ok, 100)); assert.equal(progress.b1.pos, 12, 'a personal sign-in on the player is kept in step');
|
||||||
|
progress.b7 = { pos: 3, dur: 9, t: 99 }; r = await get('/progress'); assert.equal((await r.json()).b7.pos, 3, 'what the player knows is merged in');
|
||||||
|
r = await get('/progress', { headers: { 'x-nobody': '1' } }); assert.deepEqual(await r.json(), {}, 'another person starts with their own empty positions');
|
||||||
|
const logins = seen.filter(s => s.u === '/login').length; assert.equal(logins, 1, 'one sign-in is reused');
|
||||||
|
const n = seen.length;
|
||||||
|
for (const [m, p] of [['POST', '/books'], ['GET', '/admin/users'], ['GET', '/books/b1'], ['GET', '/books/b1/files'], ['DELETE', '/progress'], ['GET', '/books/../admin']]) {
|
||||||
|
r = await get(p, { method: m }); assert.ok([404, 405].includes(r.status), m + ' ' + p + ' -> ' + r.status);
|
||||||
|
}
|
||||||
|
assert.equal(seen.length, n, 'blocked calls never reach the player');
|
||||||
|
r = await get('/books', { headers: { 'x-nobody': '1' } }); assert.equal(r.status, 503); assert.ok(/sign-in in Settings/.test((await r.json()).error), 'someone with no saved sign-in is told what to do');
|
||||||
|
console.log('audiobooks ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close(); player.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Audiobooks straight from OpenAudible (OPENAUDIBLE_URL): its own username and password, byte ranges cut by the hub, positions kept by the hub per person.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-abd-'));
|
||||||
|
process.env.DATA_DIR = dir; process.env.COOKIE_SECRET = 'abd-'.repeat(8);
|
||||||
|
const seen = [], AUDIO = Buffer.from('0123456789');
|
||||||
|
const oa = http.createServer((req, res) => {
|
||||||
|
seen.push({ u: req.url, auth: req.headers.authorization, range: req.headers.range });
|
||||||
|
if (req.headers.authorization !== 'Basic ' + Buffer.from('nate:oapw').toString('base64')) { res.writeHead(401); return res.end(); }
|
||||||
|
if (req.url === '/api/books') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('[{"id":"b1","title":"One"}]'); }
|
||||||
|
if (req.url === '/api/books/b1/files/b1.mp3') { res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': AUDIO.length }); return res.end(AUDIO); } // ignores Range, like some servers
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, async () => {
|
||||||
|
process.env.OPENAUDIBLE_URL = 'http://127.0.0.1:' + oa.address().port;
|
||||||
|
const users = require('../users'), abapi = require('../abapi'), me = { id: 'abd', name: 'N' };
|
||||||
|
const put = c => { fs.mkdirSync(path.join(dir, 'users', me.id), { recursive: true }); fs.writeFileSync(path.join(dir, 'users', me.id, 'settings.json'), JSON.stringify({ ab_creds: c })); };
|
||||||
|
put({ u: 'nate', p: 'oapw' });
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(me, () => abapi.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/ab', get = (p, o) => fetch(base + p, o);
|
||||||
|
try {
|
||||||
|
let r = await get('/books'); assert.equal(r.status, 200); assert.equal((await r.json())[0].title, 'One');
|
||||||
|
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=2-5' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '2345'); assert.equal(r.headers.get('content-range'), 'bytes 2-5/10');
|
||||||
|
r = await get('/books/b1/files/b1.mp3', { headers: { range: 'bytes=7-' } }); assert.equal(await r.text(), '789');
|
||||||
|
r = await get('/books/b1/files/b1.mp3'); assert.equal(r.status, 200); assert.equal((await r.text()).length, 10);
|
||||||
|
const post = b => get('/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }).then(x => x.json());
|
||||||
|
await post({ b1: { pos: 50, dur: 100, t: 10 } }); let p = await post({ b1: { pos: 5, dur: 100, t: 5 }, b2: { pos: 1, dur: 9, t: 1 } });
|
||||||
|
assert.equal(p.b1.pos, 50, 'the newest position wins'); assert.equal(p.b2.pos, 1);
|
||||||
|
assert.equal((await (await get('/progress')).json()).b1.pos, 50);
|
||||||
|
put({ u: 'nate', p: 'wrong' });
|
||||||
|
r = await get('/books'); assert.equal(r.status, 502); assert.ok(/did not accept the username and password/.test((await r.json()).error));
|
||||||
|
put({});
|
||||||
|
r = await get('/books'); assert.equal(r.status, 503); assert.ok(/OpenAudible username and password in Settings/.test((await r.json()).error));
|
||||||
|
console.log('audiobooks direct ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close(); oa.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// E-book library: uploads are checked by content, each person has their own shelf, and position and notes merge so the newest edit wins.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-books-'));
|
||||||
|
const books = require('../books'), users = require('../users');
|
||||||
|
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bookfx-'));
|
||||||
|
cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
|
||||||
|
const epub = fs.readFileSync(path.join(fx, 'test.epub')), pdf = fs.readFileSync(path.join(fx, 'test.pdf')), docx = fs.readFileSync(path.join(__dirname, '..', 'sample.docx'));
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/read', call = (p, o = {}) => fetch(base + p, o), up = (name, buf, h) => call('/upload?name=' + encodeURIComponent(name), { method: 'POST', body: buf, headers: h });
|
||||||
|
try {
|
||||||
|
let r = await up('Rabbit_Tales.epub', epub); assert.equal(r.status, 200); const e = await r.json(); assert.equal(e.ext, 'epub'); assert.equal(e.title, 'Rabbit Tales');
|
||||||
|
r = await up('again.epub', epub); assert.equal((await r.json()).duplicate, true, 'the same file is not stored twice');
|
||||||
|
r = await up('m.pdf', pdf); const p = await r.json(); assert.equal(p.ext, 'pdf');
|
||||||
|
r = await up('d.docx', docx); assert.equal(r.status, 200);
|
||||||
|
assert.equal((await up('fake.pdf', Buffer.from('<html>not a pdf</html>'))).status, 400, 'a file must look like what its name says');
|
||||||
|
assert.equal((await up('fake.epub', pdf)).status, 400); assert.equal((await up('x.exe', epub)).status, 400); assert.equal((await up('plain.docx', epub)).status, 400, 'an EPUB is not a DOCX');
|
||||||
|
const mobi = fs.readFileSync(path.join(fx, 'test.mobi')), drm = fs.readFileSync(path.join(fx, 'drm.mobi'));
|
||||||
|
r = await up('k.azw3', mobi); assert.equal(r.status, 200); assert.equal((await r.json()).ext, 'azw3', 'DRM-free Kindle files are accepted');
|
||||||
|
r = await up('locked.mobi', drm); assert.equal(r.status, 400); assert.match((await r.json()).error, /DRM/, 'books with DRM are refused with a clear reason');
|
||||||
|
assert.equal((await up('fake.mobi', pdf)).status, 400, 'a MOBI must look like one');
|
||||||
|
assert.equal((await call('/list')).status, 200); assert.equal((await (await call('/list')).json()).length, 4);
|
||||||
|
assert.equal((await (await call('/list', { headers: { 'x-user': 'b' } })).json()).length, 0, 'each person has their own shelf');
|
||||||
|
assert.equal((await call('/book/' + e.id + '/file', { headers: { 'x-user': 'b' } })).status, 404, "nobody can open another person's book");
|
||||||
|
r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=0-4' } }); assert.equal(r.status, 206); assert.equal(await r.text(), '%PDF-'); assert.equal(r.headers.get('content-range'), `bytes 0-4/${pdf.length}`);
|
||||||
|
r = await call('/book/' + p.id + '/file', { headers: { range: 'bytes=-6' } }); assert.equal((await r.text()), '%%EOF\n');
|
||||||
|
assert.equal((await call('/book/' + p.id + '/file', { headers: { range: 'bytes=99999-' } })).status, 416);
|
||||||
|
r = await call('/book/' + e.id, { method: 'PATCH', body: JSON.stringify({ title: 'Better', author: 'Me' }) }); assert.equal((await r.json()).author, 'Me');
|
||||||
|
assert.equal((await call('/book/' + e.id + '/cover', { method: 'PUT', body: 'x', headers: { 'content-type': 'text/html' } })).status, 400, 'covers must be images');
|
||||||
|
r = await call('/book/' + e.id + '/cover', { method: 'PUT', body: Buffer.from('GIF89a'), headers: { 'content-type': 'image/gif' } }); assert.equal((await r.json()).cover, true);
|
||||||
|
r = await call('/book/' + e.id + '/cover'); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||||
|
const pos = (loc, t) => call('/book/' + e.id + '/pos', { method: 'POST', body: JSON.stringify({ loc, pct: 0.5, t }) }).then(x => x.json());
|
||||||
|
await pos('new', 100); assert.equal((await pos('old', 50)).loc, 'new', 'the newest position wins');
|
||||||
|
let l = await (await call('/list')).json(); assert.equal(l.find(b => b.id === e.id).loc, 'new'); assert.equal(l.find(b => b.id === e.id).pct, 0.5);
|
||||||
|
const note = (arr) => call('/book/' + e.id + '/notes', { method: 'POST', body: JSON.stringify(arr) }).then(x => x.json());
|
||||||
|
await note([{ id: 'n1', t: 10, type: 'hl', loc: 'epubcfi(/6/2)', text: 'hello', color: 'green' }]);
|
||||||
|
let ns = await note([{ id: 'n2', t: 11, type: 'note', note: 'think', loc: 'x' }, { id: 'n1', t: 5, type: 'hl', text: 'stale edit', loc: 'y' }, { id: '../bad', t: 1 }]);
|
||||||
|
assert.deepEqual(ns.map(n => n.id).sort(), ['n1', 'n2'], 'two devices add notes without losing each other'); assert.equal(ns.find(n => n.id === 'n1').text, 'hello', 'an older edit does not win');
|
||||||
|
ns = await note([{ id: 'n1', t: 20, del: true, loc: 'y' }]); assert.deepEqual(ns.map(n => n.id), ['n2'], 'deleted notes stay deleted');
|
||||||
|
assert.equal((await (await call('/book/' + e.id + '/notes')).json()).length, 1);
|
||||||
|
assert.equal((await call('/book/' + e.id, { method: 'DELETE' })).status, 200); assert.equal((await (await call('/list')).json()).length, 3);
|
||||||
|
assert.equal((await call('/book/' + e.id + '/file')).status, 404);
|
||||||
|
assert.equal((await call('/nope')).status, 404);
|
||||||
|
console.log('books test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close();
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// NE Hub calendars: several per person, colors, private by default, shared view-only or edit by the owner's choice, subscribe links.
|
||||||
|
// Calls the calendar module directly as different people (no server needed).
|
||||||
|
// Run: node tools/cal-test.js
|
||||||
|
const fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-cal-'));
|
||||||
|
const users = require('../users'), cal = require('../cal');
|
||||||
|
let failed = 0;
|
||||||
|
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||||
|
|
||||||
|
const ann = users.create({ name: 'Ann', role: 'admin' }), bob = users.create({ name: 'Bob', tabs: ['calendar'] }), cy = users.create({ name: 'Cy', tabs: ['calendar'] });
|
||||||
|
async function call(u, p, body, method) {
|
||||||
|
return users.runAs(u, () => new Promise(done => {
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const req = new EventEmitter(); req.url = p; req.method = method || (body ? 'POST' : 'GET'); req.headers = { host: 'hub.test' };
|
||||||
|
const res = { code: 0, writeHead(c) { this.code = c; }, end(t) { let j = {}; try { j = JSON.parse(t); } catch {} done({ status: this.code, json: j, text: t }); } };
|
||||||
|
cal.handle(req, res);
|
||||||
|
process.nextTick(() => { if (body) req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); });
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const F = (title, d = '2026-11-03') => ({ title, location: '', notes: '', allDay: true, start: d, end: d, tz: 'UTC', repeat: 'none' });
|
||||||
|
const win = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
|
||||||
|
const titles = async u => (await call(u, win)).json.events.map(e => e.title).sort().join('|');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let r = await call(ann, '/api/cal/list');
|
||||||
|
ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json);
|
||||||
|
|
||||||
|
// legacy events (no calendar field) belong to the first calendar
|
||||||
|
fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }]));
|
||||||
|
ok('older events stay in the first calendar', (await call(ann, win)).json.events.find(e => e.title === 'Old event').calId === 'local');
|
||||||
|
|
||||||
|
// create, recolor, rename
|
||||||
|
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
|
||||||
|
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2);
|
||||||
|
r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r);
|
||||||
|
r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r);
|
||||||
|
await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' });
|
||||||
|
const l = (await call(ann, '/api/cal/list')).json.find(c => c.id === fam); ok('rename and recolor stick', l.name === 'Family stuff' && l.color === '#8a5cf0', l);
|
||||||
|
|
||||||
|
// events go into the chosen calendar and carry its color
|
||||||
|
await call(ann, '/api/cal/event', { cal: fam, fields: F('Dinner') });
|
||||||
|
await call(ann, '/api/cal/event', { cal: 'local', fields: F('Work thing') });
|
||||||
|
const ev = (await call(ann, win)).json.events;
|
||||||
|
const dn = ev.find(e => e.title === 'Dinner'), wk = ev.find(e => e.title === 'Work thing');
|
||||||
|
ok('events use the color of their calendar', dn.color === '#8a5cf0' && dn.calId === fam && wk.color === '#4f7cff' && wk.calId === 'local', [dn, wk]);
|
||||||
|
ok('an event can be read back from its calendar', (await call(ann, `/api/cal/event?cal=${fam}&uid=${dn.uid}&tz=UTC`)).json.fields.title === 'Dinner');
|
||||||
|
ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400);
|
||||||
|
|
||||||
|
// private by default
|
||||||
|
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
|
||||||
|
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r);
|
||||||
|
r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r);
|
||||||
|
|
||||||
|
// sharing, with the owner's choice of view or edit per person
|
||||||
|
r = await call(ann, '/api/cal/people'); ok('the owner can see who it can be shared with (not themself)', r.json.map(p => p.name).sort().join() === 'Bob,Cy', r.json);
|
||||||
|
r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] });
|
||||||
|
ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2);
|
||||||
|
ok('a view-only person sees the events', (await titles(bob)) === 'Dinner');
|
||||||
|
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared);
|
||||||
|
ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl);
|
||||||
|
const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev);
|
||||||
|
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r);
|
||||||
|
r = await call(bob, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('or delete', r.status === 400, r);
|
||||||
|
ok('and still see exactly what they saw', (await titles(bob)) === 'Dinner');
|
||||||
|
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Cy adds') }); ok('an edit person can add', r.status === 200, r);
|
||||||
|
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, uid: dn.uid, fields: F('Dinner at 7') }); ok('and change events', r.status === 200);
|
||||||
|
ok('the owner sees those changes', (await titles(ann)).includes('Cy adds') && (await titles(ann)).includes('Dinner at 7'));
|
||||||
|
ok('Cy saw it in the owner\'s calendar, not in their own', (await call(cy, '/api/cal/list')).json.filter(c => !c.shared).length === 1);
|
||||||
|
r = await call(cy, '/api/cal/ncal/update', { id: fam, shares: [] }); ok("a person can't change the sharing of someone else's calendar", r.status === 400, r);
|
||||||
|
r = await call(cy, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('an edit person can delete events', r.status === 200 && !(await titles(ann)).includes('Dinner at 7'));
|
||||||
|
|
||||||
|
// everybody's own color for things shared with them / not theirs
|
||||||
|
await call(bob, '/api/cal/color', { id: `${ann.id}~${fam}`, color: '#2f9e6e' });
|
||||||
|
ok('a shared calendar can be given your own color', (await call(bob, win)).json.events[0].color === '#2f9e6e' && (await call(ann, win)).json.events.find(e => e.title === 'Cy adds').color === '#8a5cf0');
|
||||||
|
|
||||||
|
// stopping sharing
|
||||||
|
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
|
||||||
|
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
|
||||||
|
|
||||||
|
// subscribe links
|
||||||
|
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
|
||||||
|
const get = async url => { const res = { text: '', code: 0, writeHead(c) { this.code = c; }, end(t) { this.text = t; } }; cal.feed({ url: new URL(url).pathname }, res); return res; };
|
||||||
|
const u1 = await feedOf(ann, fam), u2 = await feedOf(ann, fam), u3 = await feedOf(ann, 'local');
|
||||||
|
ok('each calendar has its own stable subscribe link', u1 === u2 && u1 !== u3 && /^http:\/\/hub\.test\/cal\/feed\/[0-9a-f]{48}\.ics$/.test(u1), [u1, u3]);
|
||||||
|
const f1 = await get(u1), f3 = await get(u3);
|
||||||
|
ok('it shows only that calendar', f1.code === 200 && f1.text.includes('Cy adds') && !f1.text.includes('Work thing') && f3.text.includes('Work thing') && !f3.text.includes('Cy adds'));
|
||||||
|
const all = await get((await call(ann, '/api/cal/feed')).json.url);
|
||||||
|
ok('the person-wide link still shows all of their calendars', all.text.includes('Cy adds') && all.text.includes('Work thing') && all.text.includes('Old event'));
|
||||||
|
const u4 = await feedOf(ann, fam, true); const old = await get(u1);
|
||||||
|
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
|
||||||
|
|
||||||
|
// delete
|
||||||
|
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1);
|
||||||
|
ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === '');
|
||||||
|
r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r);
|
||||||
|
ok('older events survived all that', (await titles(ann)).includes('Old event'));
|
||||||
|
|
||||||
|
// link-calendar colors
|
||||||
|
fs.writeFileSync(path.join(users.dirOf(bob.id), 'calendars.json'), JSON.stringify([{ id: 'lnk', name: 'Holidays', url: 'https://example.com/x.ics', color: '#e5a00d' }]));
|
||||||
|
await call(bob, '/api/cal/color', { id: 'lnk', color: '#112233' });
|
||||||
|
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
|
||||||
|
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
|
||||||
|
|
||||||
|
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Free audiobooks: LibriVox search and book details, chapter audio is only ever sent to the recording hosts, each person's shelf and listening position.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-'));
|
||||||
|
const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = []; let port;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
|
||||||
|
const J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||||
|
if (p === '/api/feed/audiobooks/') {
|
||||||
|
const book = { id: '55', title: 'Pride & Prejudice', description: '<p>A novel</p>', url_librivox: 'https://librivox.org/pp', language: 'English', copyright_year: '1813', totaltimesecs: 600, authors: [{ first_name: 'Jane', last_name: 'Austen' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif` };
|
||||||
|
const full = { ...book, sections: [{ section_number: '1', title: 'Chapter 1', listen_url: `http://127.0.0.1:${port}/dl/1.mp3`, playtime: '300', readers: [{ display_name: 'Ann' }] }, { section_number: '2', title: 'Chapter 2', listen_url: 'https://evil.example.com/2.mp3', playtime: '300' }, { section_number: '3', title: 'Chapter 3', listen_url: `http://127.0.0.1:${port}/dl/3.mp3`, playtime: '300' }] };
|
||||||
|
if (u.searchParams.get('id') === '55') return J({ books: [u.searchParams.get('extended') ? full : book] });
|
||||||
|
if (u.searchParams.get('title') === 'prejudice' || u.searchParams.get('author') === 'austen') return J({ books: [book] });
|
||||||
|
return J({ error: 'Audiobooks could not be found' }, 404);
|
||||||
|
}
|
||||||
|
if (p === '/cover.gif') { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; process.env.LIBRIVOX_URL = 'http://127.0.0.1:' + port;
|
||||||
|
const lv = require('../librivox'), users = require('../users');
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['audio'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => lv.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/ab/free', call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o }), j = r => r.json();
|
||||||
|
try {
|
||||||
|
let r = await call('/search?q=prejudice'); let s = await j(r); assert.equal(s.items.length, 1, 'found by title and by author, listed once'); assert.equal(s.items[0].title, 'Pride & Prejudice'); assert.equal(s.items[0].author, 'Jane Austen'); assert.match(s.items[0].cover, /^\/api\/ab\/free\/cover\/55/);
|
||||||
|
assert.deepEqual((await j(await call('/search?q=zzz'))).items, [], 'nothing found is an empty list, not an error');
|
||||||
|
assert.ok((await j(await call('/search'))).items.length >= 0, 'no search words lists the catalogue');
|
||||||
|
r = await call('/book/55'); const b = await j(r); assert.equal(b.chapters.length, 2, 'chapters on other sites are dropped'); assert.ok(!JSON.stringify(b).includes('127.0.0.1:' + port + '/dl'), 'audio addresses are not sent to the browser'); assert.equal(b.chapters[1].title, 'Chapter 3'); assert.equal(b.description, 'A novel');
|
||||||
|
r = await call('/audio/55/0'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), `http://127.0.0.1:${port}/dl/1.mp3`);
|
||||||
|
assert.equal((await call('/audio/55/9')).status, 404); assert.equal((await call('/book/abc')).status, 404);
|
||||||
|
r = await call('/cover/55'); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||||
|
assert.equal((await call('/cover/55?u=' + encodeURIComponent('http://169.254.169.254/x'))).status, 200, 'an address off the allowed sites is ignored (the book\'s own cover is used)');
|
||||||
|
assert.ok(!seen.some(x => x.includes('169.254')));
|
||||||
|
assert.deepEqual(await j(await call('/shelf')), []); await call('/shelf', { method: 'POST', body: JSON.stringify({ id: '55' }) });
|
||||||
|
s = await j(await call('/shelf')); assert.equal(s[0].title, 'Pride & Prejudice'); assert.deepEqual(await j(await call('/shelf', { headers: { 'x-user': 'b' } })), [], "each person's shelf is their own");
|
||||||
|
const post = o => call('/progress', { method: 'POST', body: JSON.stringify(o) }).then(j);
|
||||||
|
await post({ 'lv:55': { pos: 400, dur: 600, t: 10 } }); assert.equal((await post({ 'lv:55': { pos: 5, dur: 600, t: 3 } }))['lv:55'].pos, 400, 'the newest position wins');
|
||||||
|
assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {});
|
||||||
|
await call('/shelf/55', { method: 'DELETE' }); assert.deepEqual(await j(await call('/shelf')), []);
|
||||||
|
console.log('librivox test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,6 +55,11 @@ const free = () => 21000 + Math.floor(Math.random() * 20000);
|
|||||||
ok('and is stored encrypted', !fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.json'), 'utf8').includes(PASS));
|
ok('and is stored encrypted', !fs.readFileSync(path.join(dir, 'users', 'unate', 'mail-accounts.json'), 'utf8').includes(PASS));
|
||||||
r = await A('/api/mail/status'); ok('status lists the account with its unread count (2 unread in the inbox)', r.json.connected && r.json.accounts.length === 1 && r.json.accounts[0].unread === 2 && r.json.unread === 2, r.json);
|
r = await A('/api/mail/status'); ok('status lists the account with its unread count (2 unread in the inbox)', r.json.connected && r.json.accounts.length === 1 && r.json.accounts[0].unread === 2 && r.json.unread === 2, r.json);
|
||||||
|
|
||||||
|
r = await A('/api/mail/status?light=1'); ok('the light status lists the accounts without asking the mail servers (what lets the Mail tab draw at once)', r.json.connected && r.json.accounts.length === 1 && !r.json.accounts[0].error, r.json);
|
||||||
|
{ const first = (await A('/api/mail/status')).json.unread; // unread counts are cached for 30 s: change the real mailbox behind the hub's back and the number stays
|
||||||
|
const c = new ImapFlow({ host: '127.0.0.1', port: imapPort, secure: false, doSTARTTLS: false, auth: { user: ME, pass: PASS }, logger: false }); await c.connect(); const lock = await c.getMailboxLock('INBOX');
|
||||||
|
try { await c.messageFlagsAdd('1', ['\\Seen']); const again = (await A('/api/mail/status')).json.unread; ok('the full status serves a cached unread count for up to 30 s (so opening Mail never waits on the mail servers)', again === first, [first, again]); await c.messageFlagsRemove('1', ['\\Seen']); } finally { lock.release(); await c.logout(); } }
|
||||||
|
|
||||||
// ================= reading =================
|
// ================= reading =================
|
||||||
r = await A('/api/mail/folders?acct=' + gm.id); const names = r.json.map(f => f.name);
|
r = await A('/api/mail/folders?acct=' + gm.id); const names = r.json.map(f => f.name);
|
||||||
ok('folders: Inbox, Sent, Archive (Gmail "All Mail"), Junk, Deleted, then your own; the [Gmail] container is hidden', names.join() === 'Inbox,Sent,Archive,Junk,Deleted,Receipts' && r.json[0].unread === 2 && r.json[0].id.startsWith(gm.id + '~'), r.json);
|
ok('folders: Inbox, Sent, Archive (Gmail "All Mail"), Junk, Deleted, then your own; the [Gmail] container is hidden', names.join() === 'Inbox,Sent,Archive,Junk,Deleted,Receipts' && r.json[0].unread === 2 && r.json[0].id.startsWith(gm.id + '~'), r.json);
|
||||||
@@ -75,6 +80,9 @@ const free = () => 21000 + Math.floor(Math.random() * 20000);
|
|||||||
|
|
||||||
// ================= flags, moving, deleting =================
|
// ================= flags, moving, deleting =================
|
||||||
await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark as read', r.json.items[0].read === true, r.json.items[0]);
|
await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark as read', r.json.items[0].read === true, r.json.items[0]);
|
||||||
|
r = await A('/api/mail/folders?acct=' + gm.id); const unreadBefore = r.json[0].unread;
|
||||||
|
await A('/api/mail/update', { id: helloId, read: false }); r = await A('/api/mail/folders?acct=' + gm.id); ok('the folder unread count follows a change at once (not the 30 s copy)', r.json[0].unread === unreadBefore + 1, r.json[0]);
|
||||||
|
await A('/api/mail/update', { id: helloId, read: true }); r = await A('/api/mail/folders?acct=' + gm.id);
|
||||||
await A('/api/mail/update', { id: helloId, read: false, flagged: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark unread and flag', r.json.items[0].read === false && r.json.items[0].flagged === true, r.json.items[0]);
|
await A('/api/mail/update', { id: helloId, read: false, flagged: true }); r = await A('/api/mail/messages?folder=' + gm.id + '~inbox'); ok('mark unread and flag', r.json.items[0].read === false && r.json.items[0].flagged === true, r.json.items[0]);
|
||||||
r = await A('/api/mail/update', { id: helloId }); ok('an update with nothing to change is refused', r.status === 400, r.status);
|
r = await A('/api/mail/update', { id: helloId }); ok('an update with nothing to change is refused', r.status === 400, r.status);
|
||||||
const archBefore = (await peek('[Gmail]/All Mail')).messages;
|
const archBefore = (await peek('[Gmail]/All Mail')).messages;
|
||||||
|
|||||||
+3
-1
@@ -42,6 +42,7 @@ const SECRET = 'mail-test-secret-'.repeat(3);
|
|||||||
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
|
const tok = (rq.headers.authorization || '').replace('Bearer ', '');
|
||||||
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
|
if (!ms.goodTokens.has(tok)) return out(rs, 401, { error: { code: 'InvalidAuthenticationToken', message: 'expired' } });
|
||||||
const g = p.slice(5);
|
const g = p.slice(5);
|
||||||
|
if (g === '/$batch' && rq.method === 'POST') { ms.batches = (ms.batches || 0) + 1; const reqs = JSON.parse(b).requests, self = `http://127.0.0.1:${server.address().port}/v1.0`; const responses = []; for (const q of reqs) { const r = await fetch(self + q.url, { method: q.method, headers: { authorization: rq.headers.authorization } }); responses.push({ id: q.id, status: r.status, body: await r.json().catch(() => ({})) }); } return out(rs, 200, { responses }); }
|
||||||
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
|
if (g === '/me') return out(rs, 200, { displayName: 'Nate', mail: 'nate@outlook.com' });
|
||||||
let m;
|
let m;
|
||||||
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
|
if ((m = /^\/me\/mailFolders\/(\w+)$/.exec(g))) { const f = { inbox: folder('inbox', 'Inbox', 3, 10), drafts: folder('drafts', 'Drafts', 0, 1), sentitems: folder('sentitems', 'Sent Items', 0, 4), archive: folder('archive', 'Archive', 0, 0), junkemail: folder('junkemail', 'Junk Email', 0, 0), deleteditems: folder('deleteditems', 'Deleted Items', 0, 2) }[m[1]]; return f ? out(rs, 200, f) : out(rs, 404, { error: { message: 'no folder' } }); }
|
||||||
@@ -101,7 +102,8 @@ const SECRET = 'mail-test-secret-'.repeat(3);
|
|||||||
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
|
r = await call('uann', '/api/mail/messages'); ok("and cannot read it", r.status === 409, r.status);
|
||||||
|
|
||||||
// ================= reading =================
|
// ================= reading =================
|
||||||
r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
|
ms.batches = 0; r = await call('unate', '/api/mail/folders'); const names = (r.json || []).map(f => f.name);
|
||||||
|
ok('the folder list costs one batched call to Microsoft, not seven', ms.batches === 1, ms.batches);
|
||||||
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
|
ok('folders: the usual six first with unread counts, then your own; internal ones hidden', names.slice(0, 6).join() === 'Inbox,Drafts,Sent,Archive,Junk,Deleted' && names.includes('Receipts') && !names.includes('Outbox') && r.json[0].unread === 3 && names.filter(n => n === 'Inbox').length === 1, r.json);
|
||||||
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
|
r = await call('unate', '/api/mail/messages?folder=inbox'); ok('the inbox lists messages, newest first, with who, read state and attachment flag', r.status === 200 && r.json.items.length === 2 && r.json.items[0].from.address === 'ann@example.com' && r.json.items[0].read === false && r.json.items[0].attach === true && r.json.items[1].flagged === true, r.json);
|
||||||
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
|
ok('and says there may be more', r.json.more === true && r.json.next === 2, r.json);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Builds tiny EPUB and PDF files for tools/books-test.js and manual checks: python3 tools/mkbooks.py <outdir>
|
||||||
|
import sys, zipfile, os
|
||||||
|
out = sys.argv[1]; os.makedirs(out, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(os.path.join(out, 'test.epub'), 'w') as z:
|
||||||
|
z.writestr('mimetype', 'application/epub+zip', compress_type=zipfile.ZIP_STORED)
|
||||||
|
z.writestr('META-INF/container.xml', '<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>')
|
||||||
|
z.writestr('OEBPS/content.opf', '<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"><metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:identifier id="id">x1</dc:identifier><dc:title>Rabbit Tales</dc:title><dc:creator>A. Writer</dc:creator><dc:language>en</dc:language></metadata><manifest><item id="c1" href="c1.xhtml" media-type="application/xhtml+xml"/><item id="c2" href="c2.xhtml" media-type="application/xhtml+xml"/><item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/></manifest><spine><itemref idref="c1"/><itemref idref="c2"/></spine></package>')
|
||||||
|
z.writestr('OEBPS/nav.xhtml', '<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops"><body><nav epub:type="toc"><ol><li><a href="c1.xhtml">Chapter One</a></li><li><a href="c2.xhtml">Chapter Two</a></li></ol></nav></body></html>')
|
||||||
|
para = ' '.join(['The quick brown rabbit jumps over the lazy dog and keeps hopping through the green meadow.'] * 40)
|
||||||
|
for i, n in ((1, 'One'), (2, 'Two')):
|
||||||
|
z.writestr(f'OEBPS/c{i}.xhtml', f'<?xml version="1.0"?><html xmlns="http://www.w3.org/1999/xhtml"><head><title>{n}</title></head><body><h1>Chapter {n}</h1>' + ''.join(f'<p>{para} Marker{i}x{k}.</p>' for k in range(8)) + '</body></html>')
|
||||||
|
# minimal 3-page PDF
|
||||||
|
objs = []
|
||||||
|
def add(s): objs.append(s); return len(objs)
|
||||||
|
add('<< /Type /Catalog /Pages 2 0 R >>'); add('<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R] /Count 3 >>')
|
||||||
|
for i in range(3):
|
||||||
|
pid = add(f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {4+i*2} 0 R /Resources << /Font << /F1 9 0 R >> >> >>')
|
||||||
|
text = f'BT /F1 24 Tf 72 700 Td (Page {i+1} of the rabbit manual) Tj 0 -40 Td (Carrots are great on page {i+1}.) Tj ET'
|
||||||
|
add(f'<< /Length {len(text)} >>\nstream\n{text}\nendstream')
|
||||||
|
add('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')
|
||||||
|
data = '%PDF-1.4\n'; offs = []
|
||||||
|
for i, o in enumerate(objs): offs.append(len(data)); data += f'{i+1} 0 obj\n{o}\nendobj\n'
|
||||||
|
x = len(data); data += f'xref\n0 {len(objs)+1}\n0000000000 65535 f \n' + ''.join(f'{o:010d} 00000 n \n' for o in offs) + f'trailer\n<< /Size {len(objs)+1} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n'
|
||||||
|
open(os.path.join(out, 'test.pdf'), 'w').write(data)
|
||||||
|
|
||||||
|
# minimal MOBI 6 (uncompressed text, no images), enough to open in a reader. Set DRM=1 in the environment to mark it encrypted.
|
||||||
|
import struct
|
||||||
|
def mkmobi(path, drm=False):
|
||||||
|
html = '<html><head><title>Mobi Rabbits</title></head><body><h1>Chapter One</h1>' + ''.join('<p>The rabbit hops through the clover field number %d, nibbling as it goes.</p>' % i for i in range(30)) + '<mbp:pagebreak/><h1>Chapter Two</h1>' + ''.join('<p>Carrots are the second chapter topic, paragraph %d.</p>' % i for i in range(30)) + '</body></html>'
|
||||||
|
text = html.encode('utf8'); recs = [text[i:i+4096] for i in range(0, len(text), 4096)]
|
||||||
|
title = b'Mobi Rabbits'; hdrlen = 232
|
||||||
|
exth = b''
|
||||||
|
def item(t, v): return struct.pack('>II', t, 8 + len(v)) + v
|
||||||
|
body = item(100, b'M. Author') + item(503, title); exth = b'EXTH' + struct.pack('>II', 12 + len(body), 2) + body
|
||||||
|
exth += b'\0' * (-len(exth) % 4)
|
||||||
|
r0 = struct.pack('>HHIHHHH', 1, 0, len(text), len(recs), 4096, 2 if drm else 0, 0)
|
||||||
|
mobi = bytearray(hdrlen)
|
||||||
|
mobi[0:4] = b'MOBI'; struct.pack_into('>I', mobi, 4, hdrlen); struct.pack_into('>I', mobi, 8, 2); struct.pack_into('>I', mobi, 12, 65001); struct.pack_into('>I', mobi, 16, 1234); struct.pack_into('>I', mobi, 20, 6)
|
||||||
|
for off in range(24, 64, 4): struct.pack_into('>I', mobi, off, 0xFFFFFFFF)
|
||||||
|
name_off = 16 + hdrlen + len(exth)
|
||||||
|
struct.pack_into('>II', mobi, 68 - 16, name_off, len(title)); struct.pack_into('>I', mobi, 72 - 16 + 4, 9)
|
||||||
|
struct.pack_into('>I', mobi, 0x5C - 16 + 0, 0)
|
||||||
|
struct.pack_into('>I', mobi, 0x6C - 16, 0xFFFFFFFF); struct.pack_into('>I', mobi, 0x70 - 16, 0xFFFFFFFF)
|
||||||
|
struct.pack_into('>I', mobi, 0x80 - 16, 0x40)
|
||||||
|
for off in (0x90 - 16, 0x94 - 16, 0x98 - 16, 0xA0 - 16): struct.pack_into('>I', mobi, off, 0xFFFFFFFF)
|
||||||
|
struct.pack_into('>I', mobi, 0xE0 - 16, 0xFFFFFFFF); struct.pack_into('>H', mobi, 0xF2 - 16, 0)
|
||||||
|
rec0 = r0 + bytes(mobi) + exth + title + b'\0\0'
|
||||||
|
allrecs = [rec0] + recs; n = len(allrecs); off = 78 + 8 * n + 2; offs = []
|
||||||
|
for r in allrecs: offs.append(off); off += len(r)
|
||||||
|
hdr = b'MobiRabbits'.ljust(32, b'\0') + struct.pack('>HHIIIIII', 0, 0, 0, 0, 0, 0, 0, 0) + b'BOOKMOBI' + struct.pack('>IIH', 1234, 0, n)
|
||||||
|
hdr = hdr[:78 - 0] if len(hdr) >= 78 else hdr.ljust(78, b'\0')
|
||||||
|
lst = b''.join(struct.pack('>IBBBB', o, 0, 0, 0, i & 255) for i, o in enumerate(offs))
|
||||||
|
open(path, 'wb').write(hdr + lst + b'\0\0' + b''.join(allrecs))
|
||||||
|
mkmobi(os.path.join(out, 'test.mobi')); mkmobi(os.path.join(out, 'drm.mobi'), True)
|
||||||
+40
-5
@@ -16,26 +16,27 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
// ---- fake plex.tv
|
// ---- fake plex.tv
|
||||||
const tv = { switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
const tv = { adminPin: false, switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
||||||
const plexTv = await listen((rq, rs) => {
|
const plexTv = await listen((rq, rs) => {
|
||||||
const u = new URL(rq.url, 'http://x'); let m;
|
const u = new URL(rq.url, 'http://x'); let m;
|
||||||
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
||||||
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
||||||
if (u.pathname === '/api/home/users' && rq.method === 'GET') {
|
if (u.pathname === '/api/home/users' && rq.method === 'GET') {
|
||||||
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
||||||
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: false, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: tv.adminPin, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
||||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="0" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="' + (tv.adminPin ? 1 : 0) + '" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
||||||
}
|
}
|
||||||
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
|
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
|
||||||
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
|
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
|
||||||
|
if (m[1] === '1' && u.searchParams.get('pin') !== '9999') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||||
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : 'GUESTTOKEN'}"/>`);
|
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : m[1] === '1' ? 'ACCOUNT' : 'GUESTTOKEN'}"/>`);
|
||||||
}
|
}
|
||||||
if (u.pathname === '/api/v2/resources') {
|
if (u.pathname === '/api/v2/resources') {
|
||||||
tv.resourceCalls++;
|
tv.resourceCalls++;
|
||||||
const who = rq.headers['x-plex-token'], other = { KIDTOKEN: 'KIDSRV', GUESTTOKEN: 'GUESTSRV' }[who];
|
const who = rq.headers['x-plex-token'], other = { KIDTOKEN: 'KIDSRV', GUESTTOKEN: 'GUESTSRV' }[who];
|
||||||
if (who !== tv.accountToken && !other) return jsonOut(rs, 401, { error: 'bad token' });
|
if (who !== tv.accountToken && !other) return jsonOut(rs, 401, { error: 'bad token' });
|
||||||
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: true, accessToken: other || tv.serverToken,
|
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: who === tv.accountToken, accessToken: other || tv.serverToken,
|
||||||
connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]);
|
connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]);
|
||||||
}
|
}
|
||||||
rs.writeHead(404); rs.end();
|
rs.writeHead(404); rs.end();
|
||||||
@@ -45,6 +46,8 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
const makePlex = () => listen((rq, rs) => {
|
const makePlex = () => listen((rq, rs) => {
|
||||||
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
|
if (rq.url === '/identity') return jsonOut(rs, 200, { MediaContainer: { machineIdentifier: 'sid1' } });
|
||||||
plexReqs.push(rq.method + ' ' + rq.url);
|
plexReqs.push(rq.method + ' ' + rq.url);
|
||||||
|
if (rq.url.startsWith('/video/:/transcode/universal/start.m3u8')) { rs.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' }); return rs.end('#EXTM3U\n#EXT-X-MAP:URI="/video/:/transcode/universal/session/S/init.mp4"\n#EXTINF:4,\nsession/S/seg0.ts\n#EXTINF:4,\n/video/:/transcode/universal/session/S/seg1.ts\n#EXT-X-ENDLIST\n'); }
|
||||||
|
if (rq.url.startsWith('/library/parts/1/2/file.mp4')) { const rg = rq.headers.range; if (rg) { rs.writeHead(206, { 'content-type': 'video/mp4', 'content-range': 'bytes 0-9/100', 'accept-ranges': 'bytes', 'content-length': '10' }); return rs.end('0123456789'); } rs.writeHead(200, { 'content-type': 'video/mp4', 'accept-ranges': 'bytes' }); return rs.end('x'.repeat(100)); }
|
||||||
seen.push(rq.headers['x-plex-token']);
|
seen.push(rq.headers['x-plex-token']);
|
||||||
if (![tv.serverToken, 'KIDSRV', 'GUESTSRV'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
if (![tv.serverToken, 'KIDSRV', 'GUESTSRV'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
||||||
jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } });
|
jsonOut(rs, 200, { MediaContainer: { Directory: [{ key: '1', type: 'artist', title: 'Music' }] } });
|
||||||
@@ -92,6 +95,18 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']];
|
const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']];
|
||||||
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
|
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
|
||||||
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
|
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
|
||||||
|
// ---- editing a title's details (what Plex's Edit dialog has) and choosing its poster
|
||||||
|
r = await call('/api/plex/status'); ok('the owner is told they can edit details', r.json.canEdit === true, r.json);
|
||||||
|
const E = '/plex/library/sections/1/all?type=1&id=5&title.value=' + encodeURIComponent('New & Title') + '&title.locked=1&summary.value=Hi&summary.locked=1&genre%5B0%5D.tag.tag=Drama&genre%5B1%5D.tag.tag=Comedy&genre.locked=1&originallyAvailableAt.value=2020-01-02&originallyAvailableAt.locked=1';
|
||||||
|
plexReqs.length = 0; r = await call(E, undefined, 'PUT');
|
||||||
|
ok('the owner can save a title\'s details, locked so a refresh keeps them', r.status === 200 && plexReqs.length === 1 && plexReqs[0].startsWith('PUT /library/sections/1/all?type=1&id=5&title.value=New') && plexReqs[0].includes('title.locked=1'), [r.status, plexReqs]);
|
||||||
|
plexReqs.length = 0; r = await call('/plex/library/metadata/5/poster?url=' + encodeURIComponent('/library/metadata/5/thumb/123'), undefined, 'PUT'); ok('and pick one of its posters', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]);
|
||||||
|
plexReqs.length = 0; r = await call('/plex/library/metadata/5/posters?url=' + encodeURIComponent('https://img.example/p.jpg'), undefined, 'POST'); ok('or add one from an image link', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]);
|
||||||
|
plexReqs.length = 0;
|
||||||
|
const badEdit = [['PUT', '/plex/library/sections/1/all?type=1&id=5&mediaType=9&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=1&id=5&deleteAll=1'], ['PUT', '/plex/library/sections/1/all?type=1&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=9&id=5'],
|
||||||
|
['DELETE', '/plex/library/sections/1/all?type=1&id=5&title.value=x'], ['PUT', '/plex/library/metadata/5/poster?url=file:///etc/passwd'], ['PUT', '/plex/library/metadata/5/poster?url=http://10.0.0.1/x.jpg'], ['PUT', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg&extra=1'], ['POST', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg'], ['PUT', '/plex/library/metadata/5/posters?url=https://a.example/x.jpg']];
|
||||||
|
const bst = []; for (const [m, p] of badEdit) bst.push((await call(p, undefined, m)).status);
|
||||||
|
ok('anything else in an edit request is refused before it reaches Plex', bst.every(x => x >= 400) && !plexReqs.length, [bst, plexReqs]);
|
||||||
|
|
||||||
// ================= Plex Home: using Plex as someone else in the Home =================
|
// ================= Plex Home: using Plex as someone else in the Home =================
|
||||||
r = await call('/api/plex/users'); ok('the Plex Home is listed (read from plex.tv\'s XML), with who is using NE Hub now', r.status === 200 && r.json.users.length === 3 && r.json.users.find(x => x.id === '1').current && r.json.users.find(x => x.id === '2').protected && r.json.users.find(x => x.id === '2').restricted && r.json.users.find(x => x.id === '1').admin, r.json);
|
r = await call('/api/plex/users'); ok('the Plex Home is listed (read from plex.tv\'s XML), with who is using NE Hub now', r.status === 200 && r.json.users.length === 3 && r.json.users.find(x => x.id === '1').current && r.json.users.find(x => x.id === '2').protected && r.json.users.find(x => x.id === '2').restricted && r.json.users.find(x => x.id === '1').admin, r.json);
|
||||||
@@ -103,6 +118,7 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
seen.length = 0; r = await call('/api/plex/switch', { id: '2', pin: '1234' }); ok('with the right PIN the hub switches to that person', r.status === 200 && r.json.user.title === 'Kid', r);
|
seen.length = 0; r = await call('/api/plex/switch', { id: '2', pin: '1234' }); ok('with the right PIN the hub switches to that person', r.status === 200 && r.json.user.title === 'Kid', r);
|
||||||
r = await call('/api/plex/status'); ok('status says who is using Plex now', r.json.user && r.json.user.title === 'Kid' && r.json.signedIn && r.json.server === 'E-va', r.json);
|
r = await call('/api/plex/status'); ok('status says who is using Plex now', r.json.user && r.json.user.title === 'Kid' && r.json.signedIn && r.json.server === 'E-va', r.json);
|
||||||
r = await call('/plex/library/sections'); ok('Plex requests now use that person\'s own server token', r.status === 200 && seen[seen.length - 1] === 'KIDSRV', [r.status, seen]);
|
r = await call('/plex/library/sections'); ok('Plex requests now use that person\'s own server token', r.status === 200 && seen[seen.length - 1] === 'KIDSRV', [r.status, seen]);
|
||||||
|
plexReqs.length = 0; r = await call('/api/plex/status'); const ke = await call(E, undefined, 'PUT'); ok('a Home member who is not the owner cannot edit (no Edit button, and the hub refuses)', r.json.canEdit === false && ke.status >= 400 && !plexReqs.length, [r.json, ke.status, plexReqs]);
|
||||||
s = savedPlex(); ok('the owner\'s sign-in is kept (to switch back, and to list the Home) and the new one is saved', s && s.mainToken === 'ACCOUNT' && s.token === 'KIDTOKEN' && s.stk === 'KIDSRV' && s.user.title === 'Kid', s);
|
s = savedPlex(); ok('the owner\'s sign-in is kept (to switch back, and to list the Home) and the new one is saved', s && s.mainToken === 'ACCOUNT' && s.token === 'KIDTOKEN' && s.stk === 'KIDSRV' && s.user.title === 'Kid', s);
|
||||||
r = await call('/api/plex/users'); ok('the list still works as the owner and marks the new current person', r.json.users.find(x => x.id === '2').current && !r.json.users.find(x => x.id === '1').current, r.json);
|
r = await call('/api/plex/users'); ok('the list still works as the owner and marks the new current person', r.json.users.find(x => x.id === '2').current && !r.json.users.find(x => x.id === '1').current, r.json);
|
||||||
r = await call('/api/plex/switch', { id: '3' }); ok('switching straight to another person works (no PIN needed for them)', r.status === 200 && r.json.user.title === 'Guest', r);
|
r = await call('/api/plex/switch', { id: '3' }); ok('switching straight to another person works (no PIN needed for them)', r.status === 200 && r.json.user.title === 'Guest', r);
|
||||||
@@ -110,12 +126,31 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
|||||||
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
|
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
|
||||||
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
|
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
|
||||||
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
|
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
|
||||||
|
// the owner has a PIN in Plex: the hub asks for it too, plex.tv checks it, and guessing is slowed down
|
||||||
|
tv.adminPin = true; await call('/api/plex/switch', { id: '3' });
|
||||||
|
r = await call('/api/plex/users'); ok('an owner with a PIN is marked protected in the list', r.json.users.find(x => x.id === '1').protected === true, r.json);
|
||||||
|
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); ok('going back to the owner asks for the PIN', r.status === 403 && /PIN/.test(r.json.error) && !tv.switchCalls.length, [r, tv.switchCalls]);
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '1111' }); ok('a wrong owner PIN is refused by plex.tv and nothing changes', r.status === 403 && (await call('/api/plex/status')).json.user.title === 'Guest', r);
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('the right owner PIN switches back, and the wrong-guess count starts over', r.status === 200 && r.json.user.title === 'Nate', r);
|
||||||
|
await call('/api/plex/switch', { id: '3' }); tv.switchCalls.length = 0;
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '1111' });
|
||||||
|
for (let i = 0; i < 4; i++) r = await call("/api/plex/switch", { id: "1", pin: "2222" });
|
||||||
|
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('after five wrong guesses even the right PIN waits', r.status === 429 && /Try again/.test(r.json.error), r);
|
||||||
|
ok('and plex.tv is not asked while waiting', tv.switchCalls.length === 5, tv.switchCalls);
|
||||||
|
tv.adminPin = false;
|
||||||
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||||
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||||
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
|
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
|
||||||
r = await call('/plex/library/sections'); ok('AFTER A RESTART: their Plex pages load', r.status === 200, r.status);
|
r = await call('/plex/library/sections'); ok('AFTER A RESTART: their Plex pages load', r.status === 200, r.status);
|
||||||
await call('/api/plex/switch', { id: '1' });
|
await call('/api/plex/switch', { id: '1' });
|
||||||
|
|
||||||
|
// ================= Movies & TV: the pass-through carries video =================
|
||||||
|
r = await fetch(base + '/plex/library/parts/1/2/file.mp4', { headers: { cookie, range: 'bytes=0-9' } }); const rb = await r.text();
|
||||||
|
ok('a video file is streamed with Range, so the browser can seek (206 and the range come back)', r.status === 206 && rb === '0123456789' && r.headers.get('content-range') === 'bytes 0-9/100' && r.headers.get('accept-ranges') === 'bytes', [r.status, r.headers.get('content-range')]);
|
||||||
|
r = await fetch(base + '/plex/video/:/transcode/universal/start.m3u8?path=%2Flibrary%2Fmetadata%2F5&protocol=hls', { headers: { cookie } }); const pl = await r.text();
|
||||||
|
ok('a converted-video playlist keeps its addresses under /plex (relative ones untouched, absolute ones prefixed)', r.status === 200 && /mpegurl/.test(r.headers.get('content-type')) && pl.includes('URI="/plex/video/:/transcode/universal/session/S/init.mp4"') && pl.includes('\nsession/S/seg0.ts\n') && pl.includes('\n/plex/video/:/transcode/universal/session/S/seg1.ts\n'), pl);
|
||||||
|
r = await fetch(base + '/plex/:/scrobble?key=5&identifier=com.plexapp.plugins.library', { headers: { cookie } }); ok('marking something watched (a GET) is allowed through', r.status === 200, r.status);
|
||||||
|
|
||||||
// ================= repairing itself =================
|
// ================= repairing itself =================
|
||||||
tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token
|
tv.serverToken = 'SRV2'; const calls1 = tv.resourceCalls; // Plex rotates the server token
|
||||||
r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);
|
r = await call('/plex/library/sections'); ok('a changed server token is repaired automatically (the page still loads)', r.status === 200, r.status);
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Podcast proxy: only the allowed Podgrab calls get through, the Podgrab password is added server-side, audio ranges and redirects work.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-pod-'));
|
||||||
|
const seen = [];
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
let b = ''; req.on('data', c => b += c); req.on('end', () => {
|
||||||
|
seen.push({ m: req.method, u: req.url, auth: req.headers.authorization, range: req.headers.range, b });
|
||||||
|
if (req.url === '/podcasts' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify([{ ID: 'p1', Title: 'Show', URL: 'https://a.test/feed' }, { ID: 'p9', Title: 'Other', URL: 'https://b.test/feed' }])); }
|
||||||
|
if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); }
|
||||||
|
if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); }
|
||||||
|
if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); }
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
|
||||||
|
});
|
||||||
|
}).listen(0, async () => {
|
||||||
|
process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret';
|
||||||
|
const podcast = require('../podcast'), users = require('../users');
|
||||||
|
const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port, get = (p, o = {}) => fetch(base + '/api/podcast' + p, { redirect: 'manual', ...o });
|
||||||
|
try {
|
||||||
|
let r = await get('/podcasts'); assert.equal(r.status, 200); assert.deepEqual((await r.json()).map(x => x.ID), ['p1', 'p9'], 'the admin starts with everything already in Podgrab');
|
||||||
|
assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server');
|
||||||
|
r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3');
|
||||||
|
r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3');
|
||||||
|
const other = { 'x-user': 'other' };
|
||||||
|
r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list');
|
||||||
|
r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable');
|
||||||
|
r = await get('/podcasts', { method: 'POST', headers: other, body: '{"url":"https://a.test/feed"}' }); assert.equal(r.status, 200); // Podgrab already has it (409): found by its address and put on their list
|
||||||
|
assert.deepEqual((await (await get('/podcasts', { headers: other })).json()).map(x => x.ID), ['p1']);
|
||||||
|
r = await get('/podcasts', { method: 'POST', body: '{"url":"https://x.test/feed"}' }); assert.equal(r.status, 200); assert.equal((await r.json()).ID, 'p2');
|
||||||
|
assert.deepEqual((await (await get('/podcasts')).json()).map(x => x.ID), ['p1', 'p9'], 'the other list did not change (fake Podgrab lists only its own two)');
|
||||||
|
let dels = seen.filter(x => x.m === 'DELETE').length;
|
||||||
|
r = await get('/podcasts/p1', { method: 'DELETE', headers: other }); assert.equal((await r.json()).removed, false, 'the admin still follows p1, so Podgrab keeps it');
|
||||||
|
assert.equal(seen.filter(x => x.m === 'DELETE').length, dels);
|
||||||
|
r = await get('/podcasts/p9', { method: 'DELETE' }); assert.equal((await r.json()).removed, true, 'nobody else follows p9, so Podgrab drops it');
|
||||||
|
assert.equal(seen.filter(x => x.m === 'DELETE').length, dels + 1);
|
||||||
|
const post = b => get('/progress', { method: 'POST', body: JSON.stringify(b) }).then(x => x.json());
|
||||||
|
await post({ e1: { pos: 90, dur: 600, t: 10 } }); const pr = await post({ e1: { pos: 5, dur: 600, t: 3 }, e2: { pos: 7, dur: 300, t: 4 } });
|
||||||
|
assert.equal(pr.e1.pos, 90, 'the newest position wins'); assert.equal((await (await get('/progress')).json()).e2.pos, 7);
|
||||||
|
const n = seen.length;
|
||||||
|
for (const [m, p] of [['GET', '/settings'], ['POST', '/settings'], ['GET', '/opml'], ['DELETE', '/tags/1'], ['GET', '/backups'], ['GET', '/podcasts/../settings'], ['DELETE', '/podcastitems/e1']]) {
|
||||||
|
r = await get(p, { method: m }); assert.equal(r.status, 404, m + ' ' + p);
|
||||||
|
}
|
||||||
|
assert.equal(seen.length, n, 'blocked calls never reach Podgrab');
|
||||||
|
console.log('podcast ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Internet radio: directory search, saved stations per person, the stream passed on with its song titles taken out, The Journey FM's now-playing feed, and no private addresses.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-radio-'));
|
||||||
|
process.env.RADIO_ALLOW_PRIVATE = '1'; // the fake servers below are on this machine
|
||||||
|
const AUDIO = Buffer.alloc(4096, 0x55), META = Buffer.from("StreamTitle='Great Song - Some Band';"); let port;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname, J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
|
||||||
|
if (p === '/json/stations/search') return J([
|
||||||
|
{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, tags: 'christian,worship,pop,extra,more', country: 'US', state: 'Ohio', hls: 0, homepage: 'https://good.fm', favicon: `http://127.0.0.1:${port}/logo.png` },
|
||||||
|
{ stationuuid: 'bbbbbbbb-2222', name: 'HLS only', url_resolved: `http://127.0.0.1:${port}/x.m3u8`, codec: 'AAC', hls: 1 }, { stationuuid: 'cccccccc-3333', name: 'Odd codec', url_resolved: 'http://a/b', codec: 'FLAC', hls: 0 }, { stationuuid: 'dddddddd-4444', name: ' ', url_resolved: 'http://a/b', codec: 'MP3', hls: 0 }]);
|
||||||
|
if (p === '/json/stations/byuuid/aaaaaaaa-1111') return J([{ stationuuid: 'aaaaaaaa-1111', name: 'Good FM', url_resolved: `http://127.0.0.1:${port}/stream`, codec: 'MP3', bitrate: 128, hls: 0 }]);
|
||||||
|
if (p === '/json/stations/byuuid/eeeeeeee-5555') return J([{ stationuuid: 'eeeeeeee-5555', name: 'Playlist FM', url_resolved: `http://127.0.0.1:${port}/pls`, codec: 'MP3', hls: 0 }]);
|
||||||
|
if (p === '/stream') { // 1 KB of audio, then a metadata block, then more audio
|
||||||
|
res.writeHead(200, { 'content-type': 'audio/mpeg', 'icy-metaint': '1024' }); const blk = Buffer.alloc(Math.ceil(META.length / 16) * 16); META.copy(blk);
|
||||||
|
return res.end(Buffer.concat([AUDIO.subarray(0, 1024), Buffer.from([blk.length / 16]), blk, AUDIO.subarray(1024, 2048), Buffer.from([0]), AUDIO.subarray(2048, 3072)]));
|
||||||
|
}
|
||||||
|
if (p === '/pls') { res.writeHead(200, { 'content-type': 'audio/x-scpls' }); return res.end('[playlist]'); }
|
||||||
|
if (p === '/logo.png') { res.writeHead(200, { 'content-type': 'image/png' }); return res.end(Buffer.from('89504e470d0a1a0a', 'hex')); }
|
||||||
|
if (p === '/journey/getRecentlyPlayed' || p === '/journey/getNowPlaying') return J([{ TITLE: 'THE JOURNEY', ARTIST: null, ACTIVITY_DATE: '10/24/2026 22:01:21' }, { TITLE: "A LIL' CHURCH (NOBODY'S TOO LOST)", ARTIST: 'TOBYMAC', ACTIVITY_DATE: '10/24/2026 22:01:37' }, { TITLE: 'RESCUE', ARTIST: 'JORDAN ST. CYR', ACTIVITY_DATE: '10/24/2026 21:57:43' }]);
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; Object.assign(process.env, { RADIO_BROWSER_URL: `http://127.0.0.1:${port}`, JOURNEYFM_NOW_URL: `http://127.0.0.1:${port}/journey`, JOURNEYFM_STREAM_URL: `http://127.0.0.1:${port}/stream` });
|
||||||
|
const radio = require('../radio'), users = require('../users');
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['radio'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => radio.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/radio', call = (p, o = {}) => fetch(base + p, o), j = r => r.json();
|
||||||
|
try {
|
||||||
|
let r = await j(await call('/stations')); assert.equal(r.featured[0].name, 'The Journey FM'); assert.equal(r.featured[0].id, 'journeyfm'); assert.deepEqual(r.mine, []);
|
||||||
|
r = await j(await call('/search?q=good')); assert.deepEqual(r.items.map(s => s.name), ['Good FM'], 'streams a browser cannot play (HLS, odd codecs) and nameless ones are left out'); assert.equal(r.items[0].tags, 'christian,worship,pop,extra'); assert.equal(r.items[0].id, 'aaaaaaaa-1111');
|
||||||
|
assert.equal((await call('/search')).status, 200, 'no words: Christian stations');
|
||||||
|
// the stream
|
||||||
|
r = await call('/play/journeyfm'); assert.equal(r.status, 200); assert.equal(r.headers.get('content-type'), 'audio/mpeg'); let body = Buffer.from(await r.arrayBuffer());
|
||||||
|
assert.equal(body.length, 3072, 'the song-title blocks are taken out of the audio'); assert.ok(body.equals(AUDIO.subarray(0, 3072)), 'and the audio is untouched');
|
||||||
|
await new Promise(ok => setTimeout(ok, 100)); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, '', 'nothing yet for a station not played');
|
||||||
|
r = await call('/play/aaaaaaaa-1111'); assert.equal(r.status, 200); await r.arrayBuffer(); r = await j(await call('/now/aaaaaaaa-1111')); assert.equal(r.title, 'Great Song - Some Band', 'the title carried in the stream is reported');
|
||||||
|
r = await call('/play/eeeeeeee-5555'); assert.equal(r.status, 502, 'a playlist file is not a stream');
|
||||||
|
assert.equal((await call('/play/..%2Fx')).status, 404); assert.equal((await call('/play/zzzzzzzz-9999')).status, 502, 'an unknown station is an error, not a crash');
|
||||||
|
// The Journey's own feed
|
||||||
|
r = await j(await call('/now/journeyfm')); assert.equal(r.title, "A Lil' Church (Nobody's Too Lost)"); assert.equal(r.artist, 'Tobymac'); assert.equal(r.recent.length, 2, "the station's own sound bites (no artist) are not listed songs");
|
||||||
|
// saved stations
|
||||||
|
assert.equal((await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) })).status, 200); r = await j(await call('/stations')); assert.deepEqual(r.mine.map(s => s.name), ['Good FM']);
|
||||||
|
await call('/stations', { method: 'POST', body: JSON.stringify({ id: 'aaaaaaaa-1111' }) }); assert.equal((await j(await call('/stations'))).mine.length, 1, 'saving twice keeps one');
|
||||||
|
assert.deepEqual((await j(await call('/stations', { headers: { 'x-user': 'b' } }))).mine, [], "each person's list is their own");
|
||||||
|
await call('/stations/aaaaaaaa-1111', { method: 'DELETE' }); assert.deepEqual((await j(await call('/stations'))).mine, []);
|
||||||
|
r = await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/logo.png`)); assert.equal(r.headers.get('content-type'), 'image/png'); assert.equal((await call('/icon?u=' + encodeURIComponent(`http://127.0.0.1:${port}/stream`))).status, 404, 'only pictures come through as icons');
|
||||||
|
// private addresses
|
||||||
|
assert.ok(radio.isPrivate('127.0.0.1') === false, '(allowed only because this test says so)'); process.env.RADIO_ALLOW_PRIVATE = '0';
|
||||||
|
for (const a of ['127.0.0.1', '10.1.2.3', '192.168.0.9', '172.20.1.1', '169.254.169.254', '::1', 'fd00::1', '::ffff:10.0.0.1', '0.0.0.0', '100.64.0.1']) assert.ok(radio.isPrivate(a), a + ' is private');
|
||||||
|
for (const a of ['8.8.8.8', '151.101.1.1', '2606:4700::1111']) assert.ok(!radio.isPrivate(a), a + ' is public');
|
||||||
|
r = await call('/play/journeyfm'); assert.equal(r.status, 502, 'a station on a private address is refused'); await r.text();
|
||||||
|
r = await call('/icon?u=' + encodeURIComponent(`http://localhost:${port}/logo.png`)); assert.ok(r.status >= 400, 'a name that points at this machine is refused too'); await r.text();
|
||||||
|
console.log('radio test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// "Save to Files": books, free audiobook chapters and Verse By Verse lessons are copied into the person's Seafile (made folders, skipped files already there, handouts that do not exist).
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-save-'));
|
||||||
|
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-savefx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]); const epub = fs.readFileSync(path.join(fx, 'test.epub'));
|
||||||
|
const tree = new Map([['/', new Set()]]), files = [], seen = []; let port; // fake Seafile: folders and uploaded files
|
||||||
|
const fake = http.createServer(async (req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, c = 200) => { res.writeHead(c, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, T = (t, b = '') => { res.writeHead(200, { 'content-type': t }); res.end(b); };
|
||||||
|
seen.push(req.method + ' ' + p + (u.searchParams.get('p') ? ' ' + u.searchParams.get('p') : ''));
|
||||||
|
if (p.startsWith('/api2/repos/') || p.startsWith('/seafhttp/')) { if (p.startsWith('/api2/') && req.headers.authorization !== 'Token SFTOKEN') { res.writeHead(401); return res.end(); } }
|
||||||
|
if (/^\/api2\/repos\/R{8}\/dir\/$/.test(p)) { const d = u.searchParams.get('p');
|
||||||
|
if (req.method === 'POST') { const par = d.replace(/\/[^/]+$/, '') || '/'; if (!tree.has(par)) { res.writeHead(404); return res.end(); } tree.set(d, new Set()); tree.get(par).add('D:' + d.split('/').pop()); return J('success', 201); }
|
||||||
|
if (!tree.has(d)) { res.writeHead(404); return res.end('{}'); } return J([...tree.get(d)].map(n => n.startsWith('D:') ? { name: n.slice(2), type: 'dir' } : { name: n, type: 'file' })); }
|
||||||
|
if (/^\/api2\/repos\/R{8}\/upload-link\/$/.test(p)) return J(`http://127.0.0.1:${port}/seafhttp/upload-api/tok?dir=${encodeURIComponent(u.searchParams.get('p'))}`);
|
||||||
|
if (p === '/seafhttp/upload-api/tok') { const b = Buffer.concat(await req.toArray()); const name = /filename="([^"]+)"/.exec(b.toString('latin1'))[1]; const d = u.searchParams.get('dir'); if (!tree.has(d)) { res.writeHead(404); return res.end(); }
|
||||||
|
if (name === 'fail.mp3') { res.writeHead(500); return res.end(); } tree.get(d).add(name); files.push(d + '/' + name + ' ' + b.length); return T('text/plain', 'id'); }
|
||||||
|
// sources: Verse By Verse, LibriVox
|
||||||
|
if (p === '/bible-studies/jude/podcast.xml') return T('application/xml', `<rss><channel><title>Jude: Faith</title><itunes:author>Wes</itunes:author><itunes:image href="http://127.0.0.1:${port}/c.gif"/>` + [1, 2, 3].map(n => `<item><title>${n} Lesson ${n}</title><link>http://127.0.0.1:${port}/lessons/jude-${n}</link><enclosure url="http://127.0.0.1:${port}/media/${n}.mp3" length="5" type="audio/mpeg"/></item>`).join('') + '</channel></rss>');
|
||||||
|
if (/^\/lessons\/jude-[12]$/.test(p)) return T('text/html', `<a href="http://127.0.0.1:${port}/media/notes${p.slice(-1)}.pdf">n</a>`); if (p === '/lessons/jude-3') return T('text/html', 'none');
|
||||||
|
if (/^\/media\/\d\.mp3$/.test(p)) return T('audio/mpeg', 'AUDIO' + p); if (/^\/media\/notes\d\.pdf$/.test(p)) return T('application/pdf', '%PDF-1.4 notes');
|
||||||
|
if (p === '/api/feed/audiobooks/') { const base = { id: '9', title: 'Old: Story?', totaltimesecs: 60, authors: [{ first_name: 'A', last_name: 'B' }] }; return J({ books: [{ ...base, sections: [1, 2].map(n => ({ title: 'Ch ' + n + '/x', listen_url: `http://127.0.0.1:${port}/media/${n}.mp3`, playtime: '30' })) }] }); }
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; Object.assign(process.env, { SEAFILE_URL: 'http://127.0.0.1:' + port, VBV_URL: 'http://127.0.0.1:' + port, LIBRIVOX_URL: 'http://127.0.0.1:' + port }); tree.set('/Lib', new Set());
|
||||||
|
const users = require('../users'), books = require('../books'), save = require('../savefiles');
|
||||||
|
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['files'], handle: 'Yg' }), noTok = users.create({ name: 'Cy', role: 'admin', handle: 'Yw' });
|
||||||
|
const as = (u, f) => users.runAs(u, f); as(ann, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' }))); as(bob, () => fs.writeFileSync(path.join(users.dir(), 'settings.json'), JSON.stringify({ sf_token: 'SFTOKEN' })));
|
||||||
|
const bookId = as(ann, () => books.addBook(epub, 'epub', { title: 'My Book', author: 'Some Author' }).id);
|
||||||
|
const hub = http.createServer((req, res) => as({ b: bob, c: noTok }[req.headers['x-user']] || ann, () => save.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/save', R = 'RRRRRRRR', j = r => r.json(), post = (b, h = {}) => fetch(base + '/', { method: 'POST', body: JSON.stringify({ repo: R, dir: '/Lib', ...b }), headers: h });
|
||||||
|
const finish = async id => { for (let i = 0; i < 100; i++) { const s = await j(await fetch(base + '/job/' + id)); if (s.state !== 'running') return s; await new Promise(ok => setTimeout(ok, 50)); } throw new Error('job did not finish'); };
|
||||||
|
try {
|
||||||
|
let r = await post({ kind: 'book', id: bookId }); let s = await finish((await j(r)).id); assert.equal(s.state, 'done'); assert.equal(s.done, 1); assert.ok(files.some(f => f.startsWith('/Lib/Books/Some Author - My Book.epub ')), 'the book is in a Books folder, named by author and title');
|
||||||
|
r = await post({ kind: 'book', id: bookId }); s = await finish((await j(r)).id); assert.equal(s.skipped, 1); assert.equal(s.done, 0, 'running it again skips what is already there');
|
||||||
|
r = await post({ kind: 'vbv-lesson', slug: 'jude', n: 0, what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Verse By Verse/Jude Faith/01 - Lesson 1.mp3')), 'folders are made and names are safe'); assert.ok(files.some(f => f.includes('01 - Lesson 1 - Notes.pdf')));
|
||||||
|
r = await post({ kind: 'vbv-study', slug: 'jude', what: 'both' }); s = await finish((await j(r)).id); assert.equal(s.total, 6); assert.equal(s.skipped, 2, 'lesson 1 was already saved'); assert.equal(s.missing, 1, 'a lesson with no handout is counted, not failed'); assert.equal(s.failed, 0); assert.equal(s.done, 3);
|
||||||
|
assert.ok(files.some(f => f.includes('03 - Lesson 3.mp3')) && !files.some(f => f.includes('03 - Lesson 3 - Notes')));
|
||||||
|
r = await post({ kind: 'librivox-book', id: '9', dir: '/Lib/Sub' }); assert.equal((await finish((await j(r)).id)).state, 'error', 'a folder that does not exist in Files is an error, not a crash');
|
||||||
|
r = await post({ kind: 'librivox-book', id: '9' }); s = await finish((await j(r)).id); assert.equal(s.done, 2); assert.ok(files.some(f => f.startsWith('/Lib/Audiobooks/Old Story/01 - Ch 1 x.mp3')), 'odd characters in names are removed');
|
||||||
|
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'b' }); assert.equal(r.status, 403, 'a person without the Books tab cannot save books');
|
||||||
|
r = await post({ kind: 'book', id: bookId }, { 'x-user': 'c' }); assert.equal(r.status, 401, 'no Files sign-in saved: told to sign in'); assert.match((await j(r)).error, /sign in/);
|
||||||
|
assert.equal((await post({ kind: 'nope' })).status, 400); assert.equal((await post({ kind: 'book', id: bookId, repo: '../x' })).status, 400); assert.equal((await post({ kind: 'book', id: 'zzz' })).status, 404);
|
||||||
|
r = await post({ kind: 'book', id: bookId, dir: '/Lib/../../etc' }); s = await finish((await j(r)).id); assert.ok(!s.dir.includes('..'), 'a folder path cannot climb out');
|
||||||
|
assert.equal((await fetch(base + '/job/' + s.id, { headers: { 'x-user': 'b' } })).status, 404, "another person's save is not visible");
|
||||||
|
console.log('savefiles test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// Book store: search across Gutenberg, Standard Ebooks, the Internet Archive and an OPDS catalogue; adding a book puts it on the shelf; sites only reach their own addresses.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-store-'));
|
||||||
|
const fx = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-storefx-')); cp.execFileSync('python3', [path.join(__dirname, 'mkbooks.py'), fx]);
|
||||||
|
const epub = fs.readFileSync(path.join(fx, 'test.epub')), pdf = fs.readFileSync(path.join(fx, 'test.pdf')), gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64');
|
||||||
|
const seen = []; let port;
|
||||||
|
const feed = (body, links = '') => `<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom">${links}${body}</feed>`;
|
||||||
|
const acq = (id, title, author, href, type = 'application/epub+zip') => `<entry><title>${title}</title><author><name>${author}</name></author><id>${id}</id><summary>A & B</summary><link rel="http://opds-spec.org/image" href="/img/${id}.gif" type="image/gif"/><link rel="http://opds-spec.org/acquisition/open-access" href="${href}" type="${type}"/></entry>`;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(req.method + ' ' + p + u.search + (req.headers.authorization ? ' [auth]' : ''));
|
||||||
|
const J = o => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }, X = x => { res.writeHead(200, { 'content-type': 'application/atom+xml' }); res.end(x); };
|
||||||
|
if (p === '/gut/books/' ) return J({ next: u.searchParams.get('page') === '1' ? 'x' : null, results: [{ id: 11, title: 'Alice', authors: [{ name: 'Carroll, Lewis' }], subjects: ['Fantasy'], formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/file/11.epub`, 'image/jpeg': `http://127.0.0.1:${port}/img/11.gif` } }, { id: 12, title: 'No epub', authors: [], formats: {} }] });
|
||||||
|
if (p === '/gut/books/11') return J({ id: 11, formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/file/11.epub` } });
|
||||||
|
if (p === '/gut/file/11.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
|
||||||
|
if (p === '/gut/books/66') return J({ id: 66, formats: { 'application/epub+zip': `http://127.0.0.1:${port}/gut/evil` } });
|
||||||
|
if (p === '/gut/evil') { res.writeHead(302, { location: `http://localhost:${port}/secret` }); return res.end(); }
|
||||||
|
if (p === '/img/11.gif' || p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||||
|
if (p === '/ia/advancedsearch.php') return J({ response: { numFound: 1, docs: [{ identifier: 'moby1', title: 'Moby', creator: ['Melville'] }, { identifier: '../bad', title: 'x' }] } });
|
||||||
|
if (p === '/ia/metadata/moby1') return J({ metadata: {}, files: [{ name: 'moby1.pdf', format: 'Text PDF' }, { name: 'moby1.epub', format: 'EPUB' }] });
|
||||||
|
if (p === '/ia/metadata/locked') return J({ metadata: { 'access-restricted-item': 'true' }, files: [{ name: 'x.epub', format: 'EPUB' }] });
|
||||||
|
if (p === '/ia/download/moby1/moby1.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('ia')])); }
|
||||||
|
if (p === '/se/opds') return X(feed('<entry><title>New releases</title><id>n</id><link rel="http://opds-spec.org/sort/new" href="/se/opds/new" type="application/atom+xml"/></entry>', '<link rel="search" href="/se/opds/all?query={searchTerms}" type="application/atom+xml"/>'));
|
||||||
|
if (p === '/se/opds/all') return X(feed(acq('se1', 'Dracula ' + u.searchParams.get('query'), 'Bram Stoker', '/se/dl/dracula.epub')));
|
||||||
|
if (p === '/se/opds/new') return X(feed(acq('se2', 'Newest Book', 'Someone', '/se/dl/new.epub'), '<link rel="next" href="/se/opds/new2" type="application/atom+xml"/>'));
|
||||||
|
if (p === '/se/opds/new2') return X(feed(acq('se3', 'Page Two', 'Someone', '/se/dl/new.epub')));
|
||||||
|
if (p === '/se/dl/dracula.epub' || p === '/se/dl/new.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(epub); }
|
||||||
|
if (p.startsWith('/cal/')) { // Calibre-Web style: needs a sign-in; search is described by an OpenSearch file
|
||||||
|
if (req.headers.authorization !== 'Basic ' + Buffer.from('reader:pw').toString('base64')) { res.writeHead(401); return res.end(); }
|
||||||
|
if (p === '/cal/opds') return X(feed('', '<link rel="search" href="/cal/opds/osd" type="application/opensearchdescription+xml"/>'));
|
||||||
|
if (p === '/cal/opds/osd') return X('<OpenSearchDescription><Url type="application/atom+xml" template="/cal/opds/search/{searchTerms}"/></OpenSearchDescription>');
|
||||||
|
if (p.startsWith('/cal/opds/search/')) return X(feed(acq('c1', 'Own Book ' + decodeURIComponent(p.split('/').pop()), 'Me', '/cal/get/1.pdf', 'application/pdf')));
|
||||||
|
if (p === '/cal/get/1.pdf') { res.writeHead(200, { 'content-type': 'application/pdf' }); return res.end(pdf); }
|
||||||
|
}
|
||||||
|
res.writeHead(404); res.end('no');
|
||||||
|
}).listen(0, async () => {
|
||||||
|
port = fake.address().port; const B = 'http://127.0.0.1:' + port;
|
||||||
|
Object.assign(process.env, { GUTENDEX_URL: B + '/gut', ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B + '/se/opds' });
|
||||||
|
const books = require('../books'), users = require('../users');
|
||||||
|
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) };
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/read/store', call = (p, o = {}) => fetch(base + p, o), post = (p, b, h = {}) => call(p, { method: 'POST', body: JSON.stringify(b), headers: h });
|
||||||
|
try {
|
||||||
|
let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive']);
|
||||||
|
r = await call('/search?q=alice'); let j = await r.json();
|
||||||
|
assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive'], 'one result from each catalogue in turn, books with no EPUB left out');
|
||||||
|
assert.equal(j.items[0].author, 'Lewis Carroll'); assert.equal(j.items[1].summary, 'A & B'); assert.match(j.items[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true);
|
||||||
|
assert.ok(seen.includes('GET /se/opds/all?query=alice'), 'the search link in the catalogue is used'); assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped');
|
||||||
|
r = await call('/search?q=&source=standardebooks&page=2'); j = await r.json(); assert.deepEqual(j.items.map(i => i.title), ['Page Two'], 'no search words: the newest list, and its next pages');
|
||||||
|
r = await call('/search?q=x&source=nope'); j = await r.json(); assert.ok(j.errors.nope, 'a broken catalogue is reported and does not stop the rest');
|
||||||
|
r = await call(j.items ? '/cover?s=gutenberg&u=' + encodeURIComponent(B + '/img/11.gif') : ''); assert.equal(r.headers.get('content-type'), 'image/gif');
|
||||||
|
assert.equal((await call('/cover?s=gutenberg&u=' + encodeURIComponent('http://localhost:' + port + '/img/11.gif'))).status, 502, 'covers only come from the catalogue\'s own site');
|
||||||
|
// adding
|
||||||
|
r = await post('/add', { source: 'gutenberg', ref: '11', title: 'Alice', author: 'Lewis Carroll', cover: B + '/img/11.gif' }); let book = await r.json(); assert.equal(r.status, 200); assert.equal(book.ext, 'epub'); assert.equal(book.author, 'Lewis Carroll'); assert.equal(book.cover, true);
|
||||||
|
r = await post('/add', { source: 'gutenberg', ref: '11', title: 'Alice' }); assert.equal((await r.json()).duplicate, true, 'adding the same book twice does not store it twice');
|
||||||
|
assert.equal((await (await fetch(base.replace('/store', '/list'))).json()).length, 1);
|
||||||
|
assert.equal((await post('/add', { source: 'gutenberg', ref: '11' }, { 'x-user': 'b' })).status, 200); assert.equal((await (await fetch(base.replace('/store', '/list'), { headers: { 'x-user': 'b' } })).json()).length, 1, "each person's shelf is their own");
|
||||||
|
r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('GET /ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF');
|
||||||
|
r = await post('/add', { source: 'archive', ref: 'locked' }); assert.equal(r.status, 403); assert.match((await r.json()).error, /DRM/);
|
||||||
|
r = await post('/add', { source: 'gutenberg', ref: '66' }); assert.equal(r.status, 502, 'a download that redirects off the site is refused'); assert.ok(!seen.some(x => x.includes('/secret')));
|
||||||
|
r = await post('/add', { source: 'gutenberg', ref: '../x' }); assert.equal(r.status, 400);
|
||||||
|
r = await post('/add', { source: 'standardebooks', ref: 'http://localhost:' + port + '/se/dl/dracula.epub' }); assert.equal(r.status, 400, 'a catalogue book must live on the catalogue\'s own address');
|
||||||
|
r = await post('/add', { source: 'standardebooks', ref: B + '/se/dl/dracula.epub', title: 'Dracula', format: 'epub' }); assert.equal(r.status, 200);
|
||||||
|
// custom catalogue
|
||||||
|
assert.equal((await post('/sources', { name: 'Mine', url: B + '/cal/opds' }, { 'x-user': 'b' })).status, 403, 'only an admin adds catalogues');
|
||||||
|
assert.equal((await post('/sources', { name: 'Bad', url: B + '/cal/opds', user: 'reader', pass: 'wrong' })).status, 502, 'a catalogue that refuses the sign-in is not added');
|
||||||
|
assert.equal((await call('/sources').then(x => x.json())).length, 3);
|
||||||
|
r = await post('/sources', { name: 'Mine', url: B + '/cal/opds', user: 'reader', pass: 'pw' }); const mine = await r.json(); assert.equal(r.status, 200);
|
||||||
|
assert.ok(!JSON.stringify(await (await call('/sources')).json()).includes('pw'), 'sign-ins are never sent to the browser'); assert.ok(!fs.readFileSync(path.join(process.env.DATA_DIR, 'store-sources.json'), 'utf8').includes('"pw"'), 'and are encrypted on disk');
|
||||||
|
j = await (await call('/search?q=hello%20world&source=' + mine.id)).json(); assert.equal(j.items[0].title, 'Own Book hello world', 'search found through the OpenSearch description'); assert.equal(j.items[0].format, 'pdf');
|
||||||
|
r = await post('/add', { source: mine.id, ref: j.items[0].ref, title: j.items[0].title, format: 'pdf' }, { 'x-user': 'b' }); assert.equal((await r.json()).ext, 'pdf');
|
||||||
|
assert.equal((await call('/sources/' + mine.id, { method: 'DELETE', headers: { 'x-user': 'b' } })).status, 403); assert.equal((await call('/sources/' + mine.id, { method: 'DELETE' })).status, 200);
|
||||||
|
console.log('store test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Transcripts: reading SRT/VTT/JSON, finding an episode's transcript link in a feed, refusing private addresses, and speech-to-text through a fake Whisper server.
|
||||||
|
const http = require('http'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
const tr = require('../transcripts');
|
||||||
|
let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); if (!c) bad++; };
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trt-'));
|
||||||
|
let installed = false; const calls = [];
|
||||||
|
const srv = http.createServer((req, res) => {
|
||||||
|
const body = []; req.on('data', d => body.push(d)); req.on('end', () => {
|
||||||
|
const b = Buffer.concat(body).toString('latin1');
|
||||||
|
calls.push(req.method + ' ' + req.url);
|
||||||
|
if (req.method === 'GET' && req.url === '/v1/models/Systran/faster-whisper-small') { res.writeHead(installed ? 200 : 404); return res.end('{}'); }
|
||||||
|
if (req.method === 'POST' && req.url === '/v1/models') { installed = /faster-whisper-small/.test(b); res.writeHead(installed ? 201 : 422); return res.end('{}'); } // (this version of the service downloads through a body, the older path form is refused)
|
||||||
|
if (req.url === '/v1/audio/transcriptions' && req.method === 'POST') {
|
||||||
|
if (!installed) { res.writeHead(404); return res.end('{"detail":"Model is not installed locally"}'); }
|
||||||
|
if (!/response_format/.test(b) || !/faster-whisper-small/.test(b) || !/hello-audio/.test(b)) { res.writeHead(400); return res.end('bad form'); }
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ text: 'x', segments: [{ start: 0, end: 2.5, text: ' Welcome to the show. ' }, { start: 2.5, end: 6, text: ' Today we talk about <b>bread</b>.' }, { start: 6, end: 7, text: ' ' }] }));
|
||||||
|
}
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise(r => srv.listen(0, '127.0.0.1', r));
|
||||||
|
tr.init({ dataDir: dir, apiUrl: 'http://127.0.0.1:' + srv.address().port, model: 'Systran/faster-whisper-small', port: 8080 });
|
||||||
|
|
||||||
|
const srt = '1\n00:00:01,500 --> 00:00:03,000\nHello <i>there</i>\n\n2\n00:01:02,000 --> 00:01:05,250\nSecond & last\n';
|
||||||
|
let l = tr.parseCues(srt); ok(l.length === 2 && l[0].t === 1500 && l[0].text === 'Hello there' && l[1].t === 62000 && l[1].text === 'Second & last', 'SubRip cues are read, tags and entities removed');
|
||||||
|
l = tr.parseCues('WEBVTT\n\n00:05.000 --> 00:07.000\nShort form\n\n1:00:00.000 --> 1:00:02.000 align:start\nAn hour in\n'); ok(l.length === 2 && l[0].t === 5000 && l[1].t === 3600000, 'WebVTT cues, short and hour times');
|
||||||
|
l = tr.parseJson(JSON.stringify({ version: '1.0.0', segments: [{ startTime: 0, endTime: 4, speaker: 'Ann', body: 'Hi all' }, { startTime: 4.5, body: 'Next' }] })); ok(l.length === 2 && l[0].text === 'Ann: Hi all' && l[1].t === 4500, 'Podcast 2.0 JSON segments');
|
||||||
|
ok(tr.parseJson('not json').length === 0, 'broken JSON gives nothing, not a crash');
|
||||||
|
const shaped = tr.shape([{ t: 3000, text: 'b' }, { t: 1000, text: 'a' }], 'S'); ok(shaped.lines[0].text === 'a' && shaped.lines[0].e === 3000 && shaped.sync === 'line', 'lines are sorted and given ends');
|
||||||
|
|
||||||
|
const feed = `<item><guid>g1</guid><podcast:transcript url="https://a/b.srt?x=1&y=2" type="application/x-subrip"/></item><item><guid>g2</guid><enclosure url="https://f/2.mp3" length="1"/><podcast:transcript url="https://a/c.vtt" type="text/vtt"/></item>`;
|
||||||
|
ok(tr.transcriptLinks(feed, 'g1', '')[0].url === 'https://a/b.srt?x=1&y=2', 'a transcript link is found by the episode guid (and & unescaped)');
|
||||||
|
ok(tr.transcriptLinks(feed, 'nope', 'https://f/2.mp3')[0].type === 'text/vtt', 'or by the audio file address');
|
||||||
|
ok(tr.transcriptLinks(feed, 'zzz', '') === null, 'an episode that is not in the feed is told apart from one with no transcript');
|
||||||
|
ok(tr.transcriptLinks('<item><guid>q</guid></item>', 'q', '').length === 0, 'an episode with no transcript tag gives an empty list');
|
||||||
|
|
||||||
|
for (const u of ['http://127.0.0.1:1/x', 'http://localhost/x', 'http://192.168.1.5/x', 'http://10.0.0.2/x', 'ftp://example.com/x', 'http://[::1]/x']) {
|
||||||
|
let e = null; try { await tr.getText(u); } catch (x) { e = x; } ok(e && /not allowed/.test(e.message), 'refuses ' + u);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = path.join(dir, 'a.bin'); fs.writeFileSync(audio, 'hello-audio');
|
||||||
|
const r = await tr.whisper(audio, 'audio'); ok(r && r.found && r.lines.length === 2 && r.lines[0].text === 'Welcome to the show.' && r.lines[1].text === 'Today we talk about bread.' && r.source === 'Transcribed on your server', 'speech-to-text segments become timed lines (blank ones dropped, tags removed)');
|
||||||
|
ok(calls.includes('POST /v1/models/Systran/faster-whisper-small') && calls.includes('POST /v1/models') && installed, 'a model that is not installed is downloaded first (trying the older form, then the newer)');
|
||||||
|
ok(r.lines[1].t === 2500 && r.lines[1].e === 2500 + 6000, 'times are in milliseconds');
|
||||||
|
srv.close(); fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
console.log(bad ? '\n' + bad + ' check(s) failed' : '\nAll transcript checks passed'); process.exit(bad ? 1 : 0);
|
||||||
|
})().catch(e => { console.error(e); process.exit(1); });
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Verse By Verse Ministry: the study list is read from the ministry's category pages, lessons from a study's podcast feed, and audio and handouts are sent to the ministry's own media addresses only.
|
||||||
|
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-vbv-'));
|
||||||
|
let port; const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = [];
|
||||||
|
const card = (slug, title, n, who) => `<li class="study-item -card"><a class="link-wrapper js-study-item" href="/bible-studies/${slug}"><figure><div><img src="http://127.0.0.1:${port}/img/tiny.gif" srcset="http://127.0.0.1:${port}/img/tiny.gif, http://127.0.0.1:${port}/img/small.gif 2x" /></div></figure><div class="content-block"><h3 class="title">${title}</h3><h4 class="lesson-count">${n} Lessons</h4><p class="body-text -serif">${who}</p></div></a></li>`;
|
||||||
|
const fake = http.createServer((req, res) => {
|
||||||
|
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p);
|
||||||
|
const H = (t, b) => { res.writeHead(200, { 'content-type': t }); res.end(b); };
|
||||||
|
if (p === '/bible-studies/category/old-testament-books') return H('text/html', `<ul>${card('genesis-2011', 'Genesis', 109, 'Stephen Armstrong')}${card('ruth', 'Ruth & Naomi', 4, 'Wes')}</ul>`);
|
||||||
|
if (p === '/bible-studies/category/new-testament-books') return H('text/html', `<ul>${card('jude', 'Jude', 2, 'Wesley Livingston')}${card('ruth', 'Ruth & Naomi', 4, 'Wes')}</ul>`);
|
||||||
|
if (p.startsWith('/bible-studies/category/')) return H('text/html', '<ul></ul>');
|
||||||
|
if (p === '/bible-studies/jude/podcast.xml') return H('application/xml', `<rss><channel><title>Jude</title><itunes:author>Wesley Livingston</itunes:author><itunes:summary>A study of Jude & more.</itunes:summary><itunes:image href="http://127.0.0.1:${port}/img/cover.gif" />
|
||||||
|
<item><title>1 Jude 1-3</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-1</link><description>Contend for the Faith</description><itunes:subtitle>Opening study</itunes:subtitle><enclosure url="http://127.0.0.1:${port}/media/1.mp3" length="5" type="audio/mpeg" /><pubDate>Sun, 29 Aug 2021 06:00:00 -0500</pubDate></item>
|
||||||
|
<item><title>2 Jude 4-8</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-2</link><description>False teachers</description><enclosure url="https://evil.example.com/2.mp3" length="5" type="audio/mpeg" /></item>
|
||||||
|
<item><title>3 Jude 9-16</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-3</link><description>Woe</description><enclosure url="http://127.0.0.1:${port}/media/3.mp3" length="5" type="audio/mpeg" /></item></channel></rss>`);
|
||||||
|
if (p === '/bible-studies/empty/podcast.xml') return H('application/xml', '<rss><channel><title>Empty</title></channel></rss>');
|
||||||
|
if (p === '/bible-studies/empty') return H('text/html', `<html><head><title>Empty Study | Ministry</title><meta property="og:image" content="http://127.0.0.1:${port}/img/cover.gif"></head><body><h1>Empty Study</h1><ul class="lesson-list"><li class="lesson -x"><a class="lesson-link" href="/lessons/empty-1"><span class="lesson-number">1A</span><p class="body-text -light">Chapter 1:1-5</p></a></li><li class="lesson"><a class="lesson-link" href="/lessons/empty-2"><span class="lesson-number">1B</span><p class="body-text -light">Chapter 1:6-9</p></a></li></ul></body></html>`);
|
||||||
|
if (p === '/lessons/empty-1') return H('text/html', `<audio src="http://127.0.0.1:${port}/media/e1.mp3"></audio>`);
|
||||||
|
if (p === '/lessons/empty-2') return H('text/html', '<video src="x.mp4"></video>');
|
||||||
|
if (p === '/lessons/jude-lesson-1') return H('text/html', `<a href="http://127.0.0.1:${port}/media/notes1.pdf">Notes</a>`);
|
||||||
|
if (p === '/lessons/jude-lesson-3') return H('text/html', '<p>No handout</p>');
|
||||||
|
if (p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
|
||||||
|
res.writeHead(404); res.end();
|
||||||
|
}).listen(0, () => {
|
||||||
|
port = fake.address().port; process.env.VBV_URL = 'http://127.0.0.1:' + port;
|
||||||
|
const vbv = require('../vbv'), users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bob = users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' });
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? bob : me, () => vbv.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/vbv', call = (p, o = {}) => fetch(base + p, { redirect: 'manual', ...o }), j = r => r.json();
|
||||||
|
try {
|
||||||
|
let r = await j(await call('/studies')); assert.deepEqual(r.studies.map(s => s.slug).sort(), ['genesis-2011', 'jude', 'ruth']); const ruth = r.studies.find(s => s.slug === 'ruth');
|
||||||
|
assert.equal(ruth.title, 'Ruth & Naomi'); assert.deepEqual(ruth.categories.sort(), ['new-testament-books', 'old-testament-books'], 'a study in two categories is listed once'); assert.equal(r.studies.find(s => s.slug === 'genesis-2011').lessons, 109); assert.match(r.studies[0].cover, /^\/api\/vbv\/cover\?u=/);
|
||||||
|
assert.deepEqual((await j(await call('/studies?category=old-testament-books&q=gen'))).studies.map(s => s.slug), ['genesis-2011']);
|
||||||
|
r = await j(await call('/study/jude')); assert.equal(r.teacher, 'Wesley Livingston'); assert.equal(r.description, 'A study of Jude & more.'); assert.equal(r.lessons.length, 2, 'a lesson whose audio is on another site is dropped'); assert.equal(r.lessons[0].title, 'Jude 1-3', 'the lesson number in the feed title is not repeated');
|
||||||
|
assert.ok(!JSON.stringify(r).includes('/media/'), 'recording addresses are not sent to the browser'); assert.equal(r.lessons[1].title, 'Jude 9-16');
|
||||||
|
let a = await call('/audio/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/1.mp3`); assert.equal((await call('/audio/jude/5')).status, 404);
|
||||||
|
a = await call('/notes/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/notes1.pdf`); assert.equal((await call('/notes/jude/1')).status, 404, 'a lesson with no handout says so');
|
||||||
|
r = await j(await call('/study/empty')); assert.equal(r.title, 'Empty Study'); assert.deepEqual(r.lessons.map(l => l.title), ['1A \u2013 Chapter 1:1-5', '1B \u2013 Chapter 1:6-9'], 'a study with an empty feed is read from its page');
|
||||||
|
a = await call('/audio/empty/0'); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/e1.mp3`, "its recordings are found on each lesson's page when played"); assert.equal((await call('/audio/empty/1')).status, 404, 'a lesson with no recording says so');
|
||||||
|
a = await call('/notes/jude/1', { headers: { accept: 'text/html' } }); assert.equal(a.status, 404); assert.match(await a.text(), /no handout/, 'opened in a tab, a missing handout is explained in words');
|
||||||
|
assert.equal((await call('/study/..%2Fx')).status, 404); assert.equal((await call('/study/nothere')).status, 404);
|
||||||
|
a = await call('/cover?u=' + encodeURIComponent(`http://127.0.0.1:${port}/img/cover.gif`)); assert.equal(a.headers.get('content-type'), 'image/gif'); assert.equal((await call('/cover?u=' + encodeURIComponent('https://example.com/a.jpg'))).status, 404);
|
||||||
|
const post = o => call('/progress', { method: 'POST', body: JSON.stringify(o) }).then(j);
|
||||||
|
await post({ 'vbv:jude:0': { pos: 300, dur: 3000, t: 10 } }); assert.equal((await post({ 'vbv:jude:0': { pos: 5, dur: 3000, t: 3 } }))['vbv:jude:0'].pos, 300, 'the newest position wins'); assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {}, "each person's places are their own");
|
||||||
|
const n = seen.length; await call('/studies'); await call('/study/jude'); assert.equal(seen.length, n, 'the ministry\'s pages are fetched once and kept for a while, not on every visit');
|
||||||
|
console.log('vbv test ok');
|
||||||
|
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||||
|
hub.close(); fake.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
// Words for spoken audio (podcasts, audiobooks), shown in the same screen as song lyrics.
|
||||||
|
// 1. A transcript the show itself publishes (the Podcast 2.0 <podcast:transcript> tag in its feed: SRT, VTT or JSON), fetched on request.
|
||||||
|
// 2. Otherwise, speech-to-text on this server: an OpenAI-compatible Whisper service (TRANSCRIBE_API_URL, e.g. the bundled faster-whisper/speaches
|
||||||
|
// container). Only on request, one job at a time; the result is kept on disk, so an episode is transcribed once.
|
||||||
|
const fs = require('fs'), path = require('path'), os = require('os'), crypto = require('crypto');
|
||||||
|
const { Readable } = require('stream'), { pipeline } = require('stream/promises');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
|
const users = require('./users');
|
||||||
|
const podcast = require('./podcast');
|
||||||
|
|
||||||
|
const cfg = { api: '', model: '', dir: '', port: 8080 };
|
||||||
|
const MAX_AUDIO = 1.5 * 1024 ** 3, MAX_TEXT = 6 * 1024 * 1024;
|
||||||
|
function init({ dataDir, apiUrl, model, port }) { cfg.api = (apiUrl || '').replace(/\/$/, ''); cfg.model = model || 'Systran/faster-whisper-small'; cfg.dir = path.join(dataDir, 'transcripts'); cfg.port = port || 8080; fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 }); }
|
||||||
|
/** "fetch failed" says nothing: name the step and the reason (connection refused, timed out...). */
|
||||||
|
const why = e => { const c = e && e.cause; return (c && (c.code || c.message)) || (e && e.message) || 'unknown error'; };
|
||||||
|
const fail = (status, message) => Object.assign(new Error(message), { status });
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
|
||||||
|
// ---- reading transcript files
|
||||||
|
const tms = (h, m, s, f) => (+(h || 0) * 3600 + +m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
|
||||||
|
const clean = t => String(t || '').replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim();
|
||||||
|
/** SubRip and WebVTT: "00:01:02,500 --> 00:01:05,000" then the words. */
|
||||||
|
function parseCues(text) {
|
||||||
|
const out = [], re = /(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})[^\n]*\n([\s\S]*?)(?=\n\s*\n|$)/g; let m;
|
||||||
|
while ((m = re.exec(String(text).replace(/\r/g, '')))) { const body = clean(m[9]); if (body) out.push({ t: tms(m[1], m[2], m[3], m[4]), text: body }); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
/** Podcast 2.0 JSON: { segments: [{ startTime, endTime, speaker, body }] } */
|
||||||
|
function parseJson(text) {
|
||||||
|
let j; try { j = JSON.parse(text); } catch { return []; }
|
||||||
|
return (j.segments || []).map(s => ({ t: Math.round(+s.startTime * 1000), text: (s.speaker ? s.speaker + ': ' : '') + clean(s.body) })).filter(x => x.text && Number.isFinite(x.t));
|
||||||
|
}
|
||||||
|
const withEnds = lines => { lines.sort((a, b) => a.t - b.t); lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 6000; }); return lines; };
|
||||||
|
const shape = (lines, source) => lines.length ? { found: true, source, sync: 'line', lines: withEnds(lines) } : null;
|
||||||
|
|
||||||
|
// ---- the show's own transcript
|
||||||
|
const PRIVATE = /^(localhost|.*\.local|.*\.internal|0\.0\.0\.0|127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1\]?$|\[?f[cd][0-9a-f]{2}:)/i;
|
||||||
|
async function getText(url, hops = 0) { // only public http(s) addresses; redirects are checked again
|
||||||
|
const u = new URL(url); if (!/^https?:$/.test(u.protocol) || PRIVATE.test(u.hostname)) throw fail(400, 'That address is not allowed');
|
||||||
|
const r = await fetch(u, { redirect: 'manual', signal: AbortSignal.timeout(15000), headers: { 'user-agent': 'NEHub/1 (+transcripts)', accept: 'text/*, application/json, application/xml, */*' } });
|
||||||
|
if (r.status >= 300 && r.status < 400 && r.headers.get('location') && hops < 3) return getText(new URL(r.headers.get('location'), u).href, hops + 1);
|
||||||
|
if (!r.ok) throw fail(502, 'Status ' + r.status);
|
||||||
|
const len = +r.headers.get('content-length') || 0; if (len > MAX_TEXT) throw fail(413, 'Too large');
|
||||||
|
const text = await r.text(); if (text.length > MAX_TEXT) throw fail(413, 'Too large'); return text;
|
||||||
|
}
|
||||||
|
const attr = (s, k) => { const m = new RegExp(k + '\\s*=\\s*"([^"]*)"', 'i').exec(s); return m ? m[1].replace(/&/g, '&') : ''; };
|
||||||
|
/** The transcript links an episode has in its feed. */
|
||||||
|
function transcriptLinks(feedXml, guid, fileUrl) {
|
||||||
|
const items = String(feedXml).split(/<item[\s>]/i).slice(1);
|
||||||
|
const unwrap = x => String(x || '').replace(/<!\[CDATA\[|\]\]>/g, '').trim();
|
||||||
|
const item = items.find(b => { const g = /<guid[^>]*>([\s\S]*?)<\/guid>/i.exec(b); return g && guid && unwrap(g[1]) === guid; })
|
||||||
|
|| items.find(b => { const e = /<enclosure[^>]*>/i.exec(b); return e && fileUrl && attr(e[0], 'url') === fileUrl; });
|
||||||
|
if (!item) return null;
|
||||||
|
return [...item.matchAll(/<podcast:transcript\s+([^>]*?)\/?>/gi)].map(m => ({ url: attr(m[1], 'url'), type: attr(m[1], 'type').toLowerCase() })).filter(x => x.url);
|
||||||
|
}
|
||||||
|
/** { transcript } when the show publishes one we can read, otherwise { why }: the reason, so a missing transcript can be explained. */
|
||||||
|
async function nativeTranscript(info) {
|
||||||
|
if (!info) return { why: 'Could not read this episode from the podcast service.' };
|
||||||
|
if (!info.feedUrl) return { why: 'The podcast service did not give the show\'s feed address.' };
|
||||||
|
let xml; try { xml = await getText(info.feedUrl); } catch (e) { return { why: 'Could not download the show\'s feed (' + e.message + ').' }; }
|
||||||
|
const links = transcriptLinks(xml, info.guid, info.fileUrl);
|
||||||
|
if (!links) return { why: 'Could not find this episode in the show\'s feed (looked for ' + (info.guid ? 'id ' + String(info.guid).slice(0, 60) : 'its audio address') + ').' };
|
||||||
|
if (!links.length) return { why: 'The show\'s feed lists no transcript for this episode.' };
|
||||||
|
const rank = l => /json/.test(l.type) ? 0 : /vtt/.test(l.type) ? 1 : /subrip|srt/.test(l.type) ? 2 : 9;
|
||||||
|
for (const l of links.sort((a, b) => rank(a) - rank(b))) {
|
||||||
|
if (rank(l) > 2) continue; // (plain HTML or text has no times: not worth showing as a transcript)
|
||||||
|
try { const t = await getText(l.url), lines = /json/.test(l.type) ? parseJson(t) : parseCues(t); const r = shape(lines, 'Published by the show'); if (r) return { transcript: r }; } catch {}
|
||||||
|
}
|
||||||
|
return { why: 'The show lists a transcript (' + links.map(l => l.type || 'unknown type').join(', ') + ') but not in a timed format this can follow, or it could not be downloaded.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- speech to text on this server
|
||||||
|
const jobs = new Map(); // key -> { state: 'running' | 'error', since, error }
|
||||||
|
let chain = Promise.resolve(); // one at a time: it is heavy
|
||||||
|
const fileOf = key => path.join(cfg.dir, key + '.json');
|
||||||
|
const keyFor = (kind, id, src) => crypto.createHash('sha1').update(kind + '|' + id + '|' + src).digest('hex');
|
||||||
|
let modelReady = null; // the service must have the model on disk before it can use it: check, and ask for it once if it is missing
|
||||||
|
async function ensureModel() {
|
||||||
|
const id = cfg.model, enc = id.split('/').map(encodeURIComponent).join('/'), seen = [];
|
||||||
|
const call = async (method, url, body) => { const r = await fetch(cfg.api + url, { method, headers: body ? { 'content-type': 'application/json' } : {}, body, signal: AbortSignal.timeout(30 * 60e3) }); if (r.ok || r.status === 409) return true; seen.push(`${method} ${url}: ${r.status} ${(await r.text()).slice(0, 120)}`); return false; };
|
||||||
|
if ((await fetch(cfg.api + '/v1/models/' + enc, { signal: AbortSignal.timeout(20000) })).ok) return; // already installed
|
||||||
|
// (the download call has differed between versions of the service, so the known forms are tried in turn)
|
||||||
|
if (await call('POST', '/v1/models/' + enc) || await call('POST', '/v1/models', JSON.stringify({ id })) || await call('POST', '/v1/models?model_name=' + encodeURIComponent(id))) return;
|
||||||
|
throw new Error('The transcription service would not download the model ' + id + ' (' + seen.join('; ') + ')');
|
||||||
|
}
|
||||||
|
const modelOnce = () => modelReady || (modelReady = ensureModel().catch(e => { modelReady = null; throw e; }));
|
||||||
|
async function whisper(file, name) {
|
||||||
|
try { await modelOnce(); } catch (e) { throw /would not download/.test(e.message) ? e : new Error(`Could not reach the transcription service at ${cfg.api} (${why(e)}). Is the whisper container running? Check its log.`); }
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', await fs.openAsBlob(file), name || 'audio');
|
||||||
|
form.append('model', cfg.model); form.append('response_format', 'verbose_json'); form.append('timestamp_granularities[]', 'segment');
|
||||||
|
let r; try { r = await fetch(cfg.api + '/v1/audio/transcriptions', { method: 'POST', body: form, signal: AbortSignal.timeout(6 * 3600e3) }); }
|
||||||
|
catch (e) { throw new Error(`Could not reach the transcription service at ${cfg.api} (${why(e)}). Is the whisper container running? Check its log.`); }
|
||||||
|
if (!r.ok) throw new Error('The transcription service said ' + r.status + ': ' + (await r.text()).slice(0, 200));
|
||||||
|
const j = await r.json();
|
||||||
|
return shape((j.segments || []).map(s => ({ t: Math.round(+s.start * 1000), text: clean(s.text) })).filter(x => x.text), 'Transcribed on your server');
|
||||||
|
}
|
||||||
|
/** Bring the audio over from this hub (as the person who asked, with their sign-in) into a temp file. */
|
||||||
|
async function saveAudio(r, tmp) {
|
||||||
|
if (!r.ok || !r.body) throw new Error('Could not read the audio (' + r.status + ')');
|
||||||
|
if ((+r.headers.get('content-length') || 0) > MAX_AUDIO) throw new Error('That audio file is too large to transcribe');
|
||||||
|
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
||||||
|
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
||||||
|
}
|
||||||
|
async function fetchAudio(src, cookie, tmp) {
|
||||||
|
let r; try { r = await fetch('http://127.0.0.1:' + cfg.port + src, { headers: { cookie: cookie || '' }, redirect: 'follow', signal: AbortSignal.timeout(2 * 3600e3) }); }
|
||||||
|
catch (e) { throw new Error(`Could not read the audio from this server (${why(e)})`); }
|
||||||
|
if (!r.ok || !r.body) throw new Error('Could not read the audio (' + r.status + ')');
|
||||||
|
if ((+r.headers.get('content-length') || 0) > MAX_AUDIO) throw new Error('That audio file is too large to transcribe');
|
||||||
|
let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); });
|
||||||
|
await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 }));
|
||||||
|
}
|
||||||
|
function startJob(key, src, cookie, getAudio) {
|
||||||
|
jobs.set(key, { state: 'running', since: Date.now() });
|
||||||
|
chain = chain.then(async () => {
|
||||||
|
const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key);
|
||||||
|
try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(r)); jobs.delete(key); }
|
||||||
|
catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); }
|
||||||
|
finally { fs.rm(tmp, { force: true }, () => {}); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- in the background: new episodes as they are pulled, and the ones already in the library that have no transcript yet
|
||||||
|
const auto = { on: false, keep: 5, tried: new Map(), last: 0, timer: null };
|
||||||
|
const podSrc = id => '/api/podcast/podcastitems/' + id + '/file';
|
||||||
|
async function autoTick() {
|
||||||
|
if (!cfg.api || !auto.on || [...jobs.values()].some(j => j.state === 'running')) return; // (one job at a time: the next round looks again when this one is done)
|
||||||
|
let queued = 0; const feeds = new Map();
|
||||||
|
for (const ep of await podcast.recentEpisodes(auto.keep)) {
|
||||||
|
if (queued >= 2) break;
|
||||||
|
const key = keyFor('pod', 'pod:' + ep.id, podSrc(ep.id)), t = auto.tried.get(key) || { n: 0, at: 0 };
|
||||||
|
if (fs.existsSync(fileOf(key)) || jobs.has(key) || t.n >= 3 || Date.now() - t.at < 6 * 3600e3 * t.n) continue; // (a failing one is retried a few times, further apart each time)
|
||||||
|
auto.tried.set(key, { n: t.n + 1, at: Date.now() });
|
||||||
|
let r = { why: '' }; try { r = await nativeTranscript(ep); } catch {}
|
||||||
|
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); continue; } // (the show publishes one: no need to make one)
|
||||||
|
startJob(key, '', '', () => podcast.episodeAudio(ep.id)); queued++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startAuto({ on, keep }) {
|
||||||
|
auto.on = !!on && !!cfg.api; auto.keep = Math.max(1, Math.min(50, +keep || 5));
|
||||||
|
if (auto.timer || !auto.on) return;
|
||||||
|
auto.timer = setInterval(() => autoTick().catch(e => console.warn('[transcripts]', e.message)), 10 * 60e3); auto.timer.unref();
|
||||||
|
setTimeout(() => autoTick().catch(e => console.warn('[transcripts]', e.message)), 90e3).unref();
|
||||||
|
}
|
||||||
|
function status() { let saved = 0; try { saved = fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json')).length; } catch {} return { enabled: !!cfg.api, auto: auto.on, keep: auto.keep, saved, working: [...jobs.values()].filter(j => j.state === 'running').length, failed: [...jobs.values()].filter(j => j.state === 'error').length }; }
|
||||||
|
|
||||||
|
const SRC_OK = [/^\/api\/podcast\/podcastitems\/[\w-]+\/file$/, /^\/api\/ab\/books\/[^?#\s]+$/]; // only audio this hub already serves to the person
|
||||||
|
const nativeMiss = new Map(); // key -> { t, why }: a show with no transcript is not asked again for a day
|
||||||
|
async function handle(req, res) {
|
||||||
|
const u = new URL(req.url, 'http://x'), q = u.searchParams, key0 = String(q.get('key') || ''), src = String(q.get('src') || '');
|
||||||
|
const kind = key0.startsWith('pod:') ? 'pod' : key0.startsWith('ab:') ? 'ab' : '';
|
||||||
|
if (!kind || key0.length > 200 || !SRC_OK.some(re => re.test(src))) return json(res, 400, { error: 'That cannot be transcribed' });
|
||||||
|
const key = keyFor(kind, key0, src), me = users.current();
|
||||||
|
const base = { canTranscribe: !!cfg.api };
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(fileOf(key))) return json(res, 200, { state: 'ready', ...base, transcript: JSON.parse(fs.readFileSync(fileOf(key), 'utf8')) });
|
||||||
|
const job = jobs.get(key); if (job) return json(res, 200, { state: job.state, since: job.since, error: job.error, ...base });
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
if (!cfg.api) return json(res, 400, { error: 'Transcribing is not set up on this server (see the whisper service in docker-compose.yml)' });
|
||||||
|
startJob(key, src, req.headers.cookie); return json(res, 200, { state: 'running', since: Date.now(), ...base });
|
||||||
|
}
|
||||||
|
let why = '';
|
||||||
|
if (kind === 'pod') {
|
||||||
|
const miss = nativeMiss.get(key);
|
||||||
|
if (miss && miss.t > Date.now() - 6 * 3600e3) why = miss.why;
|
||||||
|
else {
|
||||||
|
let r; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch (e) { r = { why: 'Checking the show\'s feed failed (' + e.message + ').' }; }
|
||||||
|
if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); }
|
||||||
|
why = r.why; nativeMiss.set(key, { t: Date.now(), why });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json(res, 200, { state: 'none', why, ...base });
|
||||||
|
} catch (e) { json(res, e.status || 500, { error: e.message }); }
|
||||||
|
}
|
||||||
|
module.exports = { init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true };
|
||||||
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
||||||
const TABS = ['files', 'office', 'music', 'audio', 'podcast', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
||||||
const ALWAYS = ['home', 'settings'];
|
const ALWAYS = ['home', 'settings'];
|
||||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', audio: 'Audiobooks', podcast: 'Podcast', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
||||||
|
|
||||||
let cache = null;
|
let cache = null;
|
||||||
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Verse By Verse Ministry International (versebyverseministry.org): free verse-by-verse Bible teaching. The ministry publishes every study as a podcast feed
|
||||||
|
// (/bible-studies/<study>/podcast.xml) with one MP3 per lesson, and a handout (PDF) on each lesson's page. The hub reads the study list from the ministry's own
|
||||||
|
// pages and the feeds, sends the browser straight to each lesson's recording (so the ministry's server does the streaming), and keeps each person's place.
|
||||||
|
const users = require('./users');
|
||||||
|
const prog = require('./progress')('vbv-progress.json'); // keys vbv:<study>:<lesson number>
|
||||||
|
|
||||||
|
const BASE = () => (process.env.VBV_URL || 'https://versebyverseministry.org').replace(/\/+$/, '');
|
||||||
|
const HOSTS = () => { const h = new URL(BASE()).hostname; return [h, 'www.' + h, 'media.' + h.replace(/^www\./, ''), 'versebyverseministry.org', 'media.versebyverseministry.org', 'www.versebyverseministry.org']; };
|
||||||
|
const allowed = u => { try { const x = new URL(u); return /^https?:$/.test(x.protocol) && HOSTS().includes(x.hostname); } catch { return false; } };
|
||||||
|
const UA = 'NE-Hub/1.0 (self-hosted; reads the ministry\'s public pages and feeds)';
|
||||||
|
const CATEGORIES = [['new-testament-books', 'New Testament'], ['old-testament-books', 'Old Testament'], ['topical-series', 'Topical'], ['single-teachings', 'Single teachings'], ['womens-teaching', "Women's"], ['youth-resources', 'Youth']];
|
||||||
|
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 unent = s => String(s || '').replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|rsquo|lsquo|ldquo|rdquo|mdash|ndash|nbsp);/gi, (m, e) => ({ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', mdash: '—', ndash: '–', nbsp: ' ' }[e.toLowerCase()] || (e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : +e.slice(1)) : m)));
|
||||||
|
const plain = s => unent(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
async function page(url, max = 3e6) {
|
||||||
|
if (!allowed(url)) throw fail(502, 'Bad address');
|
||||||
|
let r; try { r = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xml,*/*;q=0.5' }, redirect: 'follow', signal: AbortSignal.timeout(15000) }); } catch { throw fail(502, 'Could not reach Verse By Verse Ministry'); }
|
||||||
|
if (!allowed(r.url)) throw fail(502, 'Bad address'); if (r.status === 404) throw fail(404, 'Not found'); if (!r.ok) throw fail(502, 'Verse By Verse Ministry answered ' + r.status);
|
||||||
|
const t = await r.text(); if (t.length > max) throw fail(502, 'Too large'); return t;
|
||||||
|
}
|
||||||
|
const memo = new Map(); // key -> { t, v }
|
||||||
|
async function cached(key, ttl, fn) { const c = memo.get(key); if (c && Date.now() - c.t < ttl) return c.v; const v = await fn(); if (memo.size > 300) memo.clear(); memo.set(key, { t: Date.now(), v }); return v; }
|
||||||
|
|
||||||
|
const studiesAll = () => cached('studies', 3600e3, async () => {
|
||||||
|
const by = new Map();
|
||||||
|
await Promise.all(CATEGORIES.map(async ([c, name]) => {
|
||||||
|
let h; try { h = await page(`${BASE()}/bible-studies/category/${c}`); } catch { return; }
|
||||||
|
for (const m of h.matchAll(/<li class="study-item[\s\S]*?<\/li>/g)) {
|
||||||
|
const card = m[0], slug = (/href="\/bible-studies\/([\w-]+)"/.exec(card) || [])[1]; if (!slug) continue;
|
||||||
|
const imgs = (/srcset="([^"]+)"/.exec(card) || [])[1], cover = ((imgs || '').split(',').pop() || '').trim().split(' ')[0] || (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
|
||||||
|
const s = by.get(slug) || { slug, title: plain((/<h3[^>]*>([\s\S]*?)<\/h3>/.exec(card) || [])[1]), lessons: parseInt((/(\d+)\s+Lessons?/i.exec(card) || [])[1]) || 0, teacher: plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(card) || [])[1]), cover: allowed(cover) ? cover : '', categories: [] };
|
||||||
|
s.categories.push(c); by.set(slug, s);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (!by.size) throw fail(502, 'Could not read the list of studies');
|
||||||
|
return [...by.values()];
|
||||||
|
});
|
||||||
|
const feed = slug => cached('feed:' + slug, 1800e3, async () => {
|
||||||
|
if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study');
|
||||||
|
let x = ''; try { x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6); } catch (e) { if (e.code !== 404) throw e; }
|
||||||
|
const ch = x.replace(/<item>[\s\S]*$/, '');
|
||||||
|
const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)</' + t + '>', 'i').exec(s) || [])[1]);
|
||||||
|
const items = [...x.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /<enclosure[^>]*url="([^"]+)"/.exec(it); return { n: i, title: tag(it, 'title').replace(/^\d+\s+/, '') || 'Lesson ' + (i + 1), description: tag(it, 'description'), summary: tag(it, 'itunes:subtitle'), date: tag(it, 'pubDate'), audio: enc && allowed(unent(enc[1])) ? unent(enc[1]) : '', link: tag(it, 'link') }; }).filter(i => i.audio);
|
||||||
|
if (items.length < 3) { // a few studies have an empty or partial feed; their lessons are all on the study's own page
|
||||||
|
let alt = null; try { alt = await fromPage(slug); } catch (e) { if (!items.length) throw e; }
|
||||||
|
if (alt && alt.lessons.length > items.length) return alt;
|
||||||
|
}
|
||||||
|
if (!items.length) throw fail(404, 'No lessons found');
|
||||||
|
const img = (/<itunes:image href="([^"]+)"/.exec(ch) || [])[1];
|
||||||
|
return { slug, title: tag(ch, 'title'), teacher: tag(ch, 'itunes:author'), description: tag(ch, 'itunes:summary') || tag(ch, 'description'), cover: img && allowed(img) ? img : '', lessons: items };
|
||||||
|
});
|
||||||
|
/** A study whose feed lists nothing: read its lesson list from its page; each lesson's recording is found when it is played. */
|
||||||
|
async function fromPage(slug) {
|
||||||
|
const h = await page(`${BASE()}/bible-studies/${slug}`), meta = n => plain((new RegExp('<meta[^>]+(?:property|name)="' + n + '"[^>]+content="([^"]*)"', 'i').exec(h) || [])[1]);
|
||||||
|
const lessons = [...h.matchAll(/<li class="lesson[^"]*"[\s\S]*?<\/li>/g)].map(m => { const b = m[0], href = (/href="(\/lessons\/[\w-]+)"/.exec(b) || [])[1]; if (!href) return null;
|
||||||
|
const num = plain((/<span class="lesson-number">([\s\S]*?)<\/span>/.exec(b) || [])[1]), sub = plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(b) || [])[1]);
|
||||||
|
return { title: [num, sub].filter(Boolean).join(' \u2013 '), description: sub, summary: '', date: '', audio: '', link: BASE() + href }; }).filter(Boolean).map((l, n) => ({ ...l, n, title: l.title || 'Lesson ' + (n + 1) }));
|
||||||
|
if (!lessons.length) throw fail(404, 'No lessons found for this study');
|
||||||
|
const cover = meta('og:image');
|
||||||
|
return { slug, title: plain((/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(h) || [])[1]) || plain((/<title>([^<|]*)/.exec(h) || [])[1]), teacher: '', description: meta('og:description') || meta('description'), cover: allowed(cover) ? cover : '', lessons };
|
||||||
|
}
|
||||||
|
const lessonAudio = link => cached('audio:' + link, 6 * 3600e3, async () => { const h = await page(link); return [...h.matchAll(/https?:\/\/[^"'\s<>]+\.mp3/gi)].map(m => unent(m[0])).find(allowed) || ''; });
|
||||||
|
const coverUrl = u => u ? '/api/vbv/cover?u=' + encodeURIComponent(u) : '';
|
||||||
|
const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.map(({ audio, link, ...l }) => l) });
|
||||||
|
const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => {
|
||||||
|
const h = await page(lessonUrl); const pdfs = [...h.matchAll(/https?:\/\/[^"'\s<>]+\.pdf/gi)].map(m => unent(m[0])).filter(allowed); return pdfs[0] || '';
|
||||||
|
});
|
||||||
|
const audioUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const a = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!a) throw fail(404, 'This lesson has no audio'); return a; };
|
||||||
|
const notesUrl = async (slug, n) => { const l = (await feed(slug)).lessons[n]; if (!l) throw fail(404, 'No such lesson'); const pdf = l.link && allowed(l.link) ? await notesOf(slug, n, l.link) : ''; if (!pdf) throw fail(404, 'This lesson has no handout'); return pdf; };
|
||||||
|
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/vbv'.length) || '/';
|
||||||
|
try {
|
||||||
|
let m;
|
||||||
|
if (p === '/studies' && req.method === 'GET') {
|
||||||
|
const q = (u.searchParams.get('q') || '').trim().toLowerCase(), cat = u.searchParams.get('category') || '';
|
||||||
|
const list = (await studiesAll()).filter(s => (!cat || s.categories.includes(cat)) && (!q || (s.title + ' ' + s.teacher).toLowerCase().includes(q)));
|
||||||
|
return json(res, 200, { categories: CATEGORIES.map(([id, name]) => ({ id, name })), studies: list.map(s => ({ ...s, cover: coverUrl(s.cover) })) });
|
||||||
|
}
|
||||||
|
if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1])));
|
||||||
|
if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking)
|
||||||
|
const audio = await audioUrl(m[1], +m[2]);
|
||||||
|
res.writeHead(302, { location: audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
|
}
|
||||||
|
if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') {
|
||||||
|
const pdf = await notesUrl(m[1], +m[2]);
|
||||||
|
res.writeHead(302, { location: pdf, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||||
|
}
|
||||||
|
if (p === '/cover' && req.method === 'GET') {
|
||||||
|
const src = u.searchParams.get('u') || ''; if (!allowed(src)) throw fail(404, 'No cover');
|
||||||
|
const r = await fetch(src, { headers: { 'user-agent': UA }, redirect: 'manual', signal: AbortSignal.timeout(10000) }), 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, 'Too large');
|
||||||
|
res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
const code = e.code >= 400 && e.code < 600 ? e.code : 502;
|
||||||
|
if (/^\/notes\//.test(p) && /text\/html/.test(req.headers.accept || '')) { // opened in a new tab: say it in words, with a way back
|
||||||
|
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
||||||
|
return res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lesson handout</title><body style="font:17px/1.5 system-ui;max-width:32em;margin:15vh auto;padding:0 20px;text-align:center"><h2>${code === 404 ? 'This lesson has no handout' : 'The handout could not be loaded'}</h2><p style="color:#666">${code === 404 ? 'Not every lesson comes with notes.' : 'Verse By Verse Ministry did not answer. Try again in a moment.'}</p><p><a href="javascript:history.length>1?history.back():window.close()">Go back</a></p>`);
|
||||||
|
}
|
||||||
|
return json(res, code, { error: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, studyInfo: feed, audioUrl, notesUrl, allowed, UA };
|
||||||
Reference in new issue
Block a user