add new lesson mode and started code refraction
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user