Compare commits
5
Commits
618992ec14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe8956087e | ||
|
|
c760141636 | ||
|
|
9b529d7709 | ||
|
|
dddf9bb4ac | ||
|
|
e3e57b60bd |
No files matched your search
@@ -11,6 +11,8 @@
|
||||
!reading-analytics.js
|
||||
!reading-access.js
|
||||
!moments.js
|
||||
!reader-sessions.js
|
||||
!reading-activity.js
|
||||
!public/
|
||||
!public/**
|
||||
!greatest/
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ RUN apk add --no-cache tzdata openssl
|
||||
WORKDIR /app
|
||||
ARG VCS_REF=development
|
||||
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 reading-activity.js ./
|
||||
COPY public ./public
|
||||
COPY greatest ./greatest
|
||||
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 &
|
||||
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)
|
||||
|
||||
These configure the reader process. In Docker most are set for you by `docker-compose.yml`; the variables
|
||||
@@ -144,7 +148,7 @@ completeness.
|
||||
- **Flow page gap:** Settings → Reading lets each user keep the normal separator between Flow pages or remove it for continuous webtoon artwork and text that crosses image edges.
|
||||
- **Page tone:** the reader's **Tone** button cycles Original → Inverted → Sepia → Dim → Extra dim in Flow, Page and Book. It changes only the artwork, never the controls or site theme. The choice is saved per user; it can also be selected in Settings → User → Reading.
|
||||
- **Active Reading Clock:** opt in under Settings → User → Reading time & privacy. No scrolling-speed limit; a visit counts at 60% viewed-page coverage and saves on exit. Pauses in the background or after 30 seconds without reader activity. Tap the clock for this visit's time, coverage, qualification and loading wait.
|
||||
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently and have not finished (same tiles as the grid; they stay in the grid too). It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
|
||||
- **Continue where you left off:** the first line of the library content (below the search, filters and tags) is one row of the five manga you read most recently that still have something left after the last chapter you read (same tiles as the grid; they stay in the grid too). Left means an unfinished last chapter, an unread later chapter, or — when you are rereading — a later chapter you finished in an earlier pass: more than *N days* before your current position (default 7), or before a run of more than *M chapters* you have since read in order up to it (default 5). Both are set under Settings → User → Reading → *Library page*; 0 turns a rule off. A manga you have caught up on drops out until a new chapter arrives. It hides while a search is typed, a status filter is set, or the library is sorted by *Recently read*; tags and collection grouping do not affect it. Turn it off under Settings → User → Reading → *Library page*.
|
||||
- **Your Reading Story:** private active-time summaries appear on the library and manga pages when history exists. Chapter rows show the last timed read (60%-coverage visit), rereads remain visible as lifetime time, and Settings lets each user hide, export, or delete their own history. No leaderboards or speed ranking are used.
|
||||
- **Tap / click zones:**
|
||||
- Tapping **beside the pages** always shows or hides the controls.
|
||||
|
||||
+30
-6
@@ -129,12 +129,17 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
||||
"readingClock": false, // opt in to credible per-chapter active-time tracking
|
||||
"readingStory": true, // show private retrospective summaries when history exists
|
||||
"continueRow": true, // library: "Continue where you left off" row at the top
|
||||
"rereadDays": 7, // continue row: a later chapter finished this long before the head is left again (0 = off)
|
||||
"rereadChapters": 5, // ... or one finished before a run of more than this many in-order chapters (0 = off)
|
||||
"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": {
|
||||
"v": "c0002 Roots [MangaDex]", // version read
|
||||
"p": 4, "n": 14, // page index in that version (all pages), page count
|
||||
@@ -177,7 +182,7 @@ This keeps ids human-readable and derivable without a database. The price: renam
|
||||
|
||||
| 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` |
|
||||
| everything else | Static files from `public/` (`/` → `index.html`). `ETag` + `no-cache` |
|
||||
|
||||
@@ -194,15 +199,18 @@ 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/logout` | user | Ends the session |
|
||||
| `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 |
|
||||
| `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, `left` (chapters after the newest saved position that are unfinished, unread, or finished in an earlier pass — over `rereadDays` before it, or before a run of more than `rereadChapters` chapters read in order up to it; 0 = caught up), 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/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 |
|
||||
| `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}` |
|
||||
| `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 |
|
||||
| `GET /api/reading/export` | user | Export the user's private derived reading-time summary without raw input or page-exposure diagnostics |
|
||||
| `GET /api/reading/activity` | user | The user's reading activity for the Settings grid: active reader time in UTC buckets `[start, size, ms, [[mangaIndex, ms]...]]` (6 minutes for the last 120 days, hours up to three years), chapter completions `[doneAt, mangaIndex, label]`, manga titles and whether the clock is on. The browser groups them into local months, weeks, days and hours (`public/activity-core.js`) |
|
||||
| `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/unread` `{m, c}` | user | Forget progress for a chapter |
|
||||
| `POST /api/vote` `{m, c, v, value: 1\|-1\|0}` | user | Vote a version; returns the re-ranked versions |
|
||||
@@ -270,6 +278,22 @@ The reader alone talks to AniList for discovery ([discovery.md](discovery.md)).
|
||||
(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).
|
||||
|
||||
### 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.1 Pages
|
||||
|
||||
@@ -144,6 +144,16 @@ history from which those values can be reconstructed.
|
||||
|
||||
## 5. Later: Reading Journal
|
||||
|
||||
Update 2026-11-03: the activity calendar exists as **Settings → Reading activity**
|
||||
(`reading-activity.js`, `db.activity`). It stores active reader time per user in UTC
|
||||
buckets: 6 minutes for the last 120 days, then folded into hours, dropped after three
|
||||
years. Each bucket keeps only its total and the time per manga. All active reader time
|
||||
counts there, including visits below the 60% threshold, because it answers "when did I
|
||||
read", not "how long did a chapter take". The first use seeds the buckets from the last
|
||||
eight visits per chapter the clock still remembers; older history cannot be recovered.
|
||||
Finished chapters come from the existing progress `doneAt` and need no clock.
|
||||
Deleting reading-time history (all, or one manga) deletes the matching activity.
|
||||
|
||||
If readers enjoy Reading Story, add bounded daily aggregates rather than raw activity
|
||||
events:
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ heading when switching within Settings.
|
||||
|
||||
## Routes and ownership
|
||||
|
||||
Personal routes: `settings.html#user/reading`, `#user/reading-time`,
|
||||
Personal routes: `settings.html#user/reading`, `#user/reading-time`, `#user/activity`,
|
||||
`#user/notifications`, `#user/account`.
|
||||
|
||||
Admin routes: `settings.html#admin/scraper`, `#admin/library`, `#admin/users`.
|
||||
@@ -63,6 +63,8 @@ Service health also holds the **Backups** card: built-in backup status (last run
|
||||
size, duration, error), **Back up now**, and the daily schedule and retention form,
|
||||
whose draft survives the page's polling. See [backup.md](backup.md).
|
||||
|
||||
Reading activity is a grid of months, weeks, days or hours (`public/activity.js`). Each cell glows with its total and holds a strip from the period's start (left) to its end (right): days in a month, quarter days in a week, hours in a day, 6 minutes in an hour. Clicking a month or week opens its days (the week is marked), a day opens its hours; hover, keyboard focus or a first tap shows details. It measures reading time (needs the Active Reading Clock) or finished chapters (from progress, always available).
|
||||
|
||||
Reading-time collection controls, export and deletion live under Reading time &
|
||||
privacy; reader layout controls stay under Reading. Rescan controls and user
|
||||
administration are separate. Server & library describes existing maintenance,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
/* Reading activity grid: periods, layouts and sums in the browser's local time.
|
||||
Pure functions, so the node tests can run them. The server only sends UTC buckets. */
|
||||
|
||||
const ACTIVITY_VIEWS = ['month', 'week', 'day', 'hour'];
|
||||
const ACTIVITY_DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
const ACTIVITY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const ACTIVITY_QUARTERS = ['night', 'morning', 'afternoon', 'evening'];
|
||||
const ACTIVITY_HOUR_MS = 3600000, ACTIVITY_SLICES = 10;
|
||||
|
||||
// Start of the local month, Monday-based week, day or hour containing t.
|
||||
function periodStart(level, t) {
|
||||
const d = new Date(t);
|
||||
if (level === 'hour') { d.setMinutes(0, 0, 0); return d.getTime(); }
|
||||
d.setHours(0, 0, 0, 0);
|
||||
if (level === 'week') d.setDate(d.getDate() - (d.getDay() + 6) % 7);
|
||||
if (level === 'month') d.setDate(1);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
// n periods after a period start; calendar arithmetic keeps midnight across DST changes.
|
||||
function periodShift(level, start, n) {
|
||||
if (level === 'hour') return start + n * ACTIVITY_HOUR_MS;
|
||||
const d = new Date(start);
|
||||
if (level === 'month') d.setMonth(d.getMonth() + n);
|
||||
else d.setDate(d.getDate() + n * (level === 'week' ? 7 : 1));
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function isoWeek(t) {
|
||||
const d = new Date(t); d.setHours(0, 0, 0, 0);
|
||||
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7); // Thursday of this week decides the year
|
||||
const jan4 = new Date(d.getFullYear(), 0, 4);
|
||||
return 1 + Math.round(((d - jan4) / 864e5 - 3 + (jan4.getDay() + 6) % 7) / 7);
|
||||
}
|
||||
|
||||
const dayText = t => { const d = new Date(t); return `${d.getDate()} ${ACTIVITY_MONTHS[d.getMonth()]}`; };
|
||||
const clockText = t => { const d = new Date(t); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; };
|
||||
|
||||
// Inside a cell: month → its days, week → quarter days, day → hours, hour → ten 6-minute slices.
|
||||
function segmentBounds(level, start) {
|
||||
const end = periodShift(level, start, 1), bounds = [start];
|
||||
if (level === 'month') for (let t = periodShift('day', start, 1); t < end; t = periodShift('day', t, 1)) bounds.push(t);
|
||||
else if (level === 'week') {
|
||||
for (let day = start; day < end; day = periodShift('day', day, 1)) {
|
||||
for (const hour of [6, 12, 18]) { const d = new Date(day); d.setHours(hour, 0, 0, 0); bounds.push(d.getTime()); }
|
||||
if (periodShift('day', day, 1) < end) bounds.push(periodShift('day', day, 1));
|
||||
}
|
||||
} else if (level === 'day') {
|
||||
for (let hour = 1; hour < 24; hour++) { const d = new Date(start); d.setHours(hour, 0, 0, 0); if (d.getTime() > bounds[bounds.length - 1] && d.getTime() < end) bounds.push(d.getTime()); }
|
||||
} else for (let i = 1; i < ACTIVITY_SLICES; i++) bounds.push(start + i * ACTIVITY_HOUR_MS / ACTIVITY_SLICES);
|
||||
bounds.push(end);
|
||||
return bounds;
|
||||
}
|
||||
|
||||
function segmentText(level, from, to) {
|
||||
if (level === 'month') return dayText(from);
|
||||
if (level === 'week') return `${ACTIVITY_DAY_NAMES[(new Date(from).getDay() + 6) % 7]} ${ACTIVITY_QUARTERS[Math.floor(new Date(from).getHours() / 6)]}`;
|
||||
return `${clockText(from)}–${clockText(to)}`;
|
||||
}
|
||||
|
||||
function cellFor(level, start, extra = {}) {
|
||||
const d = new Date(start), end = periodShift(level, start, 1);
|
||||
const label = level === 'month' ? ACTIVITY_MONTHS[d.getMonth()] : level === 'week' ? `W${isoWeek(start)}`
|
||||
: level === 'day' ? String(d.getDate()) : String(d.getHours()).padStart(2, '0');
|
||||
const title = level === 'month' ? `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`
|
||||
: level === 'week' ? `Week ${isoWeek(start)} · ${dayText(start)} – ${dayText(periodShift('day', end, -1))} ${new Date(end - 1).getFullYear()}`
|
||||
: level === 'day' ? `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(start)} ${d.getFullYear()}`
|
||||
: `${dayText(start)} · ${clockText(start)}–${clockText(end)}`;
|
||||
return { level, start, end, label, title, sub: level === 'week' ? dayText(start) : '', ...extra };
|
||||
}
|
||||
|
||||
// The grid for one view around `anchor`: rows of cells, plus where the arrows lead.
|
||||
function activityLayout(view, anchor) {
|
||||
const rows = [];
|
||||
if (view === 'month') {
|
||||
const year = new Date(anchor).getFullYear();
|
||||
for (const y of [year - 1, year]) {
|
||||
rows.push({ label: String(y), cells: ACTIVITY_MONTHS.map((_, m) => cellFor('month', new Date(y, m, 1).getTime())) });
|
||||
}
|
||||
return { view, rows, columns: 12, title: `${year - 1} – ${year}`, step: ['month', 12], from: rows[0].cells[0].start, to: rows[1].cells[11].end };
|
||||
}
|
||||
if (view === 'week') {
|
||||
const last = periodStart('month', anchor);
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const month = periodShift('month', last, -i), next = periodShift('month', month, 1), cells = [];
|
||||
// A week belongs to the month of its Thursday, so every week shows exactly once.
|
||||
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
|
||||
const thursday = periodShift('day', week, 3);
|
||||
if (thursday >= month && thursday < next) cells.push(cellFor('week', week));
|
||||
}
|
||||
const d = new Date(month);
|
||||
rows.push({ label: `${ACTIVITY_MONTHS[d.getMonth()]} ${d.getFullYear()}`, cells });
|
||||
}
|
||||
const first = new Date(periodShift('month', last, -5)), end = new Date(last);
|
||||
return { view, rows, columns: 5, title: `${ACTIVITY_MONTHS[first.getMonth()]} ${first.getFullYear()} – ${ACTIVITY_MONTHS[end.getMonth()]} ${end.getFullYear()}`,
|
||||
step: ['month', 6], from: rows[0].cells[0].start, to: rows[5].cells[rows[5].cells.length - 1].end };
|
||||
}
|
||||
if (view === 'day') {
|
||||
const month = periodStart('month', anchor), next = periodShift('month', month, 1), d = new Date(month);
|
||||
for (let week = periodStart('week', month); week < next; week = periodShift('week', week, 1)) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < 7; i++) { const day = periodShift('day', week, i); cells.push(cellFor('day', day, { outside: day < month || day >= next })); }
|
||||
rows.push({ label: `W${isoWeek(week)}`, week, cells });
|
||||
}
|
||||
return { view, rows, columns: 7, header: ACTIVITY_DAY_NAMES, title: `${['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][d.getMonth()]} ${d.getFullYear()}`,
|
||||
step: ['month', 1], from: rows[0].cells[0].start, to: rows[rows.length - 1].cells[6].end };
|
||||
}
|
||||
const day = periodStart('day', anchor), d = new Date(day);
|
||||
for (let q = 0; q < 4; q++) {
|
||||
const cells = [];
|
||||
for (let i = 0; i < 6; i++) { const t = new Date(day); t.setHours(q * 6 + i, 0, 0, 0); cells.push(cellFor('hour', t.getTime())); }
|
||||
rows.push({ label: ACTIVITY_QUARTERS[q][0].toUpperCase() + ACTIVITY_QUARTERS[q].slice(1), cells });
|
||||
}
|
||||
return { view, rows, columns: 6, title: `${ACTIVITY_DAY_NAMES[(d.getDay() + 6) % 7]} ${dayText(day)} ${d.getFullYear()}`,
|
||||
step: ['day', 1], from: day, to: periodShift('day', day, 1) };
|
||||
}
|
||||
|
||||
// Sums the server's buckets ([start, size, ms, [[manga, ms]…]]) and completions ([t, manga, label])
|
||||
// into each cell's segments. A bucket split across segments is shared by overlap.
|
||||
function activitySums(data, cells) {
|
||||
const segs = [];
|
||||
for (const cell of cells) {
|
||||
cell.bounds = segmentBounds(cell.level, cell.start);
|
||||
const n = cell.bounds.length - 1;
|
||||
cell.time = new Array(n).fill(0); cell.chapters = new Array(n).fill(0);
|
||||
cell.manga = new Map(); cell.done = [];
|
||||
for (let i = 0; i < n; i++) segs.push({ s: cell.bounds[i], e: cell.bounds[i + 1], cell, i });
|
||||
}
|
||||
segs.sort((a, b) => a.s - b.s);
|
||||
const firstEndingAfter = t => { let lo = 0, hi = segs.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (segs[mid].e > t) hi = mid; else lo = mid + 1; } return lo; };
|
||||
for (const [start, size, ms, mangas] of (data && data.buckets) || []) {
|
||||
const end = start + size;
|
||||
for (let k = firstEndingAfter(start); k < segs.length && segs[k].s < end; k++) {
|
||||
const seg = segs[k], share = (Math.min(seg.e, end) - Math.max(seg.s, start)) / size;
|
||||
if (share <= 0) continue;
|
||||
seg.cell.time[seg.i] += ms * share;
|
||||
for (const [manga, mangaMs] of mangas || []) seg.cell.manga.set(manga, (seg.cell.manga.get(manga) || 0) + mangaMs * share);
|
||||
}
|
||||
}
|
||||
for (const item of (data && data.done) || []) {
|
||||
const k = firstEndingAfter(item[0]), seg = segs[k];
|
||||
if (!seg || seg.s > item[0]) continue;
|
||||
seg.cell.chapters[seg.i]++;
|
||||
seg.cell.done.push(item);
|
||||
}
|
||||
for (const cell of cells) {
|
||||
cell.totalTime = cell.time.reduce((a, b) => a + b, 0);
|
||||
cell.totalChapters = cell.chapters.reduce((a, b) => a + b, 0);
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
// 0…1 glow, square-root scaled so one long evening does not flatten everything else.
|
||||
const activityGlow = (value, max) => (value > 0 && max > 0 ? Math.min(1, Math.sqrt(value / max)) : 0);
|
||||
|
||||
// Where a click on a cell leads: month → its days, week → the days of its month (week marked), day → hours.
|
||||
function activityDrill(cell) {
|
||||
if (cell.level === 'month') return { view: 'day', anchor: cell.start };
|
||||
if (cell.level === 'week') return { view: 'day', anchor: periodShift('day', cell.start, 3), mark: cell.start };
|
||||
if (cell.level === 'day') return { view: 'hour', anchor: cell.start };
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined') module.exports = {
|
||||
ACTIVITY_VIEWS, periodStart, periodShift, isoWeek, segmentBounds, segmentText, activityLayout, activitySums, activityGlow, activityDrill,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
/* Settings → Reading activity: a grid of months, weeks, days or hours. Each cell glows with its total
|
||||
and carries a strip from its start to its end, so you can see *when* inside the period you read.
|
||||
Click drills down (month/week → days → hours); hover, focus or a first tap shows the details. */
|
||||
|
||||
function activityCard() {
|
||||
const VIEWS = { month: 'Months', week: 'Weeks', day: 'Days', hour: 'Hours' };
|
||||
const UNIT = { month: 'days', week: 'quarter days', day: 'hours', hour: '6 minutes' };
|
||||
const saved = (() => { try { return JSON.parse(store.get('activity') || '{}'); } catch (e) { return {}; } })();
|
||||
const state = { view: VIEWS[saved.view] ? saved.view : 'week', metric: saved.metric === 'chapters' ? 'chapters' : 'time', anchor: Date.now(), mark: null };
|
||||
let data = null, armed = null, lastPointer = 'mouse';
|
||||
|
||||
const remember = () => store.set('activity', JSON.stringify({ view: state.view, metric: state.metric }));
|
||||
const card = h('section', { class: 'card activity-card' });
|
||||
const notice = h('p', { class: 'muted small', hidden: true });
|
||||
const summary = h('p', { class: 'activity-summary' });
|
||||
const grid = h('div', { class: 'activity-grid', role: 'grid' });
|
||||
const tip = h('div', { class: 'activity-tip', role: 'tooltip', hidden: true });
|
||||
const viewSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Grid' });
|
||||
const metricSeg = h('div', { class: 'seg', role: 'group', 'aria-label': 'Measure' });
|
||||
const title = h('b', { class: 'activity-title' });
|
||||
const legend = h('p', { class: 'muted small activity-legend' });
|
||||
card.append(
|
||||
h('div', { class: 'activity-toolbar' }, viewSeg, metricSeg),
|
||||
h('div', { class: 'activity-nav' },
|
||||
h('button', { class: 'btn small', 'aria-label': 'Earlier', onclick: () => move(-1) }, '‹'),
|
||||
title,
|
||||
h('button', { class: 'btn small', 'aria-label': 'Later', onclick: () => move(1) }, '›'),
|
||||
h('button', { class: 'btn small', onclick: () => { state.anchor = Date.now(); state.mark = null; draw(); } }, 'Today')),
|
||||
notice, summary, grid, legend, tip);
|
||||
|
||||
const duration = ms => readingDuration(ms, true);
|
||||
const valueText = (metric, value) => metric === 'time' ? duration(value) : `${value} chapter${value === 1 ? '' : 's'}`;
|
||||
const mangaTitle = index => data.mangas[index]?.title || '?';
|
||||
|
||||
function move(direction) {
|
||||
const layout = activityLayout(state.view, state.anchor);
|
||||
state.anchor = periodShift(layout.step[0], periodStart(layout.step[0], state.anchor), direction * layout.step[1]);
|
||||
state.mark = null;
|
||||
draw();
|
||||
}
|
||||
|
||||
function setView(view, anchor = state.anchor, mark = null) {
|
||||
state.view = view; state.anchor = anchor; state.mark = mark;
|
||||
remember(); draw();
|
||||
}
|
||||
|
||||
function drawControls() {
|
||||
viewSeg.replaceChildren(...Object.entries(VIEWS).map(([key, label]) =>
|
||||
h('button', { class: key === state.view ? 'on' : '', 'aria-pressed': String(key === state.view), onclick: () => setView(key) }, label)));
|
||||
metricSeg.replaceChildren(...[['time', 'Reading time'], ['chapters', 'Chapters finished']].map(([key, label]) =>
|
||||
h('button', { class: key === state.metric ? 'on' : '', 'aria-pressed': String(key === state.metric), onclick: () => { state.metric = key; remember(); draw(); } }, label)));
|
||||
}
|
||||
|
||||
function details(cell) {
|
||||
const time = cell.totalTime, chapters = cell.totalChapters;
|
||||
const lines = [h('b', {}, cell.title)];
|
||||
if (cell.start > Date.now()) { lines.push(h('div', { class: 'muted' }, 'Still ahead')); return lines; }
|
||||
lines.push(h('div', {}, time >= 30000 ? duration(time) + ' reading' : 'No reading time', ' · ', valueText('chapters', chapters) + ' finished'));
|
||||
const values = state.metric === 'time' ? cell.time : cell.chapters, active = values.filter(v => v > 0).length;
|
||||
if (active) {
|
||||
const peak = values.indexOf(Math.max(...values));
|
||||
lines.push(h('div', { class: 'muted' }, `Active in ${active} of ${values.length} ${UNIT[cell.level]} · busiest ${segmentText(cell.level, cell.bounds[peak], cell.bounds[peak + 1])}`));
|
||||
}
|
||||
const top = [...cell.manga].filter(([, ms]) => ms >= 30000).sort((a, b) => b[1] - a[1]).slice(0, 3);
|
||||
if (top.length) lines.push(h('ul', {}, top.map(([index, ms]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ' · ' + duration(ms))))));
|
||||
if (cell.level === 'hour' && cell.done.length) {
|
||||
lines.push(h('ul', {}, cell.done.slice(0, 6).map(([t, index, label]) => h('li', {}, `✓ ${mangaTitle(index)} · ${label}`,
|
||||
h('span', { class: 'muted' }, ' · ' + new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }))))));
|
||||
} else if (!top.length && cell.done.length) {
|
||||
const counts = new Map();
|
||||
for (const [, index] of cell.done) counts.set(index, (counts.get(index) || 0) + 1);
|
||||
lines.push(h('ul', {}, [...counts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([index, n]) => h('li', {}, mangaTitle(index), h('span', { class: 'muted' }, ` · ${n} ✓`)))));
|
||||
}
|
||||
if (activityDrill(cell)) lines.push(h('div', { class: 'muted small' }, lastPointer === 'touch' ? 'Tap again to open' : 'Click to open'));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function showTip(cell, el) {
|
||||
tip.replaceChildren(...details(cell));
|
||||
tip.hidden = false;
|
||||
const box = el.getBoundingClientRect(), width = tip.offsetWidth, height = tip.offsetHeight;
|
||||
const left = Math.max(8, Math.min(window.innerWidth - width - 8, box.left + box.width / 2 - width / 2));
|
||||
const top = box.top - height - 8 > 8 ? box.top - height - 8 : box.bottom + 8;
|
||||
tip.style.left = left + 'px'; tip.style.top = top + 'px';
|
||||
}
|
||||
const hideTip = () => { tip.hidden = true; armed = null; };
|
||||
|
||||
function cellNode(cell, maxCell, maxSeg) {
|
||||
const values = state.metric === 'time' ? cell.time : cell.chapters, total = state.metric === 'time' ? cell.totalTime : cell.totalChapters;
|
||||
const now = Date.now(), future = cell.start > now;
|
||||
const glow = activityGlow(total, maxCell);
|
||||
const strip = h('span', { class: 'activity-strip', 'aria-hidden': 'true' }, values.map((v, i) =>
|
||||
h('i', { class: cell.bounds[i] > now ? 'ahead' : '', style: `--a:${activityGlow(v, maxSeg).toFixed(3)}` })));
|
||||
const node = h('button', {
|
||||
type: 'button', role: 'gridcell',
|
||||
class: 'activity-cell' + (cell.outside ? ' outside' : '') + (future ? ' future' : '') + (glow >= 0.85 ? ' hot' : '')
|
||||
+ (state.mark != null && cell.level === 'day' && periodStart('week', cell.start) === state.mark ? ' marked' : '')
|
||||
+ (cell.start <= now && now < cell.end ? ' now' : ''),
|
||||
style: `--g:${glow.toFixed(3)}`,
|
||||
'aria-label': `${cell.title}: ${future ? 'still ahead' : valueText(state.metric, total)}`,
|
||||
onmouseenter: e => { if (lastPointer !== 'touch') showTip(cell, e.currentTarget); },
|
||||
onmouseleave: () => { if (lastPointer !== 'touch') hideTip(); },
|
||||
onfocus: e => showTip(cell, e.currentTarget),
|
||||
onblur: hideTip,
|
||||
onpointerdown: e => { lastPointer = e.pointerType || 'mouse'; },
|
||||
onclick: e => {
|
||||
// On touch the first tap shows the details; the second opens the next level.
|
||||
if (lastPointer === 'touch' && armed !== cell) { armed = cell; showTip(cell, e.currentTarget); return; }
|
||||
const next = activityDrill(cell);
|
||||
if (next && !future) { hideTip(); setView(next.view, next.anchor, next.mark ?? null); }
|
||||
},
|
||||
},
|
||||
h('span', { class: 'activity-label' }, cell.label, cell.sub ? h('small', {}, cell.sub) : null),
|
||||
!future && total && (state.metric === 'chapters' || total >= 30000)
|
||||
? h('span', { class: 'activity-value' }, state.metric === 'chapters' ? String(total)
|
||||
// Month cells are narrow; whole hours are enough there, the tooltip has the minutes.
|
||||
: cell.level === 'month' && total >= 36e5 ? Math.round(total / 36e5) + ' h' : duration(total)) : null,
|
||||
strip);
|
||||
return node;
|
||||
}
|
||||
|
||||
function draw() {
|
||||
drawControls();
|
||||
hideTip();
|
||||
const layout = activityLayout(state.view, state.anchor);
|
||||
title.textContent = layout.title;
|
||||
if (!data) { grid.replaceChildren(h('p', { class: 'muted' }, 'Loading your activity…')); return; }
|
||||
const cells = layout.rows.flatMap(row => row.cells);
|
||||
activitySums(data, cells);
|
||||
const counted = cells.filter(cell => !cell.outside);
|
||||
const key = state.metric === 'time' ? 'totalTime' : 'totalChapters', seg = state.metric === 'time' ? 'time' : 'chapters';
|
||||
const maxCell = Math.max(0, ...counted.map(cell => cell[key]));
|
||||
const maxSeg = Math.max(0, ...counted.flatMap(cell => cell[seg]));
|
||||
|
||||
grid.style.setProperty('--cols', layout.columns);
|
||||
grid.className = 'activity-grid view-' + state.view;
|
||||
grid.replaceChildren(
|
||||
...(layout.header ? [h('div', { class: 'activity-row head', role: 'row' }, h('span', { class: 'activity-row-label' }),
|
||||
h('div', { class: 'activity-cells' }, layout.header.map(name => h('span', { class: 'activity-head' }, name))))] : []),
|
||||
...layout.rows.map(row => h('div', { class: 'activity-row' + (state.mark != null && row.week === state.mark ? ' marked' : ''), role: 'row' },
|
||||
h('span', { class: 'activity-row-label' }, row.label),
|
||||
h('div', { class: 'activity-cells' }, row.cells.map(cell => cellNode(cell, maxCell, maxSeg))))));
|
||||
|
||||
const time = counted.reduce((sum, cell) => sum + cell.totalTime, 0), chapters = counted.reduce((sum, cell) => sum + cell.totalChapters, 0);
|
||||
const manga = new Map();
|
||||
for (const cell of counted) for (const [index, ms] of cell.manga) manga.set(index, (manga.get(index) || 0) + ms);
|
||||
const top = [...manga].sort((a, b) => b[1] - a[1])[0];
|
||||
summary.replaceChildren(
|
||||
h('b', {}, time >= 30000 ? duration(time) : 'No reading time'), ' · ', valueText('chapters', chapters), ' finished',
|
||||
...(top && top[1] >= 30000 ? [' · most time with ', h('b', {}, mangaTitle(top[0]))] : []));
|
||||
const coarse = layout.from < Date.now() - data.fineDays * 864e5 && state.view === 'hour';
|
||||
legend.textContent = `Each cell runs from left (its start) to right (its end) in ${UNIT[layout.rows[0].cells[0].level]}; brighter means more ${state.metric === 'time' ? 'reading time' : 'finished chapters'}.`
|
||||
+ (state.view === 'hour' ? '' : ' Click a cell to look closer.')
|
||||
+ (coarse ? ` Time older than ${data.fineDays} days is kept per hour, so it is spread evenly inside the hour.` : '');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
data = await api('api/reading/activity');
|
||||
notice.hidden = data.readingClock;
|
||||
notice.replaceChildren('The Active Reading Clock is off, so no new reading time is recorded; finished chapters still show. Turn it on under ',
|
||||
h('a', { href: 'settings.html#user/reading-time' }, 'Reading time & privacy'), '.');
|
||||
if (!data.buckets.length && saved.metric == null) state.metric = 'chapters';
|
||||
draw();
|
||||
} catch (error) { grid.replaceChildren(h('p', { class: 'error', role: 'alert' }, `Could not load your activity: ${error.message}`)); }
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => { if (!tip.hidden) hideTip(); }, { passive: true });
|
||||
draw();
|
||||
return { element: card, activate: load, deactivate: hideTip };
|
||||
}
|
||||
@@ -387,6 +387,8 @@ main { max-width: 1200px; margin: 0 auto; padding: 16px; }
|
||||
.login input { width: 100%; }
|
||||
.saved { color: #7fd3a0; font-size: 13px; }
|
||||
.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-danger { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.reading-danger p { margin: 3px 0 8px; }
|
||||
@@ -573,3 +575,47 @@ kbd { display: inline-block; min-width: 24px; padding: 1px 7px; border: 1px soli
|
||||
background: var(--card2); font: 13px/1.5 ui-monospace, monospace; text-align: center; }
|
||||
@media (max-width: 480px) { .moment-dialog { padding: 12px; } .moment-actions .moment-hint { flex-basis: 100%; } .moment-actions .moment-hint:empty { display: none; } }
|
||||
.moment-extend { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 8px; }
|
||||
|
||||
/* Reading activity grid (Settings → Reading activity). --g: cell glow 0…1, --a: strip segment glow 0…1. */
|
||||
.activity-toolbar { display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; }
|
||||
.activity-nav { display: flex; align-items: center; gap: 8px; margin: 12px 0 4px; }
|
||||
.activity-title { min-width: 11em; text-align: center; }
|
||||
.activity-summary { margin: 6px 0 12px; }
|
||||
.activity-grid { display: flex; flex-direction: column; gap: 6px; }
|
||||
.activity-row { display: grid; grid-template-columns: 5.5em 1fr; gap: 8px; align-items: stretch; border-radius: 10px; }
|
||||
.activity-row.marked { background: color-mix(in srgb, var(--accent) 12%, transparent); outline: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
|
||||
.activity-row-label { color: var(--muted); font-size: 13px; align-self: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.activity-cells { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 1fr)); gap: 6px; }
|
||||
.activity-head { color: var(--muted); font-size: 12px; text-align: center; }
|
||||
.activity-cell { position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: 6px; min-height: 58px; padding: 6px 7px 7px;
|
||||
border: 1px solid var(--line); border-radius: 8px; color: var(--fg); font: inherit; text-align: left; cursor: pointer;
|
||||
background: color-mix(in srgb, var(--accent) calc(var(--g) * 42%), var(--card2));
|
||||
box-shadow: 0 0 calc(var(--g) * 12px) color-mix(in srgb, var(--accent) calc(var(--g) * 55%), transparent);
|
||||
transition: transform .12s, border-color .12s; }
|
||||
.activity-cell:hover, .activity-cell:focus-visible { border-color: var(--accent); transform: translateY(-1px); outline: none; }
|
||||
.activity-cell.hot { border-color: color-mix(in srgb, var(--accent) 70%, #fff); }
|
||||
.activity-cell.now { border-color: var(--fg); }
|
||||
.activity-cell.outside { opacity: .38; }
|
||||
.activity-cell.future { cursor: default; background: transparent; border-style: dashed; box-shadow: none; transform: none; }
|
||||
.view-hour .activity-cell { cursor: default; }
|
||||
.activity-label { font-size: 13px; font-weight: 600; line-height: 1.1; display: flex; flex-direction: column; }
|
||||
.activity-label small { font-weight: 400; color: var(--muted); font-size: 11px; }
|
||||
.activity-value { position: absolute; top: 6px; right: 7px; font-size: 11px; color: color-mix(in srgb, var(--fg) 80%, transparent); }
|
||||
.view-day .activity-value, .view-month .activity-value { position: static; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.activity-strip { display: flex; height: 9px; border-radius: 3px; overflow: hidden; background: color-mix(in srgb, var(--bg) 70%, transparent); }
|
||||
.activity-strip i { flex: 1; background: var(--accent); opacity: var(--a); }
|
||||
.activity-strip i.ahead { background: transparent; }
|
||||
.activity-strip i + i { box-shadow: inset 1px 0 0 color-mix(in srgb, var(--bg) 35%, transparent); }
|
||||
.view-month .activity-strip i + i, .view-week .activity-strip i + i { box-shadow: none; }
|
||||
.activity-tip { position: fixed; z-index: 50; max-width: 300px; padding: 10px 12px; border-radius: 10px; pointer-events: none;
|
||||
background: var(--card); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(0, 0, 0, .45); font-size: 13px; line-height: 1.45; }
|
||||
.activity-tip ul { margin: 4px 0 0; padding-left: 16px; }
|
||||
.activity-legend { margin-top: 12px; }
|
||||
@media (max-width: 760px) {
|
||||
.activity-row { grid-template-columns: 1fr; gap: 3px; }
|
||||
.activity-row.head .activity-row-label { display: none; }
|
||||
.view-month .activity-cells { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
.activity-cell { min-height: 48px; padding: 5px; }
|
||||
.activity-value { display: none; }
|
||||
.activity-cells { gap: 4px; }
|
||||
}
|
||||
+3
-2
@@ -55,8 +55,9 @@
|
||||
h('option', { value: 'grouped' }, 'Group collections'), h('option', { value: 'expanded' }, 'Expand collections'), h('option', { value: 'individual' }, 'Individual manga'));
|
||||
grouping.value = ['grouped', 'expanded', 'individual'].includes(store.get('grouping')) ? store.get('grouping') : 'grouped';
|
||||
grouping.onchange = render;
|
||||
// Started but unfinished manga, most recently read first; the same manga still appear in the grid.
|
||||
const continueItems = data.mangas.filter(m => m.lastRead && m.read < m.chapters)
|
||||
// Started manga with chapters left after the last one read (first read or reread, server `left`),
|
||||
// most recently read first; the same manga still appear in the grid.
|
||||
const continueItems = data.mangas.filter(m => m.lastRead && m.left > 0)
|
||||
.sort((a, b) => b.lastRead - a.lastRead).slice(0, 5);
|
||||
const continueRow = h('section', { class: 'continue-row', 'aria-label': 'Continue where you left off', hidden: true },
|
||||
h('div', { class: 'continue-banner' }, h('span', { 'aria-hidden': 'true' }, '▶'), 'Continue where you left off'),
|
||||
|
||||
@@ -5,6 +5,7 @@ const SETTINGS_SECTIONS = [
|
||||
{ id: 'user/reading', group: 'User', title: 'Reading', hint: 'Reader modes, page spacing and zoom.' },
|
||||
{ id: 'user/reading-hours', group: 'User', title: 'Reading hours', hint: 'When this server allows chapter reading.' },
|
||||
{ id: 'user/reading-time', group: 'User', title: 'Reading time & privacy', hint: 'Your clock, Reading Story and personal data.' },
|
||||
{ id: 'user/activity', group: 'User', title: 'Reading activity', hint: 'When you read: by month, week, day or hour. Private to your account.' },
|
||||
{ id: 'user/shortcuts', group: 'User', title: 'Keyboard shortcuts', hint: 'Every key the reader understands. Press ? in the reader for the same list.' },
|
||||
{ id: 'user/notifications', group: 'User', title: 'Notifications', hint: 'Chapter alerts and your optional webhook.' },
|
||||
{ id: 'user/account', group: 'User', title: 'Account', hint: 'Your account and password.' },
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<script src="common.js" defer></script>
|
||||
<script src="keys.js" defer></script>
|
||||
<script src="settings-nav.js" defer></script>
|
||||
<script src="activity-core.js" defer></script>
|
||||
<script src="activity.js" defer></script>
|
||||
<script src="settings.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+63
-1
@@ -15,15 +15,68 @@
|
||||
const saved = h('span', { class: 'saved', hidden: true }, 'Saved');
|
||||
shell.titleRow.append(saved);
|
||||
let savedTimer = 0;
|
||||
let refreshSessions = () => {};
|
||||
async function save(patch) {
|
||||
Object.assign(S, (await api('api/settings', patch)).settings);
|
||||
saved.hidden = false;
|
||||
clearTimeout(savedTimer);
|
||||
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' },
|
||||
h('input', { type: 'checkbox', checked: !!S[key], onchange: e => save({ [key]: e.target.checked }) }),
|
||||
h('span', {}, text, hint ? h('div', { class: 'muted small' }, hint) : null));
|
||||
const number = (key, text, max) => h('label', { class: 'field' }, h('span', {}, text),
|
||||
h('input', { type: 'number', min: 0, max, step: 1, value: S[key], style: 'width:6em',
|
||||
onchange: e => { const n = Math.round(+e.target.value); if (n >= 0 && n <= max) save({ [key]: n }); else e.target.value = S[key]; } }));
|
||||
|
||||
const startMode = h('select', { onchange: e => save({ startMode: e.target.value }) },
|
||||
h('option', { value: 'last' }, 'The mode I used last'),
|
||||
@@ -90,10 +143,15 @@
|
||||
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.'))));
|
||||
|
||||
section('user/reading').append(readerSessionsCard());
|
||||
|
||||
section('user/reading').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Library page'),
|
||||
check('continueRow', 'Show "Continue where you left off" at the top',
|
||||
'One row with the five manga you read most recently and have not finished. It hides itself while you search, filter by status, or sort by "Recently read".')));
|
||||
'One row with the five manga you read most recently that still have chapters left after the last one you read. It hides itself while you search, filter by status, or sort by "Recently read".'),
|
||||
h('p', { class: 'muted small' }, 'Rereading: later chapters you finished in an earlier pass count as left again when either rule applies. 0 turns a rule off.'),
|
||||
number('rereadDays', 'Finished more than this many days before my last read', 3650),
|
||||
number('rereadChapters', 'Or I have since read more than this many chapters in a row', 1000)));
|
||||
|
||||
section('user/reading').append(h('section', { class: 'card' },
|
||||
h('h2', {}, 'Chapter list dates'),
|
||||
@@ -126,6 +184,10 @@
|
||||
'Shows your private chapter times and personal summaries on manga and library pages. Hiding it does not delete existing history.'),
|
||||
readingData));
|
||||
|
||||
const activity = activityCard();
|
||||
section('user/activity').append(activity.element);
|
||||
controllers.set('user/activity', activity);
|
||||
|
||||
const hook = h('input', { type: 'url', placeholder: 'https://ntfy.sh/my-manga-topic', value: S.webhook || '', style: 'width:100%' });
|
||||
const hookMsg = h('div', { class: 'small' });
|
||||
section('user/notifications').append(h('section', { class: 'card' },
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
// Reading activity: active reader time in small UTC time buckets, for the activity grid in Settings.
|
||||
// Buckets hold time and which manga it went to, never pages, input events or a raw event log.
|
||||
// Recent time keeps 6-minute buckets (a tenth of an hour); older time folds into hours.
|
||||
|
||||
const FINE_MS = 6 * 60 * 1000, HOUR_MS = 60 * 60 * 1000, DAY_MS = 24 * HOUR_MS;
|
||||
const FINE_DAYS = 120, KEEP_DAYS = 3 * 366;
|
||||
|
||||
const whole = value => Math.max(0, Math.round(Number(value) || 0));
|
||||
|
||||
function emptyActivity() { return { schema: 1, fine: {}, hourly: {}, backfilled: false }; }
|
||||
|
||||
function normalizeActivity(activity) {
|
||||
const out = activity && typeof activity === 'object' ? activity : emptyActivity();
|
||||
if (!out.fine || typeof out.fine !== 'object') out.fine = {};
|
||||
if (!out.hourly || typeof out.hourly !== 'object') out.hourly = {};
|
||||
out.schema = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
function credit(map, key, mangaId, ms) {
|
||||
const bucket = (map[key] = map[key] || { a: 0, m: {} });
|
||||
bucket.a += ms;
|
||||
bucket.m[mangaId] = (bucket.m[mangaId] || 0) + ms;
|
||||
}
|
||||
|
||||
// Active time `ms` that ended at `end`, spread over the buckets it covers.
|
||||
function addActive(activity, mangaId, ms, end) {
|
||||
const act = normalizeActivity(activity);
|
||||
let left = whole(ms), at = whole(end);
|
||||
if (!left || !mangaId) return act;
|
||||
while (left > 0) {
|
||||
const key = Math.floor((at - 1) / FINE_MS), share = Math.min(left, at - key * FINE_MS);
|
||||
credit(act.fine, key, mangaId, share);
|
||||
left -= share; at -= share;
|
||||
}
|
||||
return act;
|
||||
}
|
||||
|
||||
// Fine buckets older than FINE_DAYS become hourly; anything older than KEEP_DAYS is dropped.
|
||||
function fold(activity, now = Date.now()) {
|
||||
const act = normalizeActivity(activity);
|
||||
const fineLimit = Math.floor((now - FINE_DAYS * DAY_MS) / FINE_MS), keepLimit = Math.floor((now - KEEP_DAYS * DAY_MS) / HOUR_MS);
|
||||
for (const [key, bucket] of Object.entries(act.fine)) {
|
||||
if (+key >= fineLimit) continue;
|
||||
const hour = Math.floor(+key * FINE_MS / HOUR_MS);
|
||||
for (const [mangaId, ms] of Object.entries(bucket.m)) credit(act.hourly, hour, mangaId, ms);
|
||||
delete act.fine[key];
|
||||
}
|
||||
for (const key of Object.keys(act.hourly)) if (+key < keepLimit) delete act.hourly[key];
|
||||
return act;
|
||||
}
|
||||
|
||||
// One-time seed from the visits the Reading Clock still remembers (the last eight per chapter).
|
||||
// A visit's active time is placed just before its last save; older visits are gone and stay gone.
|
||||
function backfill(activity, readingByManga, now = Date.now()) {
|
||||
const act = normalizeActivity(activity);
|
||||
if (act.backfilled) return act;
|
||||
for (const [mangaId, chapters] of Object.entries(readingByManga || {})) {
|
||||
for (const record of Object.values(chapters || {})) {
|
||||
for (const receipt of record?.recentSessions || []) {
|
||||
const pass = receipt?.pass, ms = whole(pass?.activeMs), end = whole(receipt?.at);
|
||||
if (!ms || !end || end > now) continue;
|
||||
const start = Math.max(whole(pass.startedAt) || end - ms, end - ms);
|
||||
addActive(act, mangaId, Math.min(ms, end - start), end);
|
||||
}
|
||||
}
|
||||
}
|
||||
act.backfilled = true;
|
||||
return fold(act, now);
|
||||
}
|
||||
|
||||
function forgetManga(activity, mangaId) {
|
||||
const act = normalizeActivity(activity);
|
||||
for (const map of [act.fine, act.hourly]) {
|
||||
for (const [key, bucket] of Object.entries(map)) {
|
||||
const ms = bucket.m[mangaId];
|
||||
if (!ms) continue;
|
||||
delete bucket.m[mangaId];
|
||||
bucket.a = Math.max(0, bucket.a - ms);
|
||||
if (!bucket.a) delete map[key];
|
||||
}
|
||||
}
|
||||
return act;
|
||||
}
|
||||
|
||||
// Compact payload for the grid: [startMs, sizeMs, activeMs, [[mangaIndex, ms], …]] per bucket,
|
||||
// chapter completions as [doneAt, mangaIndex, chapterLabel]. The browser groups them in local time.
|
||||
function activitySeries(activity, progressByManga, { titleOf = id => id, labelOf = (m, c) => c } = {}) {
|
||||
const act = normalizeActivity(activity);
|
||||
const mangas = [], index = new Map();
|
||||
const ref = id => {
|
||||
if (!index.has(id)) { index.set(id, mangas.length); mangas.push({ id, title: titleOf(id) || id }); }
|
||||
return index.get(id);
|
||||
};
|
||||
const buckets = [];
|
||||
for (const [map, size] of [[act.hourly, HOUR_MS], [act.fine, FINE_MS]]) {
|
||||
for (const [key, bucket] of Object.entries(map)) {
|
||||
if (!bucket.a) continue;
|
||||
buckets.push([+key * size, size, whole(bucket.a), Object.entries(bucket.m).map(([id, ms]) => [ref(id), whole(ms)])]);
|
||||
}
|
||||
}
|
||||
buckets.sort((a, b) => a[0] - b[0]);
|
||||
const done = [];
|
||||
for (const [mangaId, chapters] of Object.entries(progressByManga || {})) {
|
||||
for (const [chapterId, p] of Object.entries(chapters || {})) {
|
||||
if (p && p.done && whole(p.doneAt)) done.push([whole(p.doneAt), ref(mangaId), labelOf(mangaId, chapterId)]);
|
||||
}
|
||||
}
|
||||
done.sort((a, b) => a[0] - b[0]);
|
||||
return { schema: 1, fineMs: FINE_MS, fineDays: FINE_DAYS, mangas, buckets, done };
|
||||
}
|
||||
|
||||
module.exports = { FINE_MS, HOUR_MS, FINE_DAYS, KEEP_DAYS, emptyActivity, addActive, fold, backfill, forgetManga, activitySeries };
|
||||
@@ -34,12 +34,14 @@ const { shouldRunNightly, Harvester } = require('./discovery/harvest');
|
||||
const {
|
||||
applyPulse, applyVisit, chapterInsight, earliestCompletion, eligiblePages, emptyRecord, libraryInsight, mangaInsight, publicSummary,
|
||||
} = require('./reading-analytics');
|
||||
const readingActivity = require('./reading-activity');
|
||||
const { chapterDates } = require('./chapter-dates');
|
||||
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
|
||||
const { BackupService, BackupError } = require('./backup');
|
||||
const moments = require('./moments');
|
||||
const { Tracer, requestTrace } = require('./perf/trace');
|
||||
const { ReadAhead } = require('./perf/read-ahead');
|
||||
const readerSessions = require('./reader-sessions');
|
||||
|
||||
const PORT = +process.env.PORT || 8080;
|
||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||
@@ -79,7 +81,7 @@ const greatestCovers = new GreatestCovers(path.join(DATA, 'greatest-covers'));
|
||||
const greatestCoverUrl = entry => entry.cover
|
||||
? `api/greatest-cover?id=${encodeURIComponent(entry.id)}&v=${greatestCovers.version(entry.cover)}` : null;
|
||||
const DB_FILE = path.join(DATA, 'db.json'), CACHE_FILE = path.join(DATA, 'scan-cache.json'), INDEX_FILE = path.join(DATA, 'library-index.json');
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, activity: {}, readingAccess: {}, follows: {}, notes: {}, votes: {}, greatestRatings: {}, discoveryFeedback: {}, moments: {}, known: {} };
|
||||
// A corrupt db.json stops the server instead of silently starting empty and overwriting it.
|
||||
if (fs.existsSync(DB_FILE)) Object.assign(db, JSON.parse(fs.readFileSync(DB_FILE, 'utf8')));
|
||||
function writeAtomic(file, text) { fs.writeFileSync(file + '.tmp', text); fs.renameSync(file + '.tmp', file); }
|
||||
@@ -88,6 +90,7 @@ function save() { saveTimer = saveTimer || setTimeout(flush, 500); }
|
||||
function flush() { clearTimeout(saveTimer); saveTimer = null; writeAtomic(DB_FILE, JSON.stringify(db)); }
|
||||
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { flush(); process.exit(0); });
|
||||
db.readingAccess ||= {};
|
||||
db.activity ||= {};
|
||||
// The reader owns backups: it alone mounts all of DATA and holds db.json in memory.
|
||||
const backup = new BackupService({ dataRoot: DATA, backupRoot: process.env.BACKUP || '', snapshotDb: () => JSON.stringify(db),
|
||||
scraper: { state: () => scraperRequest('/api/state'), control: action => scraperRequest('/api/control', { action }) } }).start();
|
||||
@@ -119,8 +122,10 @@ function checkPw(pw, stored) {
|
||||
}
|
||||
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,
|
||||
continueRow: true, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '' };
|
||||
const settingsOf = u => ({ ...DEFAULTS, ...u.settings, zoom: { ...(u.settings && u.settings.zoom) } });
|
||||
continueRow: true, rereadDays: 7, rereadChapters: 5, readDate: 'hour', releaseDate: true, discoveryEnabled: false, discoveryReadingActivity: false, discoveryMature: false, zoom: {}, webhook: '', readerPerSession: true };
|
||||
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) {
|
||||
name = String(name || '').trim().toLowerCase(); pw = String(pw || '');
|
||||
@@ -132,13 +137,21 @@ function createUser(name, pw, admin, replace) {
|
||||
save();
|
||||
return db.users[name];
|
||||
}
|
||||
function startSession(res, user) {
|
||||
function startSession(req, res, user) {
|
||||
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();
|
||||
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; };
|
||||
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; }
|
||||
for (const [k, s] of Object.entries(db.sessions)) if (Date.now() - s.t > 365 * 864e5 || !db.users[s.u]) delete db.sessions[k];
|
||||
|
||||
@@ -240,6 +253,18 @@ const chapterOf = (m, id) => m.chapterById.get(String(id)) || bad(404, 'Chapter
|
||||
const progressOf = (u, m) => (db.progress[u.name] && db.progress[u.name][m.id]) || {};
|
||||
const mangaReadingOf = (u, m) => (db.reading[u.name] && db.reading[u.name][m.id]) || {};
|
||||
const readingOf = (u, m, ch) => (db.reading[u.name] && db.reading[u.name][m.id] && db.reading[u.name][m.id][ch.id]) || null;
|
||||
const activityPayload = u => readingActivity.activitySeries(activityOf(u), db.progress[u.name], {
|
||||
titleOf: id => lib.byId.get(id)?.title,
|
||||
labelOf: (mangaId, chapterId) => lib.byId.get(mangaId)?.chapterById.get(chapterId)?.label || chapterId,
|
||||
});
|
||||
// The activity grid's buckets; the first use seeds them from the visits the clock still remembers.
|
||||
function activityOf(u, now = Date.now()) {
|
||||
if (!db.activity[u.name]) { db.activity[u.name] = readingActivity.backfill(readingActivity.emptyActivity(), db.reading[u.name], now); save(); }
|
||||
const act = db.activity[u.name];
|
||||
// Folding old buckets into hours is cheap, but there is no need to do it on every pulse.
|
||||
if (Math.floor(now / readingActivity.HOUR_MS) !== act.foldedHour) { readingActivity.fold(act, now); act.foldedHour = Math.floor(now / readingActivity.HOUR_MS); }
|
||||
return act;
|
||||
}
|
||||
const unreadOf = u => (db.notes[u.name] || []).filter(n => !n.read).length;
|
||||
const momentsOf = u => (db.moments[u.name] ||= []);
|
||||
const momentOf = (u, id) => momentsOf(u).find(x => x.id === String(id || '')) || bad(404, 'Moment not found');
|
||||
@@ -454,6 +479,25 @@ function continueOf(m, prog, s) {
|
||||
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 };
|
||||
}
|
||||
// Chapters still ahead of the head: never finished, or last read in an earlier pass. 0 = caught up.
|
||||
// A done chapter after the head belongs to an earlier pass (the head is a reread) when it was saved
|
||||
// more than `rereadDays` before the head, or before a run of more than `rereadChapters` chapters read
|
||||
// in order up to the head. 0 turns either rule off.
|
||||
function leftOf(m, prog, s) {
|
||||
const last = lastOf(m, prog);
|
||||
if (!last) return 0;
|
||||
const chapters = m.chapters.filter(c => c === last.ch || !(s.skipExtras && c.extra)), at = chapters.indexOf(last.ch);
|
||||
let runStart = last.p.t, run = 1;
|
||||
for (let i = at - 1; i >= 0; i--) {
|
||||
const p = prog[chapters[i].id];
|
||||
if (!p || !p.done || p.t > runStart) break;
|
||||
runStart = p.t; run++;
|
||||
}
|
||||
const gap = s.rereadDays > 0 ? s.rereadDays * 24 * 3600 * 1000 : Infinity;
|
||||
const passStart = s.rereadChapters > 0 && run > s.rereadChapters ? runStart : -Infinity;
|
||||
return chapters.slice(at + 1).filter(c => { const p = prog[c.id]; return !p || !p.done || last.p.t - p.t > gap || p.t < passStart; }).length
|
||||
+ (last.p.done ? 0 : 1);
|
||||
}
|
||||
// What a reader tab compares itself with (stale reading sessions, docs/architecture.md).
|
||||
function latestOf(m, prog, s) {
|
||||
const last = lastOf(m, prog);
|
||||
@@ -485,11 +529,11 @@ const PUBLIC = new Set(['/api/auth', '/api/login', '/api/signup']);
|
||||
const routes = {
|
||||
'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/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 next = view.members.find(m => m.read < m.chapters) || view.members[0];
|
||||
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)) };
|
||||
},
|
||||
'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 +581,16 @@ const routes = {
|
||||
},
|
||||
'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;
|
||||
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 };
|
||||
},
|
||||
'POST /api/login': ({ b, res }) => {
|
||||
'POST /api/login': ({ req, b, res }) => {
|
||||
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.');
|
||||
startSession(res, user);
|
||||
startSession(req, res, user);
|
||||
return { ok: true };
|
||||
},
|
||||
'POST /api/logout': ({ req, res }) => {
|
||||
@@ -555,7 +599,18 @@ const routes = {
|
||||
res.setHeader('Set-Cookie', 'sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
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-access': ({ u, q }) => { mangaOf(q.get('m')); return readingAccess.status(u.name, true); },
|
||||
@@ -572,22 +627,25 @@ const routes = {
|
||||
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.');
|
||||
const s = (u.settings = u.settings || {});
|
||||
const base = userSettingsOf(u), s = {};
|
||||
if (['last', 'flow', 'page', 'book'].includes(b.startMode)) s.startMode = b.startMode;
|
||||
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 (['off', 'date', 'hour'].includes(b.readDate)) s.readDate = b.readDate;
|
||||
if (Number.isInteger(b.rereadDays) && b.rereadDays >= 0 && b.rereadDays <= 3650) s.rereadDays = b.rereadDays;
|
||||
if (Number.isInteger(b.rereadChapters) && b.rereadChapters >= 0 && b.rereadChapters <= 1000) s.rereadChapters = b.rereadChapters;
|
||||
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') {
|
||||
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]));
|
||||
}
|
||||
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();
|
||||
return { settings: settingsOf(u) };
|
||||
return { settings: settingsOf(u, sess) };
|
||||
},
|
||||
'POST /api/password': ({ u, b }) => {
|
||||
if (!checkPw(String(b.old || ''), u.hash)) bad(403, 'The current password is wrong.');
|
||||
@@ -597,7 +655,7 @@ const routes = {
|
||||
|
||||
'GET /api/library': ({ u }) => {
|
||||
const prog = db.progress[u.name] || {}, fol = db.follows[u.name] || {}, notes = (db.notes[u.name] || []).filter(n => !n.read);
|
||||
const showStory = settingsOf(u).readingStory;
|
||||
const s = settingsOf(u), showStory = s.readingStory;
|
||||
const mangas = lib.mangas.map(m => {
|
||||
const p = prog[m.id] || {}, meta = m.meta || {}, release = firstReleaseOf(meta);
|
||||
return {
|
||||
@@ -607,7 +665,7 @@ const routes = {
|
||||
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [String(meta.tags)] : []), status: meta.status || '', year: meta.year || '',
|
||||
firstRelease: release.label, firstReleaseAt: release.time,
|
||||
read: m.chapters.filter(c => p[c.id] && p[c.id].done).length,
|
||||
lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0),
|
||||
lastRead: Object.values(p).reduce((t, x) => Math.max(t, x.t || 0), 0), left: leftOf(m, p, s),
|
||||
following: !!fol[m.id], unread: notes.filter(n => n.m === m.id).length,
|
||||
reading: showStory ? mangaInsight(mangaReadingOf(u, m), m.chapters.map(ch => ch.id)) : null,
|
||||
};
|
||||
@@ -715,8 +773,8 @@ const routes = {
|
||||
return { results: await searchGreatestMangaDex(entry) };
|
||||
},
|
||||
|
||||
'GET /api/manga': ({ u, q }) => {
|
||||
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u), showStory = s.readingStory;
|
||||
'GET /api/manga': ({ u, q, sess }) => {
|
||||
const m = mangaOf(q.get('m')), prog = progressOf(u, m), s = settingsOf(u, sess), showStory = s.readingStory;
|
||||
const records = mangaReadingOf(u, m);
|
||||
return {
|
||||
chapterDates: { read: s.readDate, release: s.releaseDate },
|
||||
@@ -733,7 +791,7 @@ const routes = {
|
||||
dates: s.releaseDate ? chapterDates(ch.versions) : null,
|
||||
})),
|
||||
reading: showStory ? mangaInsight(records, m.chapters.map(ch => ch.id)) : null,
|
||||
continue: continueOf(m, prog, settingsOf(u)),
|
||||
continue: continueOf(m, prog, s),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -757,25 +815,30 @@ const routes = {
|
||||
user: u.name,
|
||||
reading: libraryInsight(mangas),
|
||||
mangas,
|
||||
activity: activityPayload(u),
|
||||
};
|
||||
},
|
||||
|
||||
'GET /api/reading/activity': ({ u }) => ({ readingClock: !!settingsOf(u).readingClock, ...activityPayload(u) }),
|
||||
|
||||
'POST /api/reading/delete': ({ u, b }) => {
|
||||
if (b.all) {
|
||||
if (!checkPw(String(b.password || ''), u.hash)) bad(403, 'The current password is wrong.');
|
||||
delete db.reading[u.name];
|
||||
delete db.activity[u.name];
|
||||
readingLeases.delete(u.name);
|
||||
save();
|
||||
return { ok: true, deleted: 'all' };
|
||||
}
|
||||
const m = mangaOf(b.m), all = db.reading[u.name];
|
||||
if (all) delete all[m.id];
|
||||
if (db.activity[u.name]) readingActivity.forgetManga(db.activity[u.name], m.id);
|
||||
save();
|
||||
return { ok: true, deleted: m.id };
|
||||
},
|
||||
|
||||
'GET /api/chapter': ({ u, q }) => {
|
||||
const m = mangaOf(q.get('m')), ch = chapterOf(m, q.get('c')), s = settingsOf(u), prog = progressOf(u, m);
|
||||
'GET /api/chapter': ({ u, q, sess }) => {
|
||||
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.');
|
||||
const v = ch.versions.find(x => x.id === q.get('v')) || ranked(m, ch)[0].v;
|
||||
// Opening a chapter clears its "new chapter" notification.
|
||||
@@ -862,7 +925,15 @@ const routes = {
|
||||
const byUser = (db.reading[u.name] = db.reading[u.name] || {});
|
||||
const byManga = (byUser[m.id] = byUser[m.id] || {});
|
||||
const record = (byManga[ch.id] = readingOf(u, m, ch) || emptyRecord());
|
||||
// Seed the activity buckets from remembered visits before this visit changes them.
|
||||
const activity = activityOf(u, now);
|
||||
const visitMs = () => record.recentSessions.find(item => item.id === session)?.pass?.activeMs || 0, before = visitMs();
|
||||
const result = (cumulative ? applyVisit : applyPulse)(record, b, { version: v.id, pageCount: v.pages.length, eligible, now });
|
||||
// The activity grid counts all active reader time, including visits below the 60% threshold.
|
||||
if (!result.duplicate) {
|
||||
const added = cumulative ? visitMs() - before : result.activeMs;
|
||||
if (added > 0) readingActivity.addActive(activity, m.id, added, now);
|
||||
}
|
||||
if (b.release && readingLeases.get(u.name)?.session === session) readingLeases.delete(u.name);
|
||||
if (!result.duplicate) save();
|
||||
return {
|
||||
@@ -985,7 +1056,7 @@ const routes = {
|
||||
admin(u);
|
||||
const name = String(b.name || '');
|
||||
if (name === u.name) bad(400, "You can't delete your own account.");
|
||||
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
|
||||
delete db.users[name]; delete db.progress[name]; delete db.reading[name]; delete db.activity[name]; delete db.follows[name]; delete db.notes[name]; delete db.moments[name];
|
||||
if (db.discoveryFeedback) delete db.discoveryFeedback[name];
|
||||
if (db.greatestRatings) delete db.greatestRatings[name];
|
||||
for (const [k, s] of Object.entries(db.sessions)) if (s.u === name) delete db.sessions[k];
|
||||
@@ -1129,7 +1200,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');
|
||||
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) {
|
||||
let file;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
// "Continue where you left off" only lists manga with something left after the last chapter read:
|
||||
// unread chapters, or chapters last read in an earlier pass (a reread).
|
||||
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;
|
||||
}
|
||||
|
||||
async function start(library, data) {
|
||||
const port = await freePort(), output = [];
|
||||
const 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(''));
|
||||
return { child, base };
|
||||
}
|
||||
async function stop(child) {
|
||||
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
||||
}
|
||||
|
||||
test('the continue row counts chapters left after the head, rereads included', { timeout: 30000 }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-left-'));
|
||||
let server;
|
||||
try {
|
||||
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||
for (const [title, count] of [['Moon Garden', 4], ['Sun Field', 4], ['Star Path', 8]])
|
||||
for (let n = 1; n <= count; n++) {
|
||||
const dir = path.join(library, title, 'v01', `c00${n}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '001.png'), new Canvas(60, 90, [40, 60, 120]).png());
|
||||
}
|
||||
fs.mkdirSync(data);
|
||||
server = await start(library, data);
|
||||
let { base } = server;
|
||||
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 ids = async m => (await get(alice, '/api/manga?m=' + encodeURIComponent(m))).chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
|
||||
const moon = await ids('Moon Garden'), sun = await ids('Sun Field');
|
||||
const done = (m, ch) => post(alice, '/api/progress', { m, ...ch, p: 0, n: 1, done: true });
|
||||
const left = async () => Object.fromEntries((await get(alice, '/api/library')).mangas.map(m => [m.id, m.left]));
|
||||
|
||||
// First read stopped after chapter 2: two chapters left. Finished: nothing left.
|
||||
for (const ch of moon.slice(0, 2)) await done('Moon Garden', ch);
|
||||
for (const ch of sun) await done('Sun Field', ch);
|
||||
assert.deepEqual(await left(), { 'Moon Garden': 2, 'Sun Field': 0, 'Star Path': 0 });
|
||||
|
||||
// Read all 8 chapters of Star Path, then reread 1-6 right away: more than 5 in a row is a reread
|
||||
// even within the time limit; with a longer run setting, or the run rule off, it is not.
|
||||
const star = await ids('Star Path');
|
||||
for (const ch of star) await done('Star Path', ch);
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
for (const ch of star.slice(0, 5)) await done('Star Path', ch);
|
||||
assert.equal((await left())['Star Path'], 0, 'five in a row is not yet a reread');
|
||||
await done('Star Path', star[5]);
|
||||
assert.equal((await left())['Star Path'], 2);
|
||||
await post(alice, '/api/settings', { rereadChapters: 6 });
|
||||
assert.equal((await left())['Star Path'], 0);
|
||||
await post(alice, '/api/settings', { rereadChapters: 0 });
|
||||
assert.equal((await left())['Star Path'], 0);
|
||||
await post(alice, '/api/settings', { rereadChapters: 5, rereadDays: -1 });
|
||||
const settings = (await get(alice, '/api/me')).settings;
|
||||
assert.equal(settings.rereadDays, 7, 'invalid values are ignored');
|
||||
assert.equal((await left())['Star Path'], 2);
|
||||
|
||||
// Unfinished head: the rest of that chapter is still left.
|
||||
await post(alice, '/api/progress', { m: 'Moon Garden', ...moon[2], p: 0, n: 1 });
|
||||
assert.equal((await left())['Moon Garden'], 2);
|
||||
|
||||
// Glancing back at chapter 1 right after finishing is not a reread.
|
||||
await done('Sun Field', sun[0]);
|
||||
assert.equal((await left())['Sun Field'], 0);
|
||||
|
||||
// The finish was a month ago: rereading chapters 1-2 now leaves 3-4 from the earlier pass.
|
||||
await new Promise(resolve => setTimeout(resolve, 700));
|
||||
await stop(server.child);
|
||||
const dbFile = path.join(data, 'db.json'), db = JSON.parse(fs.readFileSync(dbFile, 'utf8'));
|
||||
const month = 30 * 24 * 3600 * 1000, sunProgress = db.progress.alice['Sun Field'];
|
||||
for (const p of Object.values(sunProgress)) { p.t -= month; p.doneAt -= month; }
|
||||
sunProgress[sun[0].c].t += month;
|
||||
fs.writeFileSync(dbFile, JSON.stringify(db));
|
||||
server = await start(library, data);
|
||||
base = server.base;
|
||||
await done('Sun Field', sun[1]);
|
||||
assert.equal((await left())['Sun Field'], 2);
|
||||
} finally {
|
||||
await stop(server && server.child);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const act = require('../reading-activity');
|
||||
const core = require('../public/activity-core');
|
||||
|
||||
const MIN = 60000, HOUR = 60 * MIN, DAY = 24 * HOUR;
|
||||
const sum = a => Object.values(a.fine).concat(Object.values(a.hourly)).reduce((s, b) => s + b.a, 0);
|
||||
|
||||
test('active time ending at a moment is spread over the 6-minute buckets it covers', () => {
|
||||
const a = act.emptyActivity(), end = 1000 * act.FINE_MS + 2 * MIN;
|
||||
act.addActive(a, 'alpha', 5 * MIN, end);
|
||||
assert.deepEqual(Object.keys(a.fine).map(Number), [999, 1000]);
|
||||
assert.equal(a.fine[1000].a, 2 * MIN); assert.equal(a.fine[999].a, 3 * MIN);
|
||||
act.addActive(a, 'beta', MIN, end);
|
||||
assert.deepEqual(a.fine[1000].m, { alpha: 2 * MIN, beta: MIN });
|
||||
});
|
||||
|
||||
test('old buckets fold into hours, very old ones are dropped, totals stay', () => {
|
||||
const now = 2000 * DAY, a = act.emptyActivity();
|
||||
act.addActive(a, 'alpha', 10 * MIN, now - 200 * DAY);
|
||||
act.addActive(a, 'alpha', 10 * MIN, now - 2 * DAY);
|
||||
act.addActive(a, 'alpha', 10 * MIN, now - 1200 * DAY);
|
||||
act.fold(a, now);
|
||||
assert.equal(Object.keys(a.hourly).length, 1);
|
||||
assert.equal(sum(a), 20 * MIN);
|
||||
});
|
||||
|
||||
test('backfill places remembered visits before their last save, once', () => {
|
||||
const now = 100 * DAY, at = now - DAY;
|
||||
const reading = { alpha: { c1: { recentSessions: [
|
||||
{ id: 'v1', at, pass: { activeMs: 20 * MIN, startedAt: at - 30 * MIN } },
|
||||
{ id: 'legacy', at, counted: true },
|
||||
] } } };
|
||||
const a = act.backfill(act.emptyActivity(), reading, now);
|
||||
assert.equal(sum(a), 20 * MIN); assert.equal(a.backfilled, true);
|
||||
act.backfill(a, reading, now);
|
||||
assert.equal(sum(a), 20 * MIN);
|
||||
});
|
||||
|
||||
test('forgetting a manga removes only its share', () => {
|
||||
const a = act.emptyActivity();
|
||||
act.addActive(a, 'alpha', MIN, 10 * act.FINE_MS);
|
||||
act.addActive(a, 'beta', 2 * MIN, 10 * act.FINE_MS);
|
||||
act.forgetManga(a, 'beta');
|
||||
assert.equal(sum(a), MIN); assert.deepEqual(a.fine[9].m, { alpha: MIN });
|
||||
});
|
||||
|
||||
test('series lists buckets in time order and chapter completions with labels', () => {
|
||||
const a = act.emptyActivity();
|
||||
act.addActive(a, 'alpha', MIN, 20 * act.FINE_MS);
|
||||
a.hourly[1] = { a: MIN, m: { beta: MIN } };
|
||||
const s = act.activitySeries(a, { alpha: { c1: { done: true, doneAt: 5 }, c2: { done: false } } },
|
||||
{ titleOf: id => id.toUpperCase(), labelOf: (m, c) => 'Ch. ' + c });
|
||||
assert.deepEqual(s.buckets.map(b => b[0]), [act.HOUR_MS, 19 * act.FINE_MS]);
|
||||
assert.deepEqual(s.done, [[5, s.mangas.findIndex(m => m.id === 'alpha'), 'Ch. c1']]);
|
||||
assert.equal(s.mangas.find(m => m.id === 'beta').title, 'BETA');
|
||||
});
|
||||
|
||||
test('periods are local and Monday based; cells carry their natural segments', () => {
|
||||
const tue = new Date(2026, 10, 3, 15, 30).getTime();
|
||||
assert.equal(core.periodStart('week', tue), new Date(2026, 10, 2).getTime());
|
||||
assert.equal(core.periodStart('month', tue), new Date(2026, 10, 1).getTime());
|
||||
assert.equal(core.isoWeek(tue), 45);
|
||||
assert.equal(core.segmentBounds('month', new Date(2026, 1, 1).getTime()).length, 29);
|
||||
assert.equal(core.segmentBounds('week', new Date(2026, 10, 2).getTime()).length, 29);
|
||||
assert.equal(core.segmentBounds('hour', new Date(2026, 10, 3, 15).getTime()).length, 11);
|
||||
});
|
||||
|
||||
test('layouts: two years of months, six months of weeks without repeats, a month calendar, a day of hours', () => {
|
||||
const t = new Date(2026, 10, 3, 15).getTime();
|
||||
const months = core.activityLayout('month', t);
|
||||
assert.deepEqual(months.rows.map(r => r.cells.length), [12, 12]);
|
||||
const weeks = core.activityLayout('week', t).rows.flatMap(r => r.cells);
|
||||
assert.equal(new Set(weeks.map(c => c.start)).size, weeks.length);
|
||||
for (let i = 1; i < weeks.length; i++) assert.equal(weeks[i].start, core.periodShift('week', weeks[i - 1].start, 1));
|
||||
const days = core.activityLayout('day', t);
|
||||
assert.equal(days.rows.flatMap(r => r.cells).filter(c => !c.outside).length, 30);
|
||||
assert.equal(core.activityLayout('hour', t).rows.flatMap(r => r.cells).length, 24);
|
||||
});
|
||||
|
||||
test('sums split buckets by overlap and count completions in their segment', () => {
|
||||
const day = new Date(2026, 10, 3).getTime();
|
||||
const cells = core.activityLayout('hour', day).rows.flatMap(r => r.cells);
|
||||
const nine = day + 9 * HOUR;
|
||||
core.activitySums({ buckets: [[nine - 30 * MIN, HOUR, 60 * MIN, [[0, 60 * MIN]]]], done: [[nine + 7 * MIN, 0, 'Ch. 1']] }, cells);
|
||||
assert.equal(Math.round(cells[8].totalTime), 30 * MIN); assert.equal(Math.round(cells[9].totalTime), 30 * MIN);
|
||||
assert.deepEqual(cells[9].time.slice(0, 6).map(Math.round), [6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 6 * MIN, 0]);
|
||||
assert.equal(cells[9].chapters[1], 1); assert.equal(cells[9].done.length, 1);
|
||||
});
|
||||
|
||||
test('drilling: month and week open days (week marked), day opens hours, hour is the end', () => {
|
||||
const week = new Date(2026, 10, 30).getTime();
|
||||
const next = core.activityDrill({ level: 'week', start: week });
|
||||
assert.equal(next.view, 'day'); assert.equal(next.mark, week);
|
||||
assert.equal(new Date(next.anchor).getMonth(), 11);
|
||||
assert.equal(core.activityDrill({ level: 'day', start: week }).view, 'hour');
|
||||
assert.equal(core.activityDrill({ level: 'hour', start: week }), null);
|
||||
assert.equal(core.activityGlow(0, 10), 0); assert.equal(core.activityGlow(10, 10), 1);
|
||||
});
|
||||
@@ -148,6 +148,15 @@ test('three services integrate collections, auth, custom covers and private heal
|
||||
assert.equal((await send(visit(first, 'beacon-first', 2, { activeMs: 500, seen: [0], final: true, release: true }))).accepted, true);
|
||||
assert.equal((await send(visit(second, 'third-visit', 1))).accepted, false);
|
||||
await send(visit(second, 'second-visit', 2, { release: true }));
|
||||
// The activity grid gets every credited millisecond once, duplicates and stale pulses excluded.
|
||||
assert.equal((await request('/api/progress', { m: first.manga.id, c: first.chapter.id, v: first.version, p: 0, n: 1, done: true })).status, 200);
|
||||
const activity = await (await request('/api/reading/activity')).json();
|
||||
assert.equal(activity.readingClock, true);
|
||||
assert.equal(activity.buckets.reduce((sum, b) => sum + b[2], 0), 2000);
|
||||
assert.deepEqual(activity.mangas.map(m => m.id), [first.manga.id]);
|
||||
assert.equal(activity.done.length, 1); assert.equal(activity.done[0][2], first.chapter.label);
|
||||
assert.equal((await request('/api/reading/delete', { m: first.manga.id })).status, 200);
|
||||
assert.equal((await (await request('/api/reading/activity')).json()).buckets.length, 0);
|
||||
assert.equal((await fetch(base + '/api/reading/visit', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(visit(first, 'unauth-visit', 1)) })).status, 401);
|
||||
await request('/api/settings', { readingClock: false });
|
||||
assert.equal((await request('/api/reading/visit', visit(first, 'disabled-visit', 1))).status, 403);
|
||||
|
||||
Reference in new issue
Block a user