Files
MangaCircus/reading-access.js
T

145 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const fs = require('fs');
const crypto = require('crypto');
function minute(value, end = false) {
if (end && value === '24:00') return 1440;
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`Invalid reading-window time: ${value}`);
return Number(value.slice(0, 2)) * 60 + Number(value.slice(3));
}
function parseSchedule(json, timezone) {
if (!json) return null;
// Validate the IANA zone at startup; a bad deployment must not silently use UTC.
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format();
const raw = JSON.parse(json);
if (!raw || Array.isArray(raw) || typeof raw !== 'object' ||
Object.keys(raw).some(key => !/^[0-6]$/.test(key))) throw new Error('READING_WINDOWS_JSON must map weekdays 0–6 to time ranges');
const days = Array.from({ length: 7 }, (_, day) => {
const ranges = raw[day] || [];
if (!Array.isArray(ranges)) throw new Error(`Reading windows for day ${day} must be an array`);
return ranges.map(range => {
if (!Array.isArray(range) || range.length !== 2) throw new Error(`Invalid reading window on day ${day}`);
const start = minute(range[0]), end = minute(range[1], true);
if (start === end) throw new Error(`Zero-length reading window on day ${day}`);
return { start, end, label: range };
});
});
return { days, timezone };
}
function localParts(date, timezone) {
const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' })
.formatToParts(date);
const get = type => parts.find(part => part.type === type).value;
return { day: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(get('weekday')),
minute: Number(get('hour')) * 60 + Number(get('minute')) };
}
function scheduled(schedule, date = new Date()) {
if (!schedule) return true;
const { day, minute: now } = localParts(date, schedule.timezone);
return schedule.days[day].some(({ start, end }) => end > start && now >= start && now < end || end < start && now >= start) ||
schedule.days[(day + 6) % 7].some(({ start, end }) => end < start && now < end);
}
function publicSchedule(schedule) {
return { enabled: !!schedule, timezone: schedule?.timezone || null,
days: schedule ? schedule.days.map(ranges => ranges.map(({ label }) => [...label])) : [] };
}
function normalizeAnswer(value) {
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
.replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, ' ');
}
function distance(a, b, cap) {
if (Math.abs(a.length - b.length) > cap) return cap + 1;
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
for (let i = 1; i <= a.length; i++) {
const row = [i];
for (let j = 1; j <= b.length; j++) row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
if (Math.min(...row) > cap) return cap + 1;
prev = row;
}
return prev[b.length];
}
function answerMatches(input, answers) {
const given = normalizeAnswer(input);
if (!given || given.length > 120) return false;
return answers.some(answer => {
const target = normalizeAnswer(answer), tolerance = target.length >= 11 ? 2 : target.length >= 5 ? 1 : 0;
return target && distance(given, target, tolerance) <= tolerance;
});
}
function loadQuestionBank(file) {
if (!file) return {};
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
if (raw.version !== 1 || !raw.manga || Array.isArray(raw.manga) || typeof raw.manga !== 'object')
throw new Error('Reading quiz catalog must have version 1 and a manga object');
for (const [id, questions] of Object.entries(raw.manga)) {
if (!id || !Array.isArray(questions)) throw new Error('Invalid reading quiz manga entry');
const ids = new Set();
for (const q of questions) {
if (!q || typeof q.id !== 'string' || !q.id || ids.has(q.id) || typeof q.prompt !== 'string' || !q.prompt.trim() ||
!Array.isArray(q.requires) || !q.requires.length || q.requires.some(chapter => typeof chapter !== 'string' || !chapter) ||
typeof q.evidence !== 'string' || !q.evidence.trim() ||
!Array.isArray(q.answers) || !q.answers.length || q.answers.some(a => typeof a !== 'string' || !normalizeAnswer(a)))
throw new Error(`Invalid reading quiz question for ${id}`);
ids.add(q.id);
}
}
return raw.manga;
}
function createReadingAccess({ schedule, bank = {}, unlockMinutes = 60, grants, save, getProgress = () => ({}), getMangaTitle = id => id, now = () => Date.now() }) {
if (!Number.isInteger(unlockMinutes) || unlockMinutes < 1 || unlockMinutes > 1440) throw new Error('READING_QUIZ_UNLOCK_MINUTES must be 1–1440');
const challenges = new Map();
const eligibleQuestions = user => Object.entries(bank).flatMap(([mangaId, questions]) => {
const title = getMangaTitle(mangaId), progress = getProgress(user, mangaId);
if (!title) return [];
return questions.filter(q => q.requires.every(chapter => progress?.[chapter]?.done === true))
.map(q => ({ ...q, mangaTitle: title }));
});
const status = (user, withQuiz = false) => {
const scheduledNow = scheduled(schedule, new Date(now()));
const unlockUntil = Number(grants[user] || 0);
return { allowed: scheduledNow || unlockUntil > now(), scheduled: scheduledNow,
unlockUntil: unlockUntil > now() ? unlockUntil : null,
quizAvailable: withQuiz ? eligibleQuestions(user).length >= 5 : undefined,
unlockMinutes, timezone: schedule?.timezone || null };
};
const publicQuestion = (challenge) => ({ challenge: challenge.id, number: challenge.index + 1, total: 5,
prompt: challenge.questions[challenge.index].prompt, mangaTitle: challenge.questions[challenge.index].mangaTitle });
return {
status,
start(user) {
if (status(user).allowed) throw new Error('Reading is already available');
const pool = eligibleQuestions(user);
if (pool.length < 5) throw new Error('Five progress-eligible questions are not available yet');
const shuffled = [...pool];
for (let i = shuffled.length - 1; i > 0; i--) { const j = crypto.randomInt(i + 1); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; }
const challenge = { id: crypto.randomBytes(16).toString('hex'), questions: shuffled.slice(0, 5), index: 0 };
challenges.set(user, challenge); // A new visit starts at question one; older tokens cannot resume.
return publicQuestion(challenge);
},
answer(user, id, answer) {
const challenge = challenges.get(user);
if (!challenge || challenge.id !== id) throw new Error('Challenge expired; start again at question one');
if (!answerMatches(answer, challenge.questions[challenge.index].answers)) return { correct: false, ...publicQuestion(challenge) };
challenge.index++;
if (challenge.index < 5) return { correct: true, ...publicQuestion(challenge) };
challenges.delete(user);
grants[user] = now() + unlockMinutes * 60000;
save();
return { correct: true, complete: true, unlockUntil: grants[user] };
},
abandon(user, id) { if (challenges.get(user)?.id === id) challenges.delete(user); },
};
}
module.exports = { parseSchedule, scheduled, publicSchedule, normalizeAnswer, answerMatches, loadQuestionBank, createReadingAccess };