add new lesson mode and started code refraction

This commit is contained in:
Rene Kievits
2025-12-20 04:31:15 +01:00
parent 6438660b03
commit 4428a2b7be
101 changed files with 12255 additions and 8172 deletions

View File

@@ -11,8 +11,11 @@ const fastify = Fastify({ logger: true });
await connectDB();
const allowedOrigins = [
'http://192.168.0.26:5169',
'http://192.168.0.26:5173',
'http://localhost:5173',
'http://localhost',
'https://localhost',
'capacitor://localhost',
'https://10.0.2.2:5173',
'https://zenkanji.crylia.de'

View File

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

View File

@@ -14,6 +14,12 @@ export const getQueue = async (req, reply) => {
return reply.send(queue);
};
export const getLessonQueue = async (req, reply) => {
const { limit } = req.query;
const queue = await ReviewService.getLessonQueue(req.user, parseInt(limit) || 10);
return reply.send(queue);
};
export const getStats = async (req, reply) => {
const stats = await StatsService.getUserStats(req.user);
return reply.send(stats);

View File

@@ -9,3 +9,13 @@ export const submitReview = async (req, reply) => {
return reply.code(404).send({ error: err.message });
}
};
export const submitLesson = async (req, reply) => {
const { subjectId } = req.body;
try {
const result = await ReviewService.processLesson(req.user, subjectId);
return reply.send(result);
} catch (err) {
return reply.code(404).send({ error: err.message });
}
};

View File

@@ -11,6 +11,11 @@ const studyItemSchema = new mongoose.Schema({
onyomi: { type: [String], default: [] },
kunyomi: { type: [String], default: [] },
nanori: { type: [String], default: [] },
radicals: [{
meaning: String,
char: String,
image: String
}],
stats: {
correct: { type: Number, default: 0 },
total: { type: Number, default: 0 }

View File

@@ -1,7 +1,7 @@
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';
import { submitReview, submitLesson } from '../controllers/review.controller.js';
import { getStats, getQueue, getLessonQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
async function routes(fastify, options) {
fastify.post('/api/auth/login', login);
@@ -12,8 +12,10 @@ async function routes(fastify, options) {
privateParams.post('/api/auth/logout', logout);
privateParams.post('/api/sync', sync);
privateParams.post('/api/review', submitReview);
privateParams.post('/api/lesson', submitLesson);
privateParams.get('/api/stats', getStats);
privateParams.get('/api/queue', getQueue);
privateParams.get('/api/lessons', getLessonQueue);
privateParams.get('/api/collection', getCollection);
privateParams.post('/api/settings', updateSettings);
});

View File

@@ -83,3 +83,22 @@ export const getQueue = async (user, limit = 100, sortMode) => {
}
return dueItems;
};
export const getLessonQueue = async (user, limit = 100) => {
const query = {
userId: user._id,
srsLevel: 0
};
return await StudyItem.find(query).sort({ level: 1, wkSubjectId: 1 }).limit(limit);
};
export const processLesson = async (user, subjectId) => {
const item = await StudyItem.findOne({ userId: user._id, wkSubjectId: subjectId });
if (!item) throw new Error('Item not found');
item.srsLevel = 1;
item.nextReview = new Date();
await item.save();
return { success: true, item };
};

View File

@@ -34,6 +34,11 @@ export const getUserStats = async (user) => {
}).select('srsLevel');
const queueCount = queueItems.length;
const lessonCount = await StudyItem.countDocuments({
userId: userId,
srsLevel: 0
});
let hasLowerLevels = false;
let lowerLevelCount = 0;
if (queueCount > 0) {
@@ -94,6 +99,7 @@ export const getUserStats = async (user) => {
distribution: dist,
forecast: forecast,
queueLength: queueCount,
lessonCount: lessonCount,
hasLowerLevels,
lowerLevelCount,
heatmap: heatmap,

View File

@@ -3,56 +3,94 @@ 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');
}
if (!apiKey) throw new Error('User has no WaniKani API Key');
console.log(`Starting sync for user: ${user._id}`);
// 1. Fetch all assigned Kanji Subject IDs
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 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.");
throw new Error("Failed to connect to WaniKani.");
}
if (allSubjectIds.length === 0) {
return { count: 0, message: "No unlocked kanji found.", settings: user.settings };
}
if (allSubjectIds.length === 0) return { count: 0, message: "No unlocked kanji found." };
// 2. Filter out items we already have
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.`);
console.log(`Found ${newIds.length} new items.`);
const CHUNK_SIZE = 100;
// 3. Process in chunks
const CHUNK_SIZE = 50; // Reduced chunk size to accommodate secondary radical fetches
for (let i = 0; i < newIds.length; i += CHUNK_SIZE) {
const chunk = newIds.slice(i, i + CHUNK_SIZE);
// A. Fetch Kanji Details
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const subJson = await subRes.json();
const kanjiDataList = subJson.data;
const operations = subJson.data.map(d => {
// B. Identify all needed Radicals
const radicalIdsToFetch = new Set();
kanjiDataList.forEach(k => {
if (k.data.component_subject_ids) {
k.data.component_subject_ids.forEach(rid => radicalIdsToFetch.add(rid));
}
});
// C. Fetch Radical Details (if any)
const radicalMap = new Map();
if (radicalIdsToFetch.size > 0) {
const rIds = Array.from(radicalIdsToFetch);
// Fetch radicals in sub-chunks if necessary (max 100 per req)
for (let j = 0; j < rIds.length; j += 100) {
const rChunk = rIds.slice(j, j + 100);
const rRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${rChunk.join(',')}`, {
headers: { Authorization: `Bearer ${apiKey}` }
});
const rJson = await rRes.json();
rJson.data.forEach(r => {
const primaryMeaning = r.data.meanings.find(m => m.primary)?.meaning || 'Unknown';
const char = r.data.characters;
// Find SVG image if no character exists
const image = !char && r.data.character_images
? r.data.character_images.find(img => img.content_type === 'image/svg+xml' && !img.metadata.inline_styles)?.url
: null;
radicalMap.set(r.id, {
meaning: primaryMeaning,
char: char,
image: image
});
});
}
}
// D. Build Database Objects
const operations = kanjiDataList.map(d => {
const readings = d.data.readings || [];
// Map the IDs to our fetched radical data
const itemRadicals = (d.data.component_subject_ids || [])
.map(rid => radicalMap.get(rid))
.filter(Boolean); // Remove any not found
return {
userId: user._id,
wkSubjectId: d.id,
@@ -64,6 +102,7 @@ export const syncWithWaniKani = async (user) => {
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),
radicals: itemRadicals, // Save them
stats: { correct: 0, total: 0 }
};
});