This commit is contained in:
Rene Kievits
2025-12-18 01:30:52 +01:00
commit 6438660b03
78 changed files with 14230 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
export const PORT = process.env.PORT || 3000;
export const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
export const SRS_TIMINGS_HOURS = [0, 0, 4, 8, 23, 47];
export const JWT_SECRET = process.env.JWT_SECRET;

12
server/src/config/db.js Normal file
View File

@@ -0,0 +1,12 @@
import mongoose from 'mongoose';
import { MONGO_URI } from './constants.js';
export const connectDB = async () => {
try {
await mongoose.connect(MONGO_URI);
console.log('✅ Connected to MongoDB');
} catch (err) {
console.error('❌ MongoDB Connection Error:', err);
process.exit(1);
}
};

View File

@@ -0,0 +1,28 @@
import * as AuthService from '../services/auth.service.js';
export const login = async (req, reply) => {
const { apiKey } = req.body;
if (!apiKey) return reply.code(400).send({ error: 'API Key required' });
try {
const user = await AuthService.loginUser(apiKey);
const token = await reply.jwtSign(
{ userId: user._id, version: user.tokenVersion },
{ expiresIn: '365d' }
);
return {
success: true,
token,
user: { settings: user.settings, stats: user.stats }
};
} catch (err) {
return reply.code(401).send({ error: err.message });
}
};
export const logout = async (req, reply) => {
await AuthService.logoutUser(req.user._id);
return { success: true };
};

View File

@@ -0,0 +1,33 @@
import { User } from '../models/User.js';
import * as ReviewService from '../services/review.service.js';
import * as StatsService from '../services/stats.service.js';
import { StudyItem } from '../models/StudyItem.js';
export const getCollection = async (req, reply) => {
const items = await StudyItem.find({ userId: req.user._id });
return reply.send(items);
};
export const getQueue = async (req, reply) => {
const { limit, sort } = req.query;
const queue = await ReviewService.getQueue(req.user, parseInt(limit) || 20, sort);
return reply.send(queue);
};
export const getStats = async (req, reply) => {
const stats = await StatsService.getUserStats(req.user);
return reply.send(stats);
};
export const updateSettings = async (req, reply) => {
const { batchSize, drawingAccuracy } = req.body;
const user = req.user;
if (!user.settings) user.settings = {};
if (batchSize) user.settings.batchSize = batchSize;
if (drawingAccuracy) user.settings.drawingAccuracy = drawingAccuracy;
await user.save();
return reply.send({ success: true, settings: user.settings });
};

View File

@@ -0,0 +1,11 @@
import * as ReviewService from '../services/review.service.js';
export const submitReview = async (req, reply) => {
const { subjectId, success } = req.body;
try {
const result = await ReviewService.processReview(req.user, subjectId, success);
return reply.send(result);
} catch (err) {
return reply.code(404).send({ error: err.message });
}
};

View File

@@ -0,0 +1,10 @@
import * as SyncService from '../services/sync.service.js';
export const sync = async (req, reply) => {
try {
const result = await SyncService.syncWithWaniKani(req.user);
return reply.send(result);
} catch (error) {
return reply.code(500).send({ error: error.message });
}
};

View File

@@ -0,0 +1,10 @@
import mongoose from 'mongoose';
const reviewLogSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
date: { type: String, required: true },
count: { type: Number, default: 0 }
});
reviewLogSchema.index({ userId: 1, date: 1 }, { unique: true });
export const ReviewLog = mongoose.model('ReviewLog', reviewLogSchema);

View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose';
const studyItemSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
wkSubjectId: { type: Number, required: true },
char: { type: String, required: true },
meaning: { type: String, required: true },
level: { type: Number, required: true },
srsLevel: { type: Number, default: 0 },
nextReview: { type: Date, default: Date.now },
onyomi: { type: [String], default: [] },
kunyomi: { type: [String], default: [] },
nanori: { type: [String], default: [] },
stats: {
correct: { type: Number, default: 0 },
total: { type: Number, default: 0 }
}
});
studyItemSchema.index({ userId: 1, wkSubjectId: 1 }, { unique: true });
export const StudyItem = mongoose.model('StudyItem', studyItemSchema);

21
server/src/models/User.js Normal file
View File

@@ -0,0 +1,21 @@
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
wkApiKey: { type: String, required: true, unique: true },
lastSync: { type: Date, default: Date.now },
settings: {
batchSize: { type: Number, default: 20 },
drawingAccuracy: { type: Number, default: 10 }
},
tokenVersion: { type: Number, default: 0 },
stats: {
totalReviews: { type: Number, default: 0 },
correctReviews: { type: Number, default: 0 },
currentStreak: { type: Number, default: 0 },
maxStreak: { type: Number, default: 0 },
lastStudyDate: { type: String, default: null },
lastFreezeDate: { type: Date, default: null }
}
});
export const User = mongoose.model('User', userSchema);

22
server/src/routes/v1.js Normal file
View File

@@ -0,0 +1,22 @@
import { login, logout } from '../controllers/auth.controller.js';
import { sync } from '../controllers/sync.controller.js';
import { submitReview } from '../controllers/review.controller.js';
import { getStats, getQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
async function routes(fastify, options) {
fastify.post('/api/auth/login', login);
fastify.register(async (privateParams) => {
privateParams.addHook('onRequest', fastify.authenticate);
privateParams.post('/api/auth/logout', logout);
privateParams.post('/api/sync', sync);
privateParams.post('/api/review', submitReview);
privateParams.get('/api/stats', getStats);
privateParams.get('/api/queue', getQueue);
privateParams.get('/api/collection', getCollection);
privateParams.post('/api/settings', updateSettings);
});
}
export default routes;

View File

@@ -0,0 +1,29 @@
import { User } from '../models/User.js';
export const loginUser = async (apiKey) => {
const response = await fetch('https://api.wanikani.com/v2/user', {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (response.status !== 200) {
throw new Error('Invalid API Key');
}
let user = await User.findOne({ wkApiKey: apiKey });
if (!user) {
user = await User.create({
wkApiKey: apiKey,
tokenVersion: 0,
stats: { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 },
settings: { batchSize: 20 }
});
}
return user;
};
export const logoutUser = async (userId) => {
await User.findByIdAndUpdate(userId, { $inc: { tokenVersion: 1 } });
return true;
};

View File

@@ -0,0 +1,85 @@
import { ReviewLog } from '../models/ReviewLog.js';
import { StudyItem } from '../models/StudyItem.js';
import { getDaysDiff, getSRSDate } from '../utils/dateUtils.js';
export const processReview = async (user, subjectId, success) => {
if (!user.stats) user.stats = { totalReviews: 0, correctReviews: 0, currentStreak: 0, maxStreak: 0 };
user.stats.totalReviews += 1;
if (success) user.stats.correctReviews += 1;
const todayStr = new Date().toISOString().split('T')[0];
const lastStudyStr = user.stats.lastStudyDate;
if (lastStudyStr !== todayStr) {
if (!lastStudyStr) {
user.stats.currentStreak = 1;
} else {
const diff = getDaysDiff(lastStudyStr, todayStr);
if (diff === 1) {
user.stats.currentStreak += 1;
} else if (diff > 1) {
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
let daysSinceFreeze = 999;
if (lastFreeze) daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
const canUseShield = daysSinceFreeze >= 7;
if (canUseShield && diff === 2) {
console.log(`User ${user._id} saved by Zen Shield!`);
user.stats.lastFreezeDate = new Date();
user.stats.currentStreak += 1;
} else {
user.stats.currentStreak = 1;
}
}
}
user.stats.lastStudyDate = todayStr;
if (user.stats.currentStreak > user.stats.maxStreak) user.stats.maxStreak = user.stats.currentStreak;
}
await user.save();
await ReviewLog.findOneAndUpdate(
{ userId: user._id, date: todayStr },
{ $inc: { count: 1 } },
{ upsert: true, new: true }
);
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
if (!item) throw new Error('Item not found');
if (!item.stats) item.stats = { correct: 0, total: 0 };
item.stats.total += 1;
if (success) item.stats.correct += 1;
if (success) {
const nextLevel = Math.min(item.srsLevel + 1, 10);
item.srsLevel = nextLevel;
item.nextReview = getSRSDate(nextLevel);
} else {
item.srsLevel = Math.max(1, item.srsLevel - 1);
item.nextReview = Date.now();
}
await item.save();
return { nextReview: item.nextReview, srsLevel: item.srsLevel };
};
export const getQueue = async (user, limit = 100, sortMode) => {
const query = {
userId: user._id,
srsLevel: { $lt: 10, $gt: 0 },
nextReview: { $lte: new Date() }
};
let dueItems;
if (sortMode === 'priority') {
dueItems = await StudyItem.find(query).sort({ srsLevel: 1, level: 1 }).limit(limit);
} else {
dueItems = await StudyItem.find(query).limit(limit);
for (let i = dueItems.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[dueItems[i], dueItems[j]] = [dueItems[j], dueItems[i]];
}
}
return dueItems;
};

View File

@@ -0,0 +1,112 @@
import { StudyItem } from '../models/StudyItem.js';
import { ReviewLog } from '../models/ReviewLog.js';
import { getDaysDiff } from '../utils/dateUtils.js';
export const getUserStats = async (user) => {
const userId = user._id;
const srsCounts = await StudyItem.aggregate([
{ $match: { userId: userId } },
{ $group: { _id: "$srsLevel", count: { $sum: 1 } } }
]);
const dist = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0 };
srsCounts.forEach(g => { if (dist[g._id] !== undefined) dist[g._id] = g.count; });
const now = new Date();
const next24h = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const upcoming = await StudyItem.find({
userId: userId,
srsLevel: { $lt: 6, $gt: 0 },
nextReview: { $lte: next24h }
}).select('nextReview');
const forecast = new Array(24).fill(0);
upcoming.forEach(item => {
const diff = Math.floor((new Date(item.nextReview) - now) / 3600000);
if (diff < 0) forecast[0]++;
else if (diff < 24) forecast[diff]++;
});
const queueItems = await StudyItem.find({
userId: userId,
srsLevel: { $lt: 6, $gt: 0 },
nextReview: { $lte: now }
}).select('srsLevel');
const queueCount = queueItems.length;
let hasLowerLevels = false;
let lowerLevelCount = 0;
if (queueCount > 0) {
const levels = queueItems.map(i => i.srsLevel);
const minSrs = Math.min(...levels);
const maxSrs = Math.max(...levels);
if (minSrs < maxSrs) {
hasLowerLevels = true;
lowerLevelCount = queueItems.filter(i => i.srsLevel === minSrs).length;
}
}
const oneYearAgo = new Date();
oneYearAgo.setDate(oneYearAgo.getDate() - 365);
const dateStr = oneYearAgo.toISOString().split('T')[0];
const logs = await ReviewLog.find({ userId: userId, date: { $gte: dateStr } });
const heatmap = {};
logs.forEach(l => { heatmap[l.date] = l.count; });
const todayStr = new Date().toISOString().split('T')[0];
const lastStudyStr = user.stats.lastStudyDate || null;
let displayStreak = user.stats.currentStreak || 0;
if (lastStudyStr) {
const diff = getDaysDiff(lastStudyStr, todayStr);
if (diff > 1) displayStreak = 0;
}
const lastFreeze = user.stats.lastFreezeDate ? new Date(user.stats.lastFreezeDate) : null;
let daysSinceFreeze = 999;
if (lastFreeze) {
daysSinceFreeze = getDaysDiff(lastFreeze.toISOString().split('T')[0], todayStr);
}
const shieldReady = daysSinceFreeze >= 7;
const shieldCooldown = shieldReady ? 0 : (7 - daysSinceFreeze);
const history7Days = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dStr = d.toISOString().split('T')[0];
const count = heatmap[dStr] || 0;
history7Days.push({ date: dStr, active: count > 0 });
}
const allGhosts = await StudyItem.find({ userId: userId, 'stats.total': { $gte: 2 } })
.select('char meaning stats srsLevel');
const sortedGhosts = allGhosts.map(item => ({
...item.toObject(),
accuracy: Math.round((item.stats.correct / item.stats.total) * 100)
})).filter(i => i.accuracy < 85)
.sort((a, b) => a.accuracy - b.accuracy)
.slice(0, 4);
return {
distribution: dist,
forecast: forecast,
queueLength: queueCount,
hasLowerLevels,
lowerLevelCount,
heatmap: heatmap,
ghosts: sortedGhosts,
streak: {
current: displayStreak,
best: user.stats.maxStreak || 0,
shield: { ready: shieldReady, cooldown: Math.max(0, shieldCooldown) },
history: history7Days
},
accuracy: {
total: user.stats.totalReviews || 0,
correct: user.stats.correctReviews || 0
}
};
};

View File

@@ -0,0 +1,78 @@
import { User } from '../models/User.js';
import { StudyItem } from '../models/StudyItem.js';
export const syncWithWaniKani = async (user) => {
const apiKey = user.wkApiKey;
if (!apiKey) {
throw new Error('User has no WaniKani API Key');
}
console.log(`Starting sync for user: ${user._id}`);
let allSubjectIds = [];
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=kanji&started=true';
try {
while (nextUrl) {
const res = await fetch(nextUrl, {
headers: { Authorization: `Bearer ${apiKey}` }
});
if (!res.ok) {
throw new Error(`WaniKani API Error: ${res.statusText}`);
}
const json = await res.json();
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
nextUrl = json.pages.next_url;
}
} catch (err) {
console.error("Error fetching assignments:", err);
throw new Error("Failed to connect to WaniKani. Check your internet connection.");
}
if (allSubjectIds.length === 0) {
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
}
const existingItems = await StudyItem.find({ userId: user._id }).select('wkSubjectId');
const existingIds = new Set(existingItems.map(i => i.wkSubjectId));
const newIds = allSubjectIds.filter(id => !existingIds.has(id));
console.log(`Found ${newIds.length} new items to download.`);
const CHUNK_SIZE = 100;
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
const chunk = newIds.slice(i, i + CHUNK_SIZE);
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const subJson = await subRes.json();
const operations = subJson.data.map(d => {
const readings = d.data.readings || [];
return {
userId: user._id,
wkSubjectId: d.id,
char: d.data.characters,
meaning: d.data.meanings.find(m => m.primary)?.meaning || 'Unknown',
level: d.data.level,
srsLevel: 1,
nextReview: Date.now(),
onyomi: readings.filter(r => r.type === 'onyomi').map(r => r.reading),
kunyomi: readings.filter(r => r.type === 'kunyomi').map(r => r.reading),
nanori: readings.filter(r => r.type === 'nanori').map(r => r.reading),
stats: { correct: 0, total: 0 }
};
});
if (operations.length > 0) {
await StudyItem.insertMany(operations);
}
}
const finalCount = await StudyItem.countDocuments({ userId: user._id });
return { success: true, count: finalCount, settings: user.settings };
};

View File

@@ -0,0 +1,32 @@
export const getDaysDiff = (date1Str, date2Str) => {
const d1 = new Date(date1Str);
const d2 = new Date(date2Str);
const diff = d2 - d1;
return Math.floor(diff / (1000 * 60 * 60 * 24));
};
export const getSRSDate = (level) => {
const now = new Date();
let hoursToAdd = 0;
switch (level) {
case 1: hoursToAdd = 4; break;
case 2: hoursToAdd = 8; break;
case 3: hoursToAdd = 24; break;
case 4: hoursToAdd = 2 * 24; break;
case 5: hoursToAdd = 7 * 24; break;
case 6: hoursToAdd = 14 * 24; break;
case 7: hoursToAdd = 7 * 24; break;
case 8: hoursToAdd = 30 * 24; break;
case 9: hoursToAdd = 90 * 24; break;
case 10: break;
default: hoursToAdd = 4;
}
if (hoursToAdd === 0) return null;
now.setUTCHours(now.getUTCHours() + hoursToAdd);
return now;
};