145 lines
7.2 KiB
JavaScript
145 lines
7.2 KiB
JavaScript
'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 };
|