Moments: per-user saved pages and regions with one category (BL-0055)
Reader: bookmark button and M save the page (Flow: page at the reading line, Book: the spread) with an optional dragged region, exactly one category (Twist, Hot, Awesome, Cute, Funny, Feels) and a note. I now toggles the bars; ? lists all keys. Keys dispatch through public/keys.js, which also feeds the new Settings -> Keyboard shortcuts section. Manga page: "Your moments" strip of crops (story or recent order, category filters), viewer with step, full page, edit, delete and "Open in reader" (read.html ...&p=). Moments point at pages by file name and show "page moved" when the file is gone. Private per user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41e7ed063e
commit
74842c59e0
15 files changed
+734
-18
No files matched your search
@@ -35,6 +35,7 @@ const {
|
||||
const { chapterDates } = require('./chapter-dates');
|
||||
const { parseSchedule, publicSchedule, loadQuestionBank, createReadingAccess } = require('./reading-access');
|
||||
const { BackupService, BackupError } = require('./backup');
|
||||
const moments = require('./moments');
|
||||
|
||||
const PORT = +process.env.PORT || 8080;
|
||||
const LIB = path.resolve(process.env.LIBRARY || 'library');
|
||||
@@ -71,7 +72,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: {}, known: {} };
|
||||
const db = { users: {}, sessions: {}, progress: {}, reading: {}, 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); }
|
||||
@@ -231,6 +232,15 @@ 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 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');
|
||||
const fileSha = async rel => sha256File(path.join(LIB, rel)).catch(() => null);
|
||||
function sha256File(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
fs.createReadStream(file).on('error', reject).on('data', d => hash.update(d)).on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
const admin = u => u.admin || bad(403, 'Admins only');
|
||||
const READING_LEASE_MS = 15000;
|
||||
const readingLeases = new Map();
|
||||
@@ -741,6 +751,9 @@ const routes = {
|
||||
prev: neighbour(m, ch, -1, s), next: neighbour(m, ch, 1, s),
|
||||
progress: prog[ch.id] || null,
|
||||
reading: s.readingClock ? publicSummary(readingOf(u, m, ch), v.id, eligiblePages(v, s)) : null,
|
||||
// the user's moments on this version, so the reader can mark pages that already have one
|
||||
moments: momentsOf(u).filter(x => x.m === m.id && x.c === ch.id && x.v === v.id)
|
||||
.map(x => ({ id: x.id, cat: x.cat, pages: (moments.locate(x, m) || { pages: [] }).pages })),
|
||||
// for the chapter picker: the whole manga across all books
|
||||
books: m.books.map(b => ({ key: b.key, label: b.label, title: b.title })),
|
||||
chapters: m.chapters.map(c => ({ id: c.id, book: c.book, label: c.label, title: c.title, extra: c.extra, done: !!(prog[c.id] && prog[c.id].done) })),
|
||||
@@ -827,6 +840,40 @@ const routes = {
|
||||
save();
|
||||
return { following: !!f[m.id] };
|
||||
},
|
||||
|
||||
// Moments (BL-0055): private to their user; one category each, no uncategorised saves.
|
||||
'GET /api/moments': ({ u, q }) => {
|
||||
const m = mangaOf(q.get('m'));
|
||||
return { moments: momentsOf(u).filter(x => x.m === m.id).map(x => moments.view(x, m, url)).sort(moments.storyCompare) };
|
||||
},
|
||||
'POST /api/moments': async ({ u, b }) => {
|
||||
const m = mangaOf(b.m), ch = chapterOf(m, b.c), v = ch.versions.find(x => x.id === b.v) || bad(404, 'Version not found');
|
||||
const pages = moments.cleanPages(b.pages, v);
|
||||
const moment = { id: crypto.randomBytes(6).toString('hex'), m: m.id, c: ch.id, v: v.id, i: pages, f: pages.map(i => v.pages[i].f),
|
||||
region: moments.cleanRegion(b.region), cat: moments.cleanCategory(b.cat), note: moments.cleanNote(b.note), t: Date.now() };
|
||||
const list = momentsOf(u);
|
||||
if (list.length >= moments.MAX_PER_USER) bad(409, `You already have ${moments.MAX_PER_USER} moments.`);
|
||||
moment.sha = await Promise.all(moment.f.map(f => fileSha(v.rel + '/' + f)));
|
||||
list.push(moment);
|
||||
save();
|
||||
return { moment: moments.view(moment, m, url) };
|
||||
},
|
||||
'POST /api/moments/update': ({ u, b }) => {
|
||||
const moment = momentOf(u, b.id);
|
||||
if ('cat' in b) moment.cat = moments.cleanCategory(b.cat);
|
||||
if ('region' in b) moment.region = moments.cleanRegion(b.region);
|
||||
if ('note' in b) moment.note = moments.cleanNote(b.note);
|
||||
moment.edited = Date.now();
|
||||
save();
|
||||
return { moment: moments.view(moment, lib.byId.get(moment.m), url) };
|
||||
},
|
||||
'POST /api/moments/delete': ({ u, b }) => {
|
||||
const list = momentsOf(u), moment = momentOf(u, b.id);
|
||||
list.splice(list.indexOf(moment), 1);
|
||||
save();
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
'GET /api/notifications': ({ u }) => ({ items: (db.notes[u.name] || []).slice().reverse() }),
|
||||
'POST /api/notifications/read': ({ u, b }) => {
|
||||
for (const n of db.notes[u.name] || []) if (b.all || n.id === b.id) n.read = true;
|
||||
@@ -868,7 +915,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.users[name]; delete db.progress[name]; delete db.reading[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];
|
||||
@@ -1080,7 +1127,7 @@ http.createServer(async (req, res) => {
|
||||
}
|
||||
serveFile(req, res, PUB, pathname === '/' ? 'index.html' : pathname.slice(1), 'no-cache');
|
||||
} catch (e) {
|
||||
const expected = e instanceof HttpError || e instanceof CatalogError;
|
||||
const expected = e instanceof HttpError || e instanceof CatalogError || e instanceof moments.MomentError;
|
||||
if (!expected) console.error(e);
|
||||
if (!res.headersSent) sendJson(res, expected ? e.code : 500, expected ? { error: e.message, ...e.extra } : { error: 'Internal error' });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user