Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3e57b60bd |
No files matched your search
@@ -11,6 +11,7 @@
|
|||||||
!reading-analytics.js
|
!reading-analytics.js
|
||||||
!reading-access.js
|
!reading-access.js
|
||||||
!moments.js
|
!moments.js
|
||||||
|
!reader-sessions.js
|
||||||
!public/
|
!public/
|
||||||
!public/**
|
!public/**
|
||||||
!greatest/
|
!greatest/
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ARG VCS_REF=development
|
ARG VCS_REF=development
|
||||||
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
|
LABEL org.opencontainers.image.title="MangaCircus" org.opencontainers.image.revision=$VCS_REF
|
||||||
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js ./
|
COPY package.json server.js library.js reading-direction.js chapter-identity.js chapter-dates.js reading-analytics.js reading-access.js catalog.js backup.js moments.js reader-sessions.js ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
COPY greatest ./greatest
|
COPY greatest ./greatest
|
||||||
COPY discovery ./discovery
|
COPY discovery ./discovery
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ Settings has **User** and **Admin** menu groups. User sections cover Reading, Re
|
|||||||
Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections &
|
Notifications and Account. Admin sections cover Scraper, Server & library, Users & access, Collections &
|
||||||
metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)).
|
metadata and Service health. Only admins see administration ([settings navigation](docs/settings-navigation.md)).
|
||||||
|
|
||||||
|
Reader settings (mode, zoom, page tone, skipping) are kept per sign-in: each device or browser keeps its own once you
|
||||||
|
change one there, and a new sign-in starts from the one you used last. **Settings → Reading → Reader settings per
|
||||||
|
session** shows every session's state and turns this off for one shared set ([details](docs/architecture.md#67-reader-settings-per-session)).
|
||||||
|
|
||||||
### Settings (environment variables)
|
### Settings (environment variables)
|
||||||
|
|
||||||
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
|
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
|
||||||
|
|||||||
+28
-9
@@ -130,11 +130,14 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
|||||||
"readingStory": true, // show private retrospective summaries when history exists
|
"readingStory": true, // show private retrospective summaries when history exists
|
||||||
"continueRow": true, // library: "Continue where you left off" row at the top
|
"continueRow": true, // library: "Continue where you left off" row at the top
|
||||||
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
"zoom": { "flow": 1, "page": 1.5, "book": 1, "strip": 0.8 }, // page zoom per mode
|
||||||
"webhook": ""
|
"webhook": "",
|
||||||
}
|
"readerPerSession": true // reader settings per login session (section 6.7)
|
||||||
|
},
|
||||||
|
"readerFrom": "3f9c…" // public id of the session the latest reader settings came from
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000 } },
|
"sessions": { "<sha256 of token>": { "u": "admin", "t": 1790796794000, "seen": 1790796794000,
|
||||||
|
"agent": "<User-Agent>", "reader": { …locked reader settings, only after a change… } } },
|
||||||
"progress": { "admin": { "Moon Garden": { "1/2": {
|
"progress": { "admin": { "Moon Garden": { "1/2": {
|
||||||
"v": "c0002 Roots [MangaDex]", // version read
|
"v": "c0002 Roots [MangaDex]", // version read
|
||||||
"p": 4, "n": 14, // page index in that version (all pages), page count
|
"p": 4, "n": 14, // page index in that version (all pages), page count
|
||||||
@@ -177,7 +180,7 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
|||||||
|
|
||||||
| Path | Handling |
|
| Path | Handling |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res }` and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) |
|
| `/api/*` | `routes["<METHOD> <path>"]`. Handlers get `{ u, b, q, req, res, sess, sk }` (`sess` = the login session record, `sk` its key) and return an object, which is sent as JSON. Throwing `HttpError(code, message, extra)` sends `{ error, ...extra }` with that status (for example `nextAllowedAt` on a discovery `429`) |
|
||||||
| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` |
|
| `/lib/*` | Image files from `LIBRARY`. Requires a session, allows image extensions only, and the path must resolve inside `LIBRARY`. `ETag` + `private, max-age=86400` |
|
||||||
| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
|
| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
|
||||||
|
|
||||||
@@ -194,13 +197,14 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js
|
|||||||
| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) |
|
| `POST /api/login` `{name, password}` | public | Starts a session (cookie `sid`) |
|
||||||
| `POST /api/logout` | user | Ends the session |
|
| `POST /api/logout` | user | Ends the session |
|
||||||
| `GET /api/me` | user | Name, admin flag, settings, unread count |
|
| `GET /api/me` | user | Name, admin flag, settings, unread count |
|
||||||
| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above |
|
| `POST /api/settings` `{…partial}` | user | Validated update of the settings keys above; reader keys go to the current session (6.7) |
|
||||||
|
| `GET /api/reader-sessions` | user | `{ enabled, current, latestFrom, latest, sessions: [{ id, device, created, seen, current, locked, reader }] }` |
|
||||||
|
| `POST /api/reader-sessions` `{id, action}` | user | `follow`: the session drops its own reader settings and follows the latest; `latest`: its settings become the latest |
|
||||||
| `POST /api/password` `{old, new}` | user | Change password |
|
| `POST /api/password` `{old, new}` | user | Change password |
|
||||||
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates |
|
| `GET /api/library` | user | Manga list with covers, counts, the user's read count, unread notifications, last read, and optional personal Reading Story aggregates |
|
||||||
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
|
| `GET /api/manga?m=` | user | Manga details, books, chapters with ranked versions, progress, optional chapter-time summaries, and `continue` target |
|
||||||
| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, `latest` (as in `/api/progress/latest`), optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read |
|
| `GET /api/chapter?m=&c=[&v=]` | user | Pages (`u, w, h, t, r`), direction, versions, prev/next, progress, optional Active Reading Clock summary, plus all books and chapters of the manga (with the user's read state) for the chapter picker. Also marks that chapter's notifications read |
|
||||||
| `POST /api/progress` `{m, c, v, p, n, done, shift[, tab, seen]}` | user | Save the reading position. With `seen`, a save is refused (`{ok:false, stale:true, latest}`) when another tab saved a newer position of this manga since; otherwise `{ok:true, t}` |
|
| `POST /api/progress` `{m, c, v, p, n, done, shift}` | user | Save the reading position |
|
||||||
| `GET /api/progress/latest?m=` | user | The manga's newest saved position (`latest`: `t, w, c, label, v, p, n, done, continue`) or `null` |
|
|
||||||
| `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease |
|
| `POST /api/reading/pulse` `{m, c, v, session, seq, …deltas}` | user | When opted in, idempotently merge a clamped active-time pulse, verified page coverage and input totals under a single-reader lease |
|
||||||
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
|
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
|
||||||
| `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress |
|
| `POST /api/reading/delete` `{m}` or `{all:true,password}` | user | Delete one manga's reading history, or all history after password confirmation, without changing normal reading progress |
|
||||||
@@ -270,6 +274,22 @@ The reader alone talks to AniList for discovery ([discovery.md](discovery.md)).
|
|||||||
(all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`.
|
(all reasons, strongest first) next to `reason`; seed evidence carries `fetchedAt`.
|
||||||
- `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server).
|
- `DISCOVERY_ANILIST_URL` overrides the AniList endpoint (tests point it at a local fixture server).
|
||||||
|
|
||||||
|
### 6.7 Reader settings per session
|
||||||
|
|
||||||
|
The reader keys (`startMode`, `lastMode`, `adapt`, `skipExtras`, `skipCovers`, `flowPageGap`, `pageTone`, `zoom`)
|
||||||
|
can differ per login session, so a phone and a desktop keep their own mode and zoom (`reader-sessions.js`):
|
||||||
|
|
||||||
|
- `user.settings` holds the **latest** reader set. A session without its own set follows it.
|
||||||
|
- The first reader change in a session copies the latest set into `session.reader` with the change applied: the session
|
||||||
|
is now **locked** and keeps that set whatever other sessions do. The change also becomes the latest set.
|
||||||
|
- Loading a page (`GET /api/me`) from a locked session makes its set the latest again, so a new sign-in starts from the
|
||||||
|
session used last. A new sign-in has no set of its own until it changes something.
|
||||||
|
- Other settings (Reading Story, dates, discovery, webhook, …) stay per user.
|
||||||
|
- `readerPerSession: false` (Settings → Reading) brings back one shared set: changes go to `user.settings` and apply to
|
||||||
|
every session. Locked sets are kept and apply again when the switch is turned back on.
|
||||||
|
|
||||||
|
Settings → Reading → *Reader settings per session* lists the user's sessions (device from the User-Agent, sign-in and last
|
||||||
|
activity, own or following) with "Follow latest again" and "Use for new sessions".
|
||||||
## 7. Frontend
|
## 7. Frontend
|
||||||
|
|
||||||
### 7.1 Pages
|
### 7.1 Pages
|
||||||
@@ -353,7 +373,6 @@ mode = adapt && intended ∉ compatible ? compatible[0] : intended
|
|||||||
- Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`).
|
- Saved 700 ms after the position settles, and on `pagehide` (a `fetch` with `keepalive`).
|
||||||
- Stores the page index in the full page list, so skipping extras doesn't corrupt it.
|
- Stores the page index in the full page list, so skipping extras doesn't corrupt it.
|
||||||
- On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start.
|
- On open, the saved page is used for the same version, and mapped proportionally for another version. A finished chapter reopens at the start.
|
||||||
- **Stale reading sessions.** A tab left open on one device (or restored by the browser) must not move "Continue" back after the user read on elsewhere. Each browser tab has an id (`sessionStorage`) that its saves store as `w`; reader links carry `at`, the newest progress time the linking page knew (`continue.at`), and the reader keeps `at` current in its address bar so a reload or restored tab still knows it. A link without `at` (bookmark, notification) counts as a deliberate open. When the manga's newest position is later than `at` and came from another tab, the reader saves nothing and asks: jump to the newest position, or keep reading here, which makes this chapter the head again. It checks on open, when the tab becomes visible again, and with every save (the server refuses the stale save). The comparison is by save time, not chapter order; the question names no device.
|
|
||||||
|
|
||||||
**Active Reading Clock (opt-in):**
|
**Active Reading Clock (opt-in):**
|
||||||
- Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds.
|
- Runs only while the reader is visible and focused, a sufficiently exposed page is decoded, a server lease is held, and accepted reader input occurred in the last 50 seconds.
|
||||||
|
|||||||
+2
-8
@@ -387,6 +387,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
|||||||
.login input { width: 100%; }
|
.login input { width: 100%; }
|
||||||
.saved { color: #7fd3a0; font-size: 13px; }
|
.saved { color: #7fd3a0; font-size: 13px; }
|
||||||
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
|
.reading-data { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
|
.reader-sessions { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
|
.reader-sessions li { display: grid; gap: 4px; padding: 10px 0; border-top: 1px solid var(--line); overflow-wrap: anywhere; }
|
||||||
.reading-data > summary { cursor: pointer; font-weight: 600; }
|
.reading-data > summary { cursor: pointer; font-weight: 600; }
|
||||||
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
.reading-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
.reading-danger p { margin: 3px 0 8px; }
|
.reading-danger p { margin: 3px 0 8px; }
|
||||||
@@ -429,14 +431,6 @@ body.reader { position: fixed; top: 0; left: 0; right: 0; bottom: 0; overflow: h
|
|||||||
.reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; }
|
.reading-gate-message[data-tone="retry"] { border-color: #9b7233; background: #382b1a; color: #ffdaa0; }
|
||||||
.reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; }
|
.reading-gate-message[data-tone="error"] { border-color: #944c55; background: #3c2025; color: #ffc0c6; }
|
||||||
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
|
@media (max-width: 660px) { .reading-gate { margin: 28px 12px 100px; padding: 18px; } }
|
||||||
/* Stale reading session: a newer position was saved elsewhere since this page was opened. */
|
|
||||||
.stale-session { position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: 16px; background: rgba(0, 0, 0, .6); }
|
|
||||||
.stale-box { box-sizing: border-box; width: min(440px, 100%); padding: 20px; border-radius: 12px; background: var(--card);
|
|
||||||
border: 1px solid var(--line); box-shadow: 0 12px 40px rgba(0, 0, 0, .6); }
|
|
||||||
.stale-box h2 { margin: 0 0 8px; font-size: 18px; }
|
|
||||||
.stale-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 10px; }
|
|
||||||
.stale-actions .btn { flex: 1 1 auto; justify-content: center; text-align: center; }
|
|
||||||
.pg { position: absolute; overflow: hidden; background: #19191c; }
|
.pg { position: absolute; overflow: hidden; background: #19191c; }
|
||||||
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
|
.pg img { display: block; width: 100%; height: 100%; -webkit-user-drag: none; }
|
||||||
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
#canvas[data-page-tone="inverted"] .pg img { filter: invert(1); }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`),
|
h('div', { class: 'info' }, h('h1', {}, c.title), h('p', { class: 'muted' }, `${c.members.length} manga · ${c.read}/${c.chapters} chapters read · ${c.unread} new`),
|
||||||
c.description ? h('p', {}, c.description) : null,
|
c.description ? h('p', {}, c.description) : null,
|
||||||
c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null,
|
c.reading.activeMs ? h('p', {}, `${readingDuration(c.reading.activeMs)} together`) : null,
|
||||||
h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v, c.continue.at) }, c.continue.text),
|
h('div', { class: 'actions' }, h('a', { class: 'btn primary', href: readUrl(c.continue.m, c.continue.c, c.continue.v) }, c.continue.text),
|
||||||
me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null))));
|
me.admin ? h('a', { class: 'btn', href: 'collections.html?' + qsa({ id: c.id }) }, 'Edit collection') : null))));
|
||||||
main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m =>
|
main.append(h('h2', {}, 'Reading order'), h('div', { class: 'collection-members' }, c.members.map(m =>
|
||||||
h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null,
|
h('a', { class: 'collection-member', href: mangaUrl(m.id) }, m.cover ? h('img', { src: m.cover, alt: '', loading: 'lazy' }) : null,
|
||||||
|
|||||||
+1
-16
@@ -39,20 +39,6 @@ const store = {
|
|||||||
get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } },
|
get(k) { try { return localStorage.getItem('mc.' + k); } catch (e) { return null; } },
|
||||||
set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } },
|
set(k, v) { try { localStorage.setItem('mc.' + k, v); } catch (e) { /* private mode */ } },
|
||||||
};
|
};
|
||||||
// One id per browser tab (it survives navigation and reloads in that tab): progress saves carry it, so
|
|
||||||
// a reader can tell its own saves from those of another tab or device.
|
|
||||||
const tabId = (() => {
|
|
||||||
let id = null;
|
|
||||||
return () => {
|
|
||||||
if (id) return id;
|
|
||||||
try { id = sessionStorage.getItem('mc.tab'); } catch (e) { /* storage blocked */ }
|
|
||||||
if (!/^[a-zA-Z0-9_-]{8,40}$/.test(id || '')) {
|
|
||||||
id = Array.from(crypto.getRandomValues(new Uint8Array(12)), b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
try { sessionStorage.setItem('mc.tab', id); } catch (e) { /* this page keeps it in memory */ }
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
function ago(t) {
|
function ago(t) {
|
||||||
const s = (Date.now() - t) / 1000;
|
const s = (Date.now() - t) / 1000;
|
||||||
@@ -74,8 +60,7 @@ function readingDuration(ms, compact = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); };
|
const qsa = obj => { const p = new URLSearchParams(); for (const k in obj) if (obj[k] != null && obj[k] !== '') p.set(k, obj[k]); return p.toString(); };
|
||||||
// `at`: the manga's latest progress time the linking page knew, so the reader can tell it opened from a stale page.
|
const readUrl = (m, c, v) => 'read.html?' + qsa({ m, c, v });
|
||||||
const readUrl = (m, c, v, at) => 'read.html?' + qsa({ m, c, v, at: at || null });
|
|
||||||
const mangaUrl = m => 'manga.html?' + qsa({ m });
|
const mangaUrl = m => 'manga.html?' + qsa({ m });
|
||||||
const libraryUrlForTags = tags => {
|
const libraryUrlForTags = tags => {
|
||||||
const p = new URLSearchParams();
|
const p = new URLSearchParams();
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@
|
|||||||
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
|
data.reading.rereads ? h('span', { class: 'story-pill' }, `${data.reading.rereads} reread${data.reading.rereads === 1 ? '' : 's'}`) : null),
|
||||||
h('div', { class: 'reading-summary-links' },
|
h('div', { class: 'reading-summary-links' },
|
||||||
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
|
most ? h('a', { href: mangaUrl(most.id) }, `Most time with ${most.title}`) : null,
|
||||||
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId, null, latest.lastRead) }, `Continue ${latest.title} →`) : null)));
|
latest && data.reading.lastChapterId ? h('a', { href: readUrl(latest.id, data.reading.lastChapterId) }, `Continue ${latest.title} →`) : null)));
|
||||||
}
|
}
|
||||||
main.append(
|
main.append(
|
||||||
h('div', { class: 'library-controls' },
|
h('div', { class: 'library-controls' },
|
||||||
|
|||||||
+6
-6
@@ -48,7 +48,7 @@
|
|||||||
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
|
h('div', { class: 'chips' }, tags.map(tag => h('a', { class: 'chip chip-link', href: libraryUrlForTags([tag]), title: `Show manga tagged ${tag}` }, tag))),
|
||||||
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
|
meta.description ? h('p', { class: 'desc' }, meta.description) : null,
|
||||||
h('div', { class: 'actions' },
|
h('div', { class: 'actions' },
|
||||||
M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v, M.continue.at) }, M.continue.text) : null,
|
M.continue ? h('a', { class: 'btn primary', href: readUrl(M.id, M.continue.c, M.continue.v) }, M.continue.text) : null,
|
||||||
follow))));
|
follow))));
|
||||||
|
|
||||||
let storyRhythm = null;
|
let storyRhythm = null;
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
|
storyStat(M.reading.typicalChapterMs ? readingDuration(M.reading.typicalChapterMs) : '—', 'typical chapter'),
|
||||||
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
|
storyStat(M.reading.rereads, `reread${M.reading.rereads === 1 ? '' : 's'}`)),
|
||||||
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
|
lastChapter ? h('p', { class: 'story-last muted' }, 'Last timed ',
|
||||||
h('a', { href: readUrl(M.id, lastChapter.id, null, M.continue?.at) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
|
h('a', { href: readUrl(M.id, lastChapter.id) }, `${lastChapter.label}${lastChapter.title ? ' · ' + lastChapter.title : ''}`),
|
||||||
` · ${ago(M.reading.updatedAt)}`) : null,
|
` · ${ago(M.reading.updatedAt)}`) : null,
|
||||||
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
|
h('div', { class: 'story-rhythm-head' }, h('b', {}, 'Reading rhythm'), h('span', { class: 'muted small' }, 'Completed chapters · active time')),
|
||||||
storyRhythm,
|
storyRhythm,
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
const passes = chapter.reading.completedPasses;
|
const passes = chapter.reading.completedPasses;
|
||||||
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
|
const title = `${label} · ${readingDuration(value)}${passes > 1 ? ` · ${passes} timed reads · ${readingDuration(chapter.reading.totalActiveMs)} total` : ''}`;
|
||||||
storyRhythm.append(h('a', {
|
storyRhythm.append(h('a', {
|
||||||
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id, null, M.continue?.at), title,
|
class: 'rhythm-bar', role: 'listitem', href: readUrl(M.id, chapter.id), title,
|
||||||
'aria-label': title, style: `height:${height}px`,
|
'aria-label': title, style: `height:${height}px`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; }
|
if (done) { await api('api/unread', { m: M.id, c: ch.id }); ch.progress = null; ch.readAt = null; }
|
||||||
else {
|
else {
|
||||||
const version = ch.versions[0];
|
const version = ch.versions[0];
|
||||||
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true, tab: tabId() });
|
await api('api/progress', { m: M.id, c: ch.id, v: version.id, p: version.pages - 1, n: version.pages, done: true });
|
||||||
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
|
ch.progress = { v: version.id, p: version.pages - 1, n: version.pages, done: true };
|
||||||
ch.readAt = { at: Date.now(), approx: false };
|
ch.readAt = { at: Date.now(), approx: false };
|
||||||
}
|
}
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
const timing = chapterTiming(ch), released = chapterRelease(ch.dates);
|
const timing = chapterTiming(ch), released = chapterRelease(ch.dates);
|
||||||
wrap.textContent = '';
|
wrap.textContent = '';
|
||||||
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
|
wrap.append(h('div', { class: 'ch' + (done ? ' read' : '') },
|
||||||
h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id, null, M.continue?.at) },
|
h('a', { class: 'ch-main' + (timing ? ' has-time' : '') + (M.chapterDates?.release ? ' has-date' : ''), href: readUrl(M.id, ch.id) },
|
||||||
h('span', { class: 'ch-label' }, ch.label),
|
h('span', { class: 'ch-label' }, ch.label),
|
||||||
h('span', { class: 'ch-pages' }, `${pages} p.`),
|
h('span', { class: 'ch-pages' }, `${pages} p.`),
|
||||||
M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' },
|
M.chapterDates?.release ? h('span', { class: 'ch-date' + (released ? ' tier-' + released.tier : ''), title: released ? released.title : 'No date known', 'aria-label': released ? released.title : 'No date known' },
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
|
h('button', { class: version.my > 0 ? 'on' : '', title: 'Better', onclick: () => vote(1) }, '▲'),
|
||||||
h('b', {}, version.score),
|
h('b', {}, version.score),
|
||||||
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
|
h('button', { class: version.my < 0 ? 'on' : '', title: 'Worse', onclick: () => vote(-1) }, '▼')),
|
||||||
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id, M.continue?.at) }, 'Read')));
|
h('a', { class: 'btn small', href: readUrl(M.id, ch.id, version.id) }, 'Read')));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
draw();
|
draw();
|
||||||
|
|||||||
+7
-62
@@ -155,55 +155,6 @@
|
|||||||
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
|
// A link to a page (e.g. "Open in reader" from a moment) wins over the saved position.
|
||||||
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
|
if (P.get('p') != null) { const k = list.findIndex(x => x.i >= +P.get('p')); if (k >= 0) cur = k; }
|
||||||
|
|
||||||
// Stale reading sessions: a tab left open (or restored by the browser) on another device must not move
|
|
||||||
// the "Continue" point back. `seen` is the newest progress time this tab knows: the link's `at`, kept
|
|
||||||
// current in the address bar, so a reload or restored tab still knows it. Without `at` the open was a
|
|
||||||
// deliberate one (bookmark, notification) and starts from the current head.
|
|
||||||
const tab = tabId();
|
|
||||||
let seen = Number(P.get('at')) || (D.latest ? D.latest.t : 0), stale = null, staleCard = null;
|
|
||||||
const chapterUrl = (c, v) => readUrl(D.manga.id, c, v, seen);
|
|
||||||
const newer = latest => !!latest && latest.t > seen && latest.w !== tab;
|
|
||||||
function markSeen(t) {
|
|
||||||
seen = Math.max(seen, t || 0);
|
|
||||||
const q = new URLSearchParams(location.search);
|
|
||||||
q.set('at', seen);
|
|
||||||
history.replaceState(history.state, '', '?' + q);
|
|
||||||
}
|
|
||||||
// Another tab or device saved a newer position. The same place is no conflict; anything else asks.
|
|
||||||
function checkLatest(latest) {
|
|
||||||
if (!newer(latest)) return;
|
|
||||||
if (latest.c === D.chapter.id && list[cur] && latest.p === list[cur].i) { markSeen(latest.t); return; }
|
|
||||||
showStale(latest);
|
|
||||||
}
|
|
||||||
function showStale(latest) {
|
|
||||||
stale = latest;
|
|
||||||
clearTimeout(saveTimer);
|
|
||||||
if (staleCard) staleCard.remove();
|
|
||||||
const where = latest.done ? `${latest.label} (finished)` : `${latest.label} · page ${latest.p + 1}`;
|
|
||||||
const go = h('a', { class: 'btn primary', href: readUrl(D.manga.id, latest.continue.c, latest.continue.v, latest.t) }, latest.continue.text);
|
|
||||||
const stay = h('button', { class: 'btn', type: 'button' }, 'Keep reading here');
|
|
||||||
stay.onclick = () => {
|
|
||||||
staleCard.remove(); staleCard = null; stale = null;
|
|
||||||
markSeen(latest.t);
|
|
||||||
saveProgress();
|
|
||||||
stage.focus({ preventScroll: true });
|
|
||||||
};
|
|
||||||
staleCard = h('div', { class: 'stale-session', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'stale-title' },
|
|
||||||
h('div', { class: 'stale-box' },
|
|
||||||
h('h2', { id: 'stale-title' }, 'You have read further since'),
|
|
||||||
h('p', {}, 'This page is an older reading session. Your newest reading position is ', h('b', {}, where), ` (${ago(latest.t)}).`),
|
|
||||||
h('div', { class: 'stale-actions' }, go, stay),
|
|
||||||
h('p', { class: 'muted small' }, `Keeping ${D.chapter.label} makes it your “Continue” point again.`)));
|
|
||||||
document.body.append(staleCard);
|
|
||||||
go.focus();
|
|
||||||
}
|
|
||||||
async function recheck() {
|
|
||||||
if (stale || document.hidden) return;
|
|
||||||
try { checkLatest((await api('api/progress/latest?' + qsa({ m: D.manga.id }))).latest); } catch { /* offline: the next save checks again */ }
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', recheck);
|
|
||||||
window.addEventListener('pageshow', e => { if (e.persisted) recheck(); });
|
|
||||||
|
|
||||||
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
|
// Book mode: pages pair up inside each run of single pages. Runs restart after every double page / cover,
|
||||||
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
|
// so one missing pair never shifts the rest of the chapter. "Shift" makes a run start with a lone page.
|
||||||
function computeSpreads() {
|
function computeSpreads() {
|
||||||
@@ -289,7 +240,7 @@
|
|||||||
const n = D.next;
|
const n = D.next;
|
||||||
return h('div', { class: 'end' }, h('div', { class: 'endbox' },
|
return h('div', { class: 'end' }, h('div', { class: 'endbox' },
|
||||||
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
|
h('div', { class: 'muted' }, 'End of ' + D.chapter.label + (D.chapter.title ? ' · ' + D.chapter.title : '')),
|
||||||
n ? h('a', { class: 'btn primary', href: chapterUrl(n.id), onclick: e => { e.currentTarget.href = chapterUrl(n.id); saveProgress(true); } }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
|
n ? h('a', { class: 'btn primary', href: readUrl(D.manga.id, n.id), onclick: () => saveProgress(true) }, `Next: ${n.label}${n.title ? ' · ' + n.title : ''} ›`)
|
||||||
: h('div', {}, 'You are up to date.'),
|
: h('div', {}, 'You are up to date.'),
|
||||||
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
|
h('a', { class: 'btn', href: mangaUrl(D.manga.id) }, 'Back to ' + D.manga.title)));
|
||||||
}
|
}
|
||||||
@@ -638,7 +589,7 @@
|
|||||||
function goChapter(c) {
|
function goChapter(c) {
|
||||||
if (!c) return toast('You are up to date');
|
if (!c) return toast('You are up to date');
|
||||||
saveProgress(true);
|
saveProgress(true);
|
||||||
location.href = chapterUrl(c.id);
|
location.href = readUrl(D.manga.id, c.id);
|
||||||
}
|
}
|
||||||
// Flow: jump to the top of the next page, but never further than ~one screen.
|
// Flow: jump to the top of the next page, but never further than ~one screen.
|
||||||
function flowStep(dir) {
|
function flowStep(dir) {
|
||||||
@@ -750,13 +701,8 @@
|
|||||||
}
|
}
|
||||||
function saveProgress(keepalive) {
|
function saveProgress(keepalive) {
|
||||||
clearTimeout(saveTimer);
|
clearTimeout(saveTimer);
|
||||||
if (stale) return; // nothing is saved until the reader chose where to go on
|
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift] });
|
||||||
const body = JSON.stringify({ m: D.manga.id, c: D.chapter.id, v: D.version, p: list[cur].i, n: all.length, done, shift: [...shift], tab, seen });
|
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body }).catch(() => {});
|
||||||
fetch('api/progress', { method: 'POST', keepalive: !!keepalive, headers: { 'Content-Type': 'application/json' }, body })
|
|
||||||
.then(r => r.json()).then(r => {
|
|
||||||
if (r.stale) checkLatest(r.latest);
|
|
||||||
else if (r.ok && r.t) markSeen(r.t);
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
addEventListener('pagehide', () => saveProgress(true));
|
addEventListener('pagehide', () => saveProgress(true));
|
||||||
|
|
||||||
@@ -771,17 +717,17 @@
|
|||||||
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
|
groups[c.book].append(h('option', { value: c.id }, `${c.label}${c.title ? ' · ' + c.title : ''}${c.extra ? ' (extra)' : ''}${c.done ? ' ✓' : ''}`));
|
||||||
}
|
}
|
||||||
chsel.value = D.chapter.id;
|
chsel.value = D.chapter.id;
|
||||||
chsel.onchange = () => { saveProgress(true); location.href = chapterUrl(chsel.value); };
|
chsel.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, chsel.value); };
|
||||||
$('#back').href = mangaUrl(D.manga.id);
|
$('#back').href = mangaUrl(D.manga.id);
|
||||||
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
|
for (const [id, c] of [['#prevch', D.prev], ['#nextch', D.next]]) {
|
||||||
const a = $(id);
|
const a = $(id);
|
||||||
if (c) { a.href = chapterUrl(c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => { a.href = chapterUrl(c.id); saveProgress(true); }; }
|
if (c) { a.href = readUrl(D.manga.id, c.id); a.title = c.label + (c.title ? ' · ' + c.title : ''); a.onclick = () => saveProgress(true); }
|
||||||
else a.classList.add('disabled');
|
else a.classList.add('disabled');
|
||||||
}
|
}
|
||||||
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
|
for (const v of D.versions) ver.append(h('option', { value: v.id }, (v.source || v.id) + (v.score ? ` (${v.score > 0 ? '+' : ''}${v.score})` : '')));
|
||||||
ver.value = D.version;
|
ver.value = D.version;
|
||||||
ver.hidden = D.versions.length < 2;
|
ver.hidden = D.versions.length < 2;
|
||||||
ver.onchange = () => { saveProgress(true); location.href = chapterUrl(D.chapter.id, ver.value); };
|
ver.onchange = () => { saveProgress(true); location.href = readUrl(D.manga.id, D.chapter.id, ver.value); };
|
||||||
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
|
for (const b of modeBtns) b.onclick = () => { if (b.dataset.mode !== mode) setMode(b.dataset.mode, true); };
|
||||||
toneButton.onclick = cycleTone;
|
toneButton.onclick = cycleTone;
|
||||||
$('#moment').onclick = saveMoment;
|
$('#moment').onclick = saveMoment;
|
||||||
@@ -802,7 +748,6 @@
|
|||||||
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
|
for (const bar of [$('#top'), $('#bot')]) bar.addEventListener('click', e => { if (e.target.closest('button')) stage.focus({ preventScroll: true }); });
|
||||||
|
|
||||||
setMode(mode, false);
|
setMode(mode, false);
|
||||||
if (newer(D.latest)) checkLatest(D.latest); else markSeen(seen);
|
|
||||||
if (S.readingClock) readingClock = createReadingClock({
|
if (S.readingClock) readingClock = createReadingClock({
|
||||||
button: $('#readclock'),
|
button: $('#readclock'),
|
||||||
panel: $('#clockpanel'),
|
panel: $('#clockpanel'),
|
||||||
|
|||||||
@@ -15,11 +15,61 @@
|
|||||||
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
||||||
shell.titleRow.append(saved);
|
shell.titleRow.append(saved);
|
||||||
let savedTimer = 0;
|
let savedTimer = 0;
|
||||||
|
let refreshSessions = () => {};
|
||||||
async function save(patch) {
|
async function save(patch) {
|
||||||
Object.assign(S, (await api('api/settings', patch)).settings);
|
Object.assign(S, (await api('api/settings', patch)).settings);
|
||||||
saved.hidden = false;
|
saved.hidden = false;
|
||||||
clearTimeout(savedTimer);
|
clearTimeout(savedTimer);
|
||||||
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
savedTimer = setTimeout(() => { saved.hidden = true; }, 1500);
|
||||||
|
refreshSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which login session holds which reader settings (reader-sessions.js on the server).
|
||||||
|
function readerSessionsCard() {
|
||||||
|
const TONE = { original: 'Original', inverted: 'Inverted', sepia: 'Sepia', dim: 'Dim', 'extra-dim': 'Extra dim' };
|
||||||
|
const START = { last: 'last mode', flow: 'always Flow', page: 'always Page', book: 'always Book' };
|
||||||
|
const summary = r => [`${LABEL[r.lastMode] || r.lastMode} (opens: ${START[r.startMode] || r.startMode})`, `tone ${TONE[r.pageTone] || r.pageTone}`,
|
||||||
|
'zoom ' + (Object.keys(r.zoom || {}).length ? Object.entries(r.zoom).map(([k, z]) => `${LABEL[k] || k} ${Math.round(z * 100)}%`).join(', ') : 'fit'),
|
||||||
|
r.skipExtras ? 'skips extras' : null, r.skipCovers ? 'skips covers' : null, r.flowPageGap === false ? 'no Flow gap' : null].filter(Boolean).join(' · ');
|
||||||
|
const state = h('div', {}, h('p', { class: 'muted' }, 'Loading sessions…'));
|
||||||
|
const message = h('span', { class: 'small' });
|
||||||
|
async function act(id, action, current) {
|
||||||
|
try {
|
||||||
|
draw(await api('api/reader-sessions', { id, action }));
|
||||||
|
// This page's controls show this session's settings; reload them when they changed.
|
||||||
|
if (current && action === 'follow') location.reload();
|
||||||
|
} catch (error) { message.textContent = error.message; message.className = 'small error'; }
|
||||||
|
}
|
||||||
|
function draw(o) {
|
||||||
|
const current = o.sessions.find(s => s.current), source = o.sessions.find(s => s.id === o.latestFrom);
|
||||||
|
const latestText = !o.latestFrom ? 'one shared set (changed while per-session settings were off, or never changed)'
|
||||||
|
: source ? (source.current ? 'this session' : `${source.device}, active ${ago(source.seen)}`) : 'a session that has since signed out';
|
||||||
|
const intent = !o.enabled ? 'Off: every session uses the same reader settings, and a change applies everywhere.'
|
||||||
|
: current?.locked ? 'This session has its own reader settings. Changes here stay here and become the starting point for new sessions.'
|
||||||
|
: 'This session follows the latest reader settings. Your first change here gives it its own copy, which stays put when other sessions change theirs.';
|
||||||
|
state.replaceChildren(
|
||||||
|
h('p', {}, h('b', {}, 'Now: '), intent),
|
||||||
|
o.enabled ? h('p', { class: 'muted small' }, 'New sessions start from the latest settings: ', h('b', {}, summary(o.latest)), ` — from ${latestText}.`)
|
||||||
|
: h('p', { class: 'muted small' }, 'Shared settings: ', h('b', {}, summary(o.latest))),
|
||||||
|
h('ul', { class: 'reader-sessions' }, o.sessions.map(s => h('li', {},
|
||||||
|
h('div', {}, h('b', {}, s.device), s.current ? ' (this session)' : '', ' · ',
|
||||||
|
h('span', { class: 'muted small' }, `signed in ${new Date(s.created).toLocaleDateString()}, active ${ago(s.seen)}`)),
|
||||||
|
h('div', { class: 'small' }, !o.enabled ? 'Uses the shared settings' + (s.locked ? ' (its own copy is kept for when this is on again)' : '')
|
||||||
|
: s.locked ? 'Own settings: ' + summary(s.reader) : 'Follows the latest settings'),
|
||||||
|
o.enabled ? h('div', { class: 'row' },
|
||||||
|
s.locked ? h('button', { class: 'btn small', onclick: () => act(s.id, 'follow', s.current) }, 'Follow latest again') : null,
|
||||||
|
s.locked && s.id !== o.latestFrom ? h('button', { class: 'btn small', onclick: () => act(s.id, 'latest', s.current) }, 'Use for new sessions') : null) : null))),
|
||||||
|
message);
|
||||||
|
}
|
||||||
|
refreshSessions = () => api('api/reader-sessions').then(draw, error => { message.textContent = error.message; message.className = 'small error'; });
|
||||||
|
refreshSessions();
|
||||||
|
return h('section', { class: 'card' },
|
||||||
|
h('h2', {}, 'Reader settings per session'),
|
||||||
|
// The controls above switch between this session's set and the shared one, so reload them.
|
||||||
|
h('label', { class: 'check' },
|
||||||
|
h('input', { type: 'checkbox', checked: !!S.readerPerSession, onchange: async e => { await save({ readerPerSession: e.target.checked }); location.reload(); } }),
|
||||||
|
h('span', {}, 'Keep reader settings per session', h('div', { class: 'muted small' }, 'Each sign-in (device or browser) keeps its own reader mode, zoom, tone and the other reader preferences above once you change one there. A new sign-in copies the settings of the session you used last. Turn this off for one shared set.'))),
|
||||||
|
state);
|
||||||
}
|
}
|
||||||
const check = (key, text, hint) => h('label', { class: 'check' },
|
const check = (key, text, hint) => h('label', { class: 'check' },
|
||||||
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
||||||
@@ -90,6 +140,8 @@
|
|||||||
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
h('button', { class: 'btn small', onclick: async () => { await save({ zoom: {} }); zoomText.textContent = zooms(); } }, 'Reset to fit'),
|
||||||
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
h('div', { class: 'muted small' }, '100% = the page fits the screen. Change it in the reader with Ctrl + mouse wheel, a pinch, or the + / - keys.'))));
|
||||||
|
|
||||||
|
section('user/reading').append(readerSessionsCard());
|
||||||
|
|
||||||
section('user/reading').append(h('section', { class: 'card' },
|
section('user/reading').append(h('section', { class: 'card' },
|
||||||
h('h2', {}, 'Library page'),
|
h('h2', {}, 'Library page'),
|
||||||
check('continueRow', 'Show "Continue where you left off" at the top',
|
check('continueRow', 'Show "Continue where you left off" at the top',
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
/* Reader settings per login session (docs/architecture.md, "Reader settings per session").
|
||||||
|
*
|
||||||
|
* The user's own reader settings (`user.settings`) are the "latest" set. A session that never changed a
|
||||||
|
* reader setting follows the latest set; the first change copies the latest set into the session and
|
||||||
|
* locks it there, and also makes it the new latest set. Using a locked session makes its set the latest
|
||||||
|
* again, so a new login starts from the last active session. With `readerPerSession: false` there is
|
||||||
|
* one set per user, as before; locked sets are kept and return when the switch is turned on again. */
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const READER_KEYS = ['startMode', 'lastMode', 'adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'pageTone', 'zoom'];
|
||||||
|
|
||||||
|
const perSession = settings => settings.readerPerSession !== false;
|
||||||
|
const publicId = key => crypto.createHash('sha256').update('reader-session:' + key).digest('hex').slice(0, 16);
|
||||||
|
|
||||||
|
function pick(settings) {
|
||||||
|
const out = {};
|
||||||
|
for (const k of READER_KEYS) if (settings[k] !== undefined) out[k] = k === 'zoom' ? { ...settings[k] } : settings[k];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `base` is the user's merged settings (defaults + user.settings); the session's locked set wins over it.
|
||||||
|
function effective(base, session) {
|
||||||
|
if (!perSession(base) || !session?.reader) return base;
|
||||||
|
return { ...base, ...pick(session.reader), zoom: { ...session.reader.zoom } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splits a validated settings patch: reader keys go to the session (locking it) and to the latest set.
|
||||||
|
function applyPatch({ user, base, session, key, patch }) {
|
||||||
|
const s = (user.settings = user.settings || {});
|
||||||
|
const reader = pick(patch), rest = Object.fromEntries(Object.entries(patch).filter(([k]) => !READER_KEYS.includes(k)));
|
||||||
|
Object.assign(s, rest);
|
||||||
|
if (!Object.keys(reader).length) return;
|
||||||
|
if (perSession({ ...base, ...rest }) && session) {
|
||||||
|
session.reader = { ...pick(effective(base, session)), ...reader };
|
||||||
|
Object.assign(s, pick(session.reader));
|
||||||
|
user.readerFrom = publicId(key);
|
||||||
|
} else {
|
||||||
|
Object.assign(s, reader);
|
||||||
|
user.readerFrom = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request from a locked session makes its set the latest one. Returns true when something changed.
|
||||||
|
function noteActive(user, base, session, key) {
|
||||||
|
if (!perSession(base) || !session?.reader) return false;
|
||||||
|
const id = publicId(key);
|
||||||
|
if (user.readerFrom === id) return false;
|
||||||
|
user.settings = { ...(user.settings || {}), ...pick(session.reader) };
|
||||||
|
user.readerFrom = id;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(agent) {
|
||||||
|
const a = String(agent || '');
|
||||||
|
if (!a) return 'Unknown device';
|
||||||
|
const os = /iPhone/.test(a) ? 'iPhone' : /iPad/.test(a) ? 'iPad' : /Android/.test(a) ? 'Android'
|
||||||
|
: /Windows/.test(a) ? 'Windows' : /Mac OS X|Macintosh/.test(a) ? 'Mac' : /CrOS/.test(a) ? 'ChromeOS' : /Linux/.test(a) ? 'Linux' : '';
|
||||||
|
const browser = /Edg\//.test(a) ? 'Edge' : /OPR\//.test(a) ? 'Opera' : /Firefox\//.test(a) ? 'Firefox'
|
||||||
|
: /Chrome\//.test(a) ? 'Chrome' : /Safari\//.test(a) ? 'Safari' : '';
|
||||||
|
return [browser, os].filter(Boolean).join(' on ') || a.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What Settings shows: every login session of the user with its state and effective reader set.
|
||||||
|
function overview(user, base, sessions, currentKey) {
|
||||||
|
const list = Object.entries(sessions).filter(([, s]) => s.u === user.name).map(([key, s]) => ({
|
||||||
|
id: publicId(key), device: deviceLabel(s.agent), created: s.t, seen: s.seen || s.t,
|
||||||
|
current: key === currentKey, locked: !!s.reader, reader: pick(effective(base, s)),
|
||||||
|
})).sort((a, b) => b.current - a.current || b.seen - a.seen);
|
||||||
|
return { enabled: perSession(base), current: currentKey ? publicId(currentKey) : null,
|
||||||
|
latestFrom: user.readerFrom || null, latest: pick(base), sessions: list };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings actions on one of the user's sessions: 'follow' drops its lock, 'latest' makes its set the latest.
|
||||||
|
function act(user, base, sessions, id, action) {
|
||||||
|
const entry = Object.entries(sessions).find(([key, s]) => s.u === user.name && publicId(key) === id);
|
||||||
|
if (!entry) return false;
|
||||||
|
const [key, session] = entry;
|
||||||
|
if (action === 'follow') { delete session.reader; if (user.readerFrom === id) user.readerFrom = null; return true; }
|
||||||
|
if (action === 'latest') {
|
||||||
|
user.settings = { ...(user.settings || {}), ...pick(effective(base, session)) };
|
||||||
|
user.readerFrom = session.reader ? publicId(key) : null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { READER_KEYS, publicId, pick, effective, applyPatch, noteActive, deviceLabel, overview, act };
|
||||||
@@ -40,6 +40,7 @@ const { BackupService, BackupError } = require('./backup');
|
|||||||
const moments = require('./moments');
|
const moments = require('./moments');
|
||||||
const { Tracer, requestTrace } = require('./perf/trace');
|
const { Tracer, requestTrace } = require('./perf/trace');
|
||||||
const { ReadAhead } = require('./perf/read-ahead');
|
const { ReadAhead } = require('./perf/read-ahead');
|
||||||
|
const readerSessions = require('./reader-sessions');
|
||||||
|
|
||||||
const PORT = +process.env.PORT || 8080;
|
const PORT = +process.env.PORT || 8080;
|
||||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||||
@@ -119,8 +120,10 @@ function checkPw(pw, stored) {
|
|||||||
}
|
}
|
||||||
const DUMMY_HASH = hashPw('x');
|
const DUMMY_HASH = hashPw('x');
|
||||||
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
const DEFAULTS = { startMode: 'last', lastMode: 'page', adapt: true, skipExtras: false, skipCovers: false, flowPageGap: true, pageTone: 'original', readingClock: false, readingStory: true,
|
||||||
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
|
||||||
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
const userSettingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
||||||
|
// With a login session, its locked reader settings (reader-sessions.js) win over the user's latest set.
|
||||||
|
const settingsOf = (u, sess) => readerSessions.effective(userSettingsOf(u), sess);
|
||||||
|
|
||||||
function createUser(name, pw, admin, replace) {
|
function createUser(name, pw, admin, replace) {
|
||||||
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
|
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
|
||||||
@@ -132,13 +135,21 @@ function createUser(name, pw, admin, replace) {
|
|||||||
save();
|
save();
|
||||||
return db.users[name];
|
return db.users[name];
|
||||||
}
|
}
|
||||||
function startSession(res, user) {
|
function startSession(req, res, user) {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
db.sessions[sha(token)] = { u: user.name, t: Date.now() };
|
db.sessions[sha(token)] = { u: user.name, t: Date.now(), agent: String(req.headers['user-agent'] || '').slice(0, 300) };
|
||||||
save();
|
save();
|
||||||
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
|
res.setHeader('Set-Cookie', `sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${SECURE_COOKIE ? '; Secure' : ''}`);
|
||||||
}
|
}
|
||||||
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
|
const tokenOf = req => { const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || ''); return m ? m[1] : null; };
|
||||||
|
function sessionKeyOf(req) { const t = tokenOf(req); return t ? sha(t) : null; }
|
||||||
|
// Last-seen time (coarse, to keep db.json writes rare) and device label of a login session.
|
||||||
|
function touchSession(u, sess, key, req) {
|
||||||
|
let changed = false;
|
||||||
|
if (!sess.agent && req.headers['user-agent']) { sess.agent = String(req.headers['user-agent']).slice(0, 300); changed = true; }
|
||||||
|
if (Date.now() - (sess.seen || 0) > 5 * 60e3) { sess.seen = Date.now(); changed = true; }
|
||||||
|
if (changed) save();
|
||||||
|
}
|
||||||
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
|
function userOf(req) { const t = tokenOf(req), s = t && db.sessions[sha(t)]; return (s && db.users[s.u]) || null; }
|
||||||
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
|
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
|
||||||
|
|
||||||
@@ -438,28 +449,14 @@ function readAtOf(progress, record) {
|
|||||||
if (progress.doneAt) return { at: progress.doneAt, approx: false };
|
if (progress.doneAt) return { at: progress.doneAt, approx: false };
|
||||||
return earliestCompletion(record);
|
return earliestCompletion(record);
|
||||||
}
|
}
|
||||||
// The most recently saved chapter position of a manga: the head that "Continue" follows.
|
|
||||||
function lastOf(m, prog) {
|
|
||||||
let last = null;
|
|
||||||
for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; }
|
|
||||||
return last;
|
|
||||||
}
|
|
||||||
// `at` is the head's time; reader links carry it so a reader opened from a stale page notices a newer head.
|
|
||||||
function continueOf(m, prog, s) {
|
function continueOf(m, prog, s) {
|
||||||
const ok = c => !(s.skipExtras && c.extra);
|
const ok = c => !(s.skipExtras && c.extra);
|
||||||
const last = lastOf(m, prog);
|
let last = null;
|
||||||
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, at: 0, text: 'Start reading · ' + first.label }; }
|
for (const ch of m.chapters) { const p = prog[ch.id]; if (p && (!last || p.t > last.p.t)) last = { ch, p }; }
|
||||||
const at = last.p.t;
|
if (!last) { const first = m.chapters.find(ok) || m.chapters[0]; return { c: first.id, text: 'Start reading · ' + first.label }; }
|
||||||
if (!last.p.done) return { c: last.ch.id, v: last.p.v, at, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
|
if (!last.p.done) return { c: last.ch.id, v: last.p.v, text: `Continue · ${last.ch.label} · page ${last.p.p + 1}` };
|
||||||
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
|
const next = m.chapters.slice(m.chapters.indexOf(last.ch) + 1).find(ok);
|
||||||
return next ? { c: next.id, at, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, at, text: 'Read again · ' + last.ch.label };
|
return next ? { c: next.id, text: 'Read next · ' + next.label } : { c: last.ch.id, v: last.p.v, text: 'Read again · ' + last.ch.label };
|
||||||
}
|
|
||||||
// What a reader tab compares itself with (stale reading sessions, docs/architecture.md).
|
|
||||||
function latestOf(m, prog, s) {
|
|
||||||
const last = lastOf(m, prog);
|
|
||||||
if (!last) return null;
|
|
||||||
const { t, w, v, p, n, done } = last.p;
|
|
||||||
return { t, w: w || '', c: last.ch.id, label: last.ch.label, v, p, n, done, continue: continueOf(m, prog, s) };
|
|
||||||
}
|
}
|
||||||
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
|
// From the saved page on, then the pages before it; afterwards the next chapter's preferred version.
|
||||||
function warmChapter(u, m, ch, v, progress, s) {
|
function warmChapter(u, m, ch, v, progress, s) {
|
||||||
@@ -485,11 +482,11 @@ const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
|
|||||||
const routes = {
|
const routes = {
|
||||||
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
|
'GET /api/health': () => ({ ok: !scanState.error, service: 'reader', uptime: Math.round(process.uptime()), scan: scanState }),
|
||||||
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
|
'GET /api/collections': ({ u }) => ({ collections: routes['GET /api/library']({ u }).collections }),
|
||||||
'GET /api/collection': ({ u, q }) => {
|
'GET /api/collection': ({ u, q, sess }) => {
|
||||||
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
|
const view = routes['GET /api/library']({ u }).collections.find(c => c.id === q.get('id')) || bad(404, 'Collection not found');
|
||||||
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
|
const next = view.members.find(m => m.read < m.chapters) || view.members[0];
|
||||||
const manga = mangaOf(next.id);
|
const manga = mangaOf(next.id);
|
||||||
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u)) },
|
return { ...view, continue: { m: next.id, ...continueOf(manga, progressOf(u, manga), settingsOf(u, sess)) },
|
||||||
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
|
relations: catalog.state.relations.filter(r => r.state === 'confirmed' && view.members.some(m => m.id === r.from || m.id === r.to)) };
|
||||||
},
|
},
|
||||||
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
|
'GET /api/admin/catalog': ({ u }) => { admin(u); return { ...catalog.state, types: TYPES, roles: ROLES, mangas: lib.mangas.map(m => ({ id: m.id, title: m.title })) }; },
|
||||||
@@ -537,16 +534,16 @@ const routes = {
|
|||||||
},
|
},
|
||||||
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
|
'GET /api/auth': () => ({ firstRun: !Object.keys(db.users).length, signup: OPEN_SIGNUP }),
|
||||||
|
|
||||||
'POST /api/signup': ({ b, res }) => {
|
'POST /api/signup': ({ req, b, res }) => {
|
||||||
const first = !Object.keys(db.users).length;
|
const first = !Object.keys(db.users).length;
|
||||||
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
|
if (!first && !OPEN_SIGNUP) bad(403, 'Sign-up is closed. Ask an admin for an account.');
|
||||||
startSession(res, createUser(b.name, b.password, first));
|
startSession(req, res, createUser(b.name, b.password, first));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'POST /api/login': ({ b, res }) => {
|
'POST /api/login': ({ req, b, res }) => {
|
||||||
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
|
const user = db.users[String(b.name || '').trim().toLowerCase()], pw = String(b.password || '');
|
||||||
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
|
if (!checkPw(pw, user ? user.hash : DUMMY_HASH) || !user) bad(403, 'Wrong user name or password.');
|
||||||
startSession(res, user);
|
startSession(req, res, user);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'POST /api/logout': ({ req, res }) => {
|
'POST /api/logout': ({ req, res }) => {
|
||||||
@@ -555,7 +552,18 @@ const routes = {
|
|||||||
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
'GET /api/me': ({ u }) => ({ name: u.name, admin: !!u.admin, settings: settingsOf(u), unread: unreadOf(u) }),
|
// Every page loads /api/me, so a page view marks its session as the last active one for reader settings.
|
||||||
|
'GET /api/me': ({ u, sess, sk }) => {
|
||||||
|
if (sess && readerSessions.noteActive(u, userSettingsOf(u), sess, sk)) save();
|
||||||
|
return { name: u.name, admin: !!u.admin, settings: settingsOf(u, sess), unread: unreadOf(u) };
|
||||||
|
},
|
||||||
|
'GET /api/reader-sessions': ({ u, sk }) => readerSessions.overview(u, userSettingsOf(u), db.sessions, sk),
|
||||||
|
'POST /api/reader-sessions': ({ u, b, sk }) => {
|
||||||
|
if (!['follow', 'latest'].includes(b.action)) bad(400, 'Unknown action');
|
||||||
|
if (!readerSessions.act(u, userSettingsOf(u), db.sessions, String(b.id || ''), b.action)) bad(404, 'Session not found');
|
||||||
|
save();
|
||||||
|
return readerSessions.overview(u, userSettingsOf(u), db.sessions, sk);
|
||||||
|
},
|
||||||
|
|
||||||
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
|
'GET /api/reading-schedule': () => publicSchedule(readingSchedule),
|
||||||
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
'GET /api/reading-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
||||||
@@ -572,22 +580,23 @@ const routes = {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
'POST /api/settings': ({ u, b }) => {
|
'POST /api/settings': ({ u, b, sess, sk }) => {
|
||||||
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
|
if (typeof b.webhook === 'string' && b.webhook.trim() && !/^https?:\/\/\S+$/i.test(b.webhook.trim())) bad(400, 'The webhook must be an http(s) URL.');
|
||||||
const s = (u.settings = u.settings || {});
|
const base = userSettingsOf(u), s = {};
|
||||||
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
||||||
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
if (['flow', 'page', 'book'].includes(b.lastMode)) s.lastMode = b.lastMode;
|
||||||
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
if (['original', 'inverted', 'sepia', 'dim', 'extra-dim'].includes(b.pageTone)) s.pageTone = b.pageTone;
|
||||||
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
if (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
||||||
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
|
for (const k of ['adapt', 'skipExtras', 'skipCovers', 'flowPageGap', 'readingClock', 'readingStory', 'releaseDate', 'continueRow',
|
||||||
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
'discoveryEnabled', 'discoveryReadingActivity', 'discoveryMature', 'readerPerSession']) if (typeof b[k] === 'boolean') s[k] = b[k];
|
||||||
if (b.zoom && typeof b.zoom === 'object') {
|
if (b.zoom && typeof b.zoom === 'object') {
|
||||||
s.zoom = {};
|
s.zoom = {};
|
||||||
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
|
for (const k of ['flow', 'page', 'book', 'strip']) if (+b.zoom[k] > 0) s.zoom[k] = Math.min(8, Math.max(0.1, +b.zoom[k]));
|
||||||
}
|
}
|
||||||
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
|
if (typeof b.webhook === 'string') s.webhook = b.webhook.trim().slice(0, 500);
|
||||||
|
readerSessions.applyPatch({ user: u, base, session: sess, key: sk, patch: s });
|
||||||
save();
|
save();
|
||||||
return { settings: settingsOf(u) };
|
return { settings: settingsOf(u, sess) };
|
||||||
},
|
},
|
||||||
'POST /api/password': ({ u, b }) => {
|
'POST /api/password': ({ u, b }) => {
|
||||||
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
|
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
|
||||||
@@ -715,8 +724,8 @@ const routes = {
|
|||||||
return { results: await searchGreatestMangaDex(entry) };
|
return { results: await searchGreatestMangaDex(entry) };
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /api/manga': ({ u, q }) => {
|
'GET /api/manga': ({ u, q, sess }) => {
|
||||||
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u), showStory = s.readingStory;
|
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory;
|
||||||
const records = mangaReadingOf(u, m);
|
const records = mangaReadingOf(u, m);
|
||||||
return {
|
return {
|
||||||
chapterDates: { read: s.readDate, release: s.releaseDate },
|
chapterDates: { read: s.readDate, release: s.releaseDate },
|
||||||
@@ -733,7 +742,7 @@ const routes = {
|
|||||||
dates: s.releaseDate ? chapterDates(ch.versions) : null,
|
dates: s.releaseDate ? chapterDates(ch.versions) : null,
|
||||||
})),
|
})),
|
||||||
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
|
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
|
||||||
continue: continueOf(m, prog, settingsOf(u)),
|
continue: continueOf(m, prog, s),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -774,8 +783,8 @@ const routes = {
|
|||||||
return { ok: true, deleted: m.id };
|
return { ok: true, deleted: m.id };
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /api/chapter': ({ u, q }) => {
|
'GET /api/chapter': ({ u, q, sess }) => {
|
||||||
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m);
|
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u, sess), prog = progressOf(u, m);
|
||||||
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
|
if (!readingAccess.status(u.name).allowed) bad(423, 'Reading is outside the deployment schedule. Complete the five-question challenge to unlock it.');
|
||||||
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
|
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
|
||||||
// Opening a chapter clears its "new chapter" notification.
|
// Opening a chapter clears its "new chapter" notification.
|
||||||
@@ -791,7 +800,6 @@ const routes = {
|
|||||||
pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })),
|
pages: v.pages.map(p => ({ u: url(v.rel + '/' + p.f), w: p.w, h: p.h, t: p.t, r: p.r })),
|
||||||
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
|
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
|
||||||
progress: prog[ch.id] || null,
|
progress: prog[ch.id] || null,
|
||||||
latest: latestOf(m, prog, s),
|
|
||||||
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
|
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
|
||||||
// the user's moments on this version, so the reader can mark pages that already have one
|
// the user's moments on this version, so the reader can mark pages that already have one
|
||||||
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
|
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
|
||||||
@@ -802,30 +810,18 @@ const routes = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /api/progress/latest': ({ u, q }) => {
|
|
||||||
const m = mangaOf(q.get('m'));
|
|
||||||
return { latest: latestOf(m, progressOf(u, m), settingsOf(u)) };
|
|
||||||
},
|
|
||||||
'POST /api/progress': ({ u, b }) => {
|
'POST /api/progress': ({ u, b }) => {
|
||||||
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
|
const m = mangaOf(b.m), ch = chapterOf(m, b.c);
|
||||||
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
|
const all = (db.progress[u.name] = db.progress[u.name] || {}), mp = (all[m.id] = all[m.id] || {}), old = mp[ch.id] || {};
|
||||||
const tab = /^[a-zA-Z0-9_-]{8,40}$/.test(String(b.tab || '')) ? String(b.tab) : '';
|
|
||||||
// A reader tab sends the head time it knows (`seen`). When another tab or device saved a newer
|
|
||||||
// position since, this tab is stale: it must not become the head until the reader chooses to stay.
|
|
||||||
if (Number.isFinite(b.seen)) {
|
|
||||||
const last = lastOf(m, mp);
|
|
||||||
if (last && last.p.t > b.seen && (!tab || last.p.w !== tab)) return { ok: false, stale: true, latest: latestOf(m, mp, settingsOf(u)) };
|
|
||||||
}
|
|
||||||
const now = Date.now(), done = !!(old.done || b.done);
|
const now = Date.now(), done = !!(old.done || b.done);
|
||||||
// doneAt: when the chapter was first completed; kept until it is marked unread.
|
// doneAt: when the chapter was first completed; kept until it is marked unread.
|
||||||
const doneAt = old.done ? old.doneAt : done ? now : undefined;
|
const doneAt = old.done ? old.doneAt : done ? now : undefined;
|
||||||
mp[ch.id] = {
|
mp[ch.id] = {
|
||||||
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}),
|
v: String(b.v || ''), p: Math.max(0, b.p | 0), n: Math.max(0, b.n | 0), t: now, done, ...(doneAt ? { doneAt } : {}),
|
||||||
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
|
shift: Array.isArray(b.shift) ? b.shift.filter(Number.isInteger).slice(0, 1000) : old.shift || [],
|
||||||
...(tab ? { w: tab } : {}),
|
|
||||||
};
|
};
|
||||||
save();
|
save();
|
||||||
return { ok: true, t: now };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
// Separate endpoint: an older server on rollback cannot misread cumulative
|
// Separate endpoint: an older server on rollback cannot misread cumulative
|
||||||
// visit totals as legacy deltas. Old open tabs retain their original protocol.
|
// visit totals as legacy deltas. Old open tabs retain their original protocol.
|
||||||
@@ -1129,7 +1125,9 @@ async function handleApi(req, res, u, route, q) {
|
|||||||
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
|
if (!/^application\/json/.test(req.headers['content-type'] || '') || req.headers['sec-fetch-site'] === 'cross-site') bad(403, 'Bad request origin');
|
||||||
b = (await readBody(req)) || {};
|
b = (await readBody(req)) || {};
|
||||||
}
|
}
|
||||||
sendJson(res, 200, await route({ req, res, u, b, q }));
|
const sk = u && sessionKeyOf(req), sess = sk && db.sessions[sk];
|
||||||
|
if (sess) touchSession(u, sess, sk, req);
|
||||||
|
sendJson(res, 200, await route({ req, res, u, b, q, sess, sk }));
|
||||||
}
|
}
|
||||||
function serveFile(req, res, root, rel, cacheControl, only, trace) {
|
function serveFile(req, res, root, rel, cacheControl, only, trace) {
|
||||||
let file;
|
let file;
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use strict';
|
||||||
|
// Reader settings per login session: a session follows the latest set until it changes one, then keeps its own.
|
||||||
|
const test = require('node:test'), assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const readerSessions = require('../reader-sessions');
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
const listener = net.createServer();
|
||||||
|
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
||||||
|
const port = listener.address().port;
|
||||||
|
await new Promise(resolve => listener.close(resolve));
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('device labels name browser and system', () => {
|
||||||
|
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1'), 'Safari on iPhone');
|
||||||
|
assert.equal(readerSessions.deviceLabel('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0 Safari/537.36 Edg/130.0'), 'Edge on Windows');
|
||||||
|
assert.equal(readerSessions.deviceLabel(''), 'Unknown device');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sessions lock their reader settings on the first change, new ones copy the last active one', { timeout: 20000 }, async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-reader-sessions-'));
|
||||||
|
let child;
|
||||||
|
try {
|
||||||
|
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||||
|
fs.mkdirSync(library); fs.mkdirSync(data);
|
||||||
|
const port = await freePort(), output = [];
|
||||||
|
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
||||||
|
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
||||||
|
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '',
|
||||||
|
GREATEST_COVER_WARM: '0' } });
|
||||||
|
child.stdout.on('data', d => output.push(String(d)));
|
||||||
|
child.stderr.on('data', d => output.push(String(d)));
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
let ready = false;
|
||||||
|
for (let n = 0; n < 100 && !ready; n++) {
|
||||||
|
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
||||||
|
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
assert.ok(ready, output.join(''));
|
||||||
|
|
||||||
|
const json = async r => { const body = await r.json(); assert.equal(r.status, 200, JSON.stringify(body)); return body; };
|
||||||
|
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'user-agent': 'Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0', ...(cookie ? { cookie } : {}) },
|
||||||
|
body: JSON.stringify(body) }).then(async r => (url.endsWith('login') || url.endsWith('signup') ? r : json(r)));
|
||||||
|
const me = async cookie => (await json(await fetch(base + '/api/me', { headers: { cookie } }))).settings;
|
||||||
|
const overview = cookie => fetch(base + '/api/reader-sessions', { headers: { cookie } }).then(json);
|
||||||
|
const login = async () => (await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
|
||||||
|
|
||||||
|
const a = (await post(null, '/api/signup', { name: 'reader', password: 'reader-password' })).headers.get('set-cookie').split(';')[0];
|
||||||
|
// First session: defaults, following the latest set.
|
||||||
|
assert.equal((await me(a)).lastMode, 'page');
|
||||||
|
assert.equal((await overview(a)).sessions[0].locked, false);
|
||||||
|
|
||||||
|
// Session B exists before A changes anything: it follows A's change.
|
||||||
|
const b = await login();
|
||||||
|
await post(a, '/api/settings', { lastMode: 'flow', zoom: { flow: 1.5 } });
|
||||||
|
assert.equal((await me(b)).lastMode, 'flow');
|
||||||
|
assert.equal((await me(b)).zoom.flow, 1.5);
|
||||||
|
|
||||||
|
// B changes its own: B locks, A keeps its own set.
|
||||||
|
await post(b, '/api/settings', { pageTone: 'sepia' });
|
||||||
|
assert.equal((await me(b)).pageTone, 'sepia');
|
||||||
|
assert.equal((await me(b)).lastMode, 'flow');
|
||||||
|
assert.equal((await me(a)).pageTone, 'original');
|
||||||
|
|
||||||
|
// A non-reader setting is shared and does not lock anything.
|
||||||
|
const c = await login();
|
||||||
|
await post(c, '/api/settings', { continueRow: false });
|
||||||
|
assert.equal((await me(a)).continueRow, false);
|
||||||
|
assert.equal((await overview(c)).sessions.find(s => s.current).locked, false);
|
||||||
|
// C follows the last active session, which is A (it loaded /api/me last).
|
||||||
|
assert.equal((await me(c)).pageTone, 'original');
|
||||||
|
await me(b);
|
||||||
|
assert.equal((await me(c)).pageTone, 'sepia');
|
||||||
|
|
||||||
|
// Overview: three sessions, labels, intent.
|
||||||
|
const o = await overview(a);
|
||||||
|
assert.equal(o.enabled, true);
|
||||||
|
assert.equal(o.sessions.length, 3);
|
||||||
|
assert.equal(o.sessions[0].current, true);
|
||||||
|
assert.equal(o.sessions[0].device, 'Firefox on Linux');
|
||||||
|
assert.deepEqual(o.sessions.map(s => s.locked).sort(), [false, true, true]);
|
||||||
|
|
||||||
|
// "Follow latest again" drops A's lock; "Use for new sessions" makes a set the latest.
|
||||||
|
const idA = o.sessions[0].id, idB = o.sessions.find(s => !s.current && s.locked).id;
|
||||||
|
await post(a, '/api/reader-sessions', { id: idA, action: 'follow' });
|
||||||
|
assert.equal((await me(a)).pageTone, 'sepia');
|
||||||
|
assert.equal((await fetch(base + '/api/reader-sessions', { method: 'POST', headers: { 'content-type': 'application/json', cookie: a },
|
||||||
|
body: JSON.stringify({ id: 'nope', action: 'follow' }) })).status, 404);
|
||||||
|
assert.equal((await post(a, '/api/reader-sessions', { id: idB, action: 'latest' })).latestFrom, idB);
|
||||||
|
|
||||||
|
// Off: one shared set; a change applies everywhere and locks nothing. On again: kept locks return.
|
||||||
|
await post(b, '/api/settings', { readerPerSession: false });
|
||||||
|
await post(a, '/api/settings', { pageTone: 'dim' });
|
||||||
|
assert.equal((await me(b)).pageTone, 'dim');
|
||||||
|
assert.equal((await me(c)).pageTone, 'dim');
|
||||||
|
await post(a, '/api/settings', { readerPerSession: true });
|
||||||
|
assert.equal((await me(b)).pageTone, 'sepia');
|
||||||
|
assert.equal((await me(a)).pageTone, 'sepia');
|
||||||
|
} finally {
|
||||||
|
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Stale reading sessions: a reader tab left open on another device must not move "Continue" back.
|
|
||||||
const test = require('node:test'), assert = require('node:assert/strict');
|
|
||||||
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const { Canvas } = require('../tools/png');
|
|
||||||
|
|
||||||
async function freePort() {
|
|
||||||
const listener = net.createServer();
|
|
||||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
|
||||||
const port = listener.address().port;
|
|
||||||
await new Promise(resolve => listener.close(resolve));
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => {
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-'));
|
|
||||||
let child;
|
|
||||||
try {
|
|
||||||
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
|
||||||
for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) {
|
|
||||||
const dir = path.join(library, 'Moon Garden', 'v01', ch);
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
|
|
||||||
}
|
|
||||||
fs.mkdirSync(data);
|
|
||||||
const port = await freePort(), output = [];
|
|
||||||
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
|
||||||
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
|
||||||
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
|
|
||||||
child.stdout.on('data', d => output.push(String(d)));
|
|
||||||
child.stderr.on('data', d => output.push(String(d)));
|
|
||||||
const base = `http://127.0.0.1:${port}`;
|
|
||||||
let ready = false;
|
|
||||||
for (let n = 0; n < 100 && !ready; n++) {
|
|
||||||
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
|
||||||
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
assert.ok(ready, output.join(''));
|
|
||||||
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
|
|
||||||
headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json());
|
|
||||||
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
|
|
||||||
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: 'alice', password: 'alice-password' }) });
|
|
||||||
const alice = signup.headers.get('set-cookie').split(';')[0];
|
|
||||||
await post(alice, '/api/admin/rescan', { full: true });
|
|
||||||
|
|
||||||
const manga = await get(alice, '/api/manga?m=Moon%20Garden');
|
|
||||||
const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
|
|
||||||
const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra });
|
|
||||||
const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest);
|
|
||||||
|
|
||||||
// Phone reads chapter 1, the tablet then reads on in chapter 3.
|
|
||||||
const first = await save(one, { tab: 'phone-tab-1', seen: 0 });
|
|
||||||
assert.equal(first.ok, true);
|
|
||||||
const phoneSeen = first.t;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 5));
|
|
||||||
const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen });
|
|
||||||
assert.equal(tablet.ok, true);
|
|
||||||
assert.equal((await latest()).c, three.c);
|
|
||||||
|
|
||||||
// The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3.
|
|
||||||
const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 });
|
|
||||||
assert.equal(refused.ok, false);
|
|
||||||
assert.equal(refused.stale, true);
|
|
||||||
assert.equal(refused.latest.c, three.c);
|
|
||||||
assert.equal(refused.latest.w, 'tablet-tab-1');
|
|
||||||
assert.equal(refused.latest.continue.c, three.c);
|
|
||||||
assert.match(refused.latest.continue.text, /Continue/);
|
|
||||||
const after = await get(alice, '/api/manga?m=Moon%20Garden');
|
|
||||||
assert.equal(after.continue.c, three.c);
|
|
||||||
assert.equal(after.continue.at, tablet.t);
|
|
||||||
assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing');
|
|
||||||
|
|
||||||
// The chapter view tells a reader which head it is compared with.
|
|
||||||
const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`);
|
|
||||||
assert.equal(view.latest.c, three.c);
|
|
||||||
assert.equal(view.latest.t, tablet.t);
|
|
||||||
|
|
||||||
// The tablet's own saves never conflict with themselves, even with an older `seen` still in flight.
|
|
||||||
assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true);
|
|
||||||
|
|
||||||
// "Keep reading here": the phone now knows the newest time and its save becomes the head.
|
|
||||||
const head = await latest();
|
|
||||||
const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 });
|
|
||||||
assert.equal(stayed.ok, true);
|
|
||||||
assert.equal((await latest()).c, one.c);
|
|
||||||
assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c);
|
|
||||||
|
|
||||||
// Saves without `seen` (mark as read on the manga page, older clients) behave as before.
|
|
||||||
assert.equal((await save(two, { done: true })).ok, true);
|
|
||||||
assert.equal((await latest()).c, two.c);
|
|
||||||
} finally {
|
|
||||||
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
|
||||||
fs.rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Reference in new issue
Block a user