got kanji with ja and en translation working (not both) and all options at random
This commit is contained in:
232
server/src/api/v1/subject/index.ts
Normal file
232
server/src/api/v1/subject/index.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import express, { type Router, type Response } from 'express'
|
||||
import { ReviewItemModel, type ReviewItemType } from '../../../models/reviewitem.model.ts'
|
||||
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
router.use(verifyAccessToken)
|
||||
|
||||
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { subjectOptions } = req.body
|
||||
if (!subjectOptions) return res.status(400).json({ error: 'Missing subject options' })
|
||||
|
||||
let typeFilter: ReviewItemType[] = ['kanji', 'vocab']
|
||||
if (subjectOptions.subject) {
|
||||
typeFilter = Array.isArray(subjectOptions.subject)
|
||||
? subjectOptions.subject
|
||||
: [subjectOptions.subject]
|
||||
}
|
||||
|
||||
const possibleModes: { key: string; type: string }[] = []
|
||||
|
||||
if (subjectOptions.writingPractice) {
|
||||
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
|
||||
}
|
||||
|
||||
if (subjectOptions.direction === 'jp->en') {
|
||||
const modes = Array.isArray(subjectOptions.mode) ? subjectOptions.mode : [subjectOptions.mode]
|
||||
if (modes.includes('meaning')) possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
|
||||
if (modes.includes('reading')) possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
|
||||
}
|
||||
|
||||
if (subjectOptions.direction === 'en->jp') {
|
||||
if (subjectOptions.mode === 'writing' || subjectOptions.writingMode) {
|
||||
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
|
||||
}
|
||||
}
|
||||
|
||||
if (possibleModes.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||
|
||||
const now = new Date()
|
||||
const dueConditions = possibleModes.map(m => ({ [`srs.${m.key}.next_review`]: { $lte: now } }))
|
||||
|
||||
const dueItems = await ReviewItemModel.find({
|
||||
userId,
|
||||
type: { $in: typeFilter },
|
||||
$or: dueConditions,
|
||||
})
|
||||
|
||||
if (!dueItems || dueItems.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||
|
||||
const nextItem = dueItems[Math.floor(Math.random() * dueItems.length)]
|
||||
|
||||
const dueModesForItem = possibleModes.filter(
|
||||
m => nextItem.srs[m.key].next_review <= now
|
||||
)
|
||||
|
||||
if (dueModesForItem.length === 0) {
|
||||
return res.status(200).json({ message: 'No reviews due for this item' })
|
||||
}
|
||||
|
||||
const selectedMode = dueModesForItem[Math.floor(Math.random() * dueModesForItem.length)].type
|
||||
|
||||
res.status(200).json({
|
||||
...nextItem.toObject(),
|
||||
_selectedMode: selectedMode,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching next review item:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch next review item' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/correct', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { itemId } = req.body
|
||||
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||
|
||||
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||
|
||||
let key: string
|
||||
if (req.body.currentSubjectOptions.writingPractice) {
|
||||
key = 'srs.writing_practice'
|
||||
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||
} else {
|
||||
key = 'srs.en_jp_writing'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
item.set(`${key}.streak`, (item.get(`${key}.streak`) ?? 0) + 1)
|
||||
item.set(`${key}.ease_factor`, Math.min(3.0, (item.get(`${key}.ease_factor`) ?? 2.5) + 0.1))
|
||||
item.set(`${key}.interval`, Math.round((item.get(`${key}.interval`) ?? 1) * (item.get(`${key}.ease_factor`) ?? 2.5)))
|
||||
item.set(`${key}.last_review`, now)
|
||||
item.set(`${key}.next_review`, new Date(now.getTime() + (item.get(`${key}.interval`) ?? 1) * 3600 * 1000))
|
||||
|
||||
await item.save()
|
||||
|
||||
res.status(200).json({ message: 'Item marked as correct' })
|
||||
} catch (error) {
|
||||
console.error('Error marking item correct:', error)
|
||||
res.status(500).json({ error: 'Failed to update item' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/incorrect', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { itemId } = req.body
|
||||
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||
|
||||
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||
|
||||
let key: string
|
||||
if (req.body.currentSubjectOptions.writingPractice) {
|
||||
key = 'srs.writing_practice'
|
||||
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||
} else {
|
||||
key = 'srs.en_jp_writing'
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
item.set(`${key}.streak`, 0)
|
||||
item.set(`${key}.ease_factor`, Math.max(1.3, (item.get(`${key}.ease_factor`) ?? 2.5) - 0.2))
|
||||
item.set(`${key}.interval`, 1)
|
||||
item.set(`${key}.last_review`, now)
|
||||
item.set(`${key}.next_review`, new Date(now.getTime() + 60 * 60 * 1000)) // 1h cooldown for wrong answers
|
||||
|
||||
await item.save()
|
||||
|
||||
res.status(200).json({ message: 'Item marked as incorrect' })
|
||||
} catch (error) {
|
||||
console.error('Error marking item incorrect:', error)
|
||||
res.status(500).json({ error: 'Failed to update item' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/stats', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
|
||||
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
|
||||
|
||||
const dueItems = await ReviewItemModel.countDocuments({
|
||||
userId,
|
||||
srs_next_review: { $lte: now },
|
||||
})
|
||||
|
||||
const waitingItems = await ReviewItemModel.countDocuments({
|
||||
userId,
|
||||
srs_next_review: { $gt: now },
|
||||
})
|
||||
|
||||
const kanjiAgg = await ReviewItemModel.aggregate([
|
||||
{ $match: { userId, type: 'kanji' } },
|
||||
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||
])
|
||||
const vocabAgg = await ReviewItemModel.aggregate([
|
||||
{ $match: { userId, type: 'vocab' } },
|
||||
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||
])
|
||||
|
||||
res.status(200).json({
|
||||
totalKanji,
|
||||
totalVocab,
|
||||
dueItems,
|
||||
waitingItems,
|
||||
avgKanjiScore: kanjiAgg[0]?.avgScore ?? 0,
|
||||
avgVocabScore: vocabAgg[0]?.avgScore ?? 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch stats' })
|
||||
}
|
||||
})
|
||||
|
||||
router.get('/:itemId', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const userId = req.userId
|
||||
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||
|
||||
const { itemId } = req.params
|
||||
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||
|
||||
const item = await ReviewItemModel.findOne({ _id: itemId, userId })
|
||||
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||
|
||||
const now = new Date()
|
||||
const due = item.srs_next_review && item.srs_next_review <= now
|
||||
|
||||
res.status(200).json({
|
||||
itemId: item._id,
|
||||
type: item.type,
|
||||
characters: item.characters,
|
||||
meanings: item.meanings,
|
||||
readings: item.readings,
|
||||
auxiliary_meanings: item.auxiliary_meanings,
|
||||
pronunciation_audios: item.pronunciation_audios ?? [],
|
||||
level: item.level,
|
||||
slug: item.slug,
|
||||
srs_score: item.srs_score ?? 0,
|
||||
srs_streak: item.srs_streak ?? 0,
|
||||
srs_ease_factor: item.srs_ease_factor ?? 2.5,
|
||||
srs_last_review: item.srs_last_review,
|
||||
srs_next_review: item.srs_next_review,
|
||||
srs_interval: item.srs_interval ?? 1,
|
||||
isDue: due,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching item stats:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch item stats' })
|
||||
}
|
||||
})
|
||||
|
||||
export default router as Router
|
||||
@@ -15,6 +15,7 @@ import kanjiRouter from './api/v1/subject/kanji.ts'
|
||||
import vocabRouter from './api/v1/subject/vocab.ts'
|
||||
import authRouter from './api/v1/auth/index.ts'
|
||||
import userRoutes from './api/v1/user/index.ts'
|
||||
import subjectRoutes from './api/v1/subject/index.ts'
|
||||
|
||||
import { verifyAccessToken } from './middleware/auth.ts'
|
||||
|
||||
@@ -63,6 +64,7 @@ app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
|
||||
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
||||
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
||||
app.use('/api/v1/auth', authRouter)
|
||||
app.use('/api/v1/review', verifyAccessToken, subjectRoutes)
|
||||
|
||||
const PORT = process.env.PORT || 3000
|
||||
connectMongo().then(() => {
|
||||
|
||||
51
server/src/models/reviewitem.model.ts
Normal file
51
server/src/models/reviewitem.model.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import mongoose from 'mongoose'
|
||||
|
||||
export type ReviewItemType = 'kanji' | 'vocab'
|
||||
|
||||
const ReviewItemSchema = new mongoose.Schema({
|
||||
userId: { type: String, required: true },
|
||||
type: { type: String, enum: ['kanji', 'vocab'], required: true },
|
||||
id: { type: Number, required: true },
|
||||
|
||||
characters: String,
|
||||
meanings: Array,
|
||||
readings: { type: Array, default: [] },
|
||||
auxiliary_meanings: Array,
|
||||
level: Number,
|
||||
slug: String,
|
||||
|
||||
pronunciation_audios: { type: Array, default: [] },
|
||||
|
||||
srs: {
|
||||
jp_en_meaning: {
|
||||
streak: { type: Number, default: 0 },
|
||||
ease_factor: { type: Number, default: 2.5 },
|
||||
interval: { type: Number, default: 1 },
|
||||
next_review: { type: Date, default: () => new Date() },
|
||||
last_review: { type: Date, default: null },
|
||||
},
|
||||
jp_en_reading: {
|
||||
streak: { type: Number, default: 0 },
|
||||
ease_factor: { type: Number, default: 2.5 },
|
||||
interval: { type: Number, default: 1 },
|
||||
next_review: { type: Date, default: () => new Date() },
|
||||
last_review: { type: Date, default: null },
|
||||
},
|
||||
en_jp_writing: {
|
||||
streak: { type: Number, default: 0 },
|
||||
ease_factor: { type: Number, default: 2.5 },
|
||||
interval: { type: Number, default: 1 },
|
||||
next_review: { type: Date, default: () => new Date() },
|
||||
last_review: { type: Date, default: null },
|
||||
},
|
||||
writing_practice: {
|
||||
streak: { type: Number, default: 0 },
|
||||
ease_factor: { type: Number, default: 2.5 },
|
||||
interval: { type: Number, default: 1 },
|
||||
next_review: { type: Date, default: () => new Date() },
|
||||
last_review: { type: Date, default: null },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const ReviewItemModel = mongoose.model('ReviewItem', ReviewItemSchema)
|
||||
@@ -1,8 +1,6 @@
|
||||
import fetch from 'node-fetch'
|
||||
import { ApiKeyModel } from '../models/apikey.model.ts'
|
||||
import { AssignmentModel } from '../models/assignments.model.ts'
|
||||
import { KanjiModel } from '../models/kanji.model.ts'
|
||||
import { VocabularyModel } from '../models/vocabulary.model.ts'
|
||||
import { ReviewItemModel } from '../models/reviewitem.model.ts'
|
||||
|
||||
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
||||
|
||||
@@ -24,6 +22,7 @@ export interface WaniKaniSubject {
|
||||
level: number
|
||||
slug: string
|
||||
unlocked_at?: string
|
||||
subject_type: 'kanji' | 'vocabulary' | 'kana_vocabulary'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,28 +62,56 @@ const fetchSubjects = async (
|
||||
return results
|
||||
}
|
||||
|
||||
const mapKanji = (item: WaniKaniSubject, userId: string): KanjiItem => ({
|
||||
userId: userId,
|
||||
characters: item.data.characters,
|
||||
meanings: item.data.meanings,
|
||||
readings: item.data.readings,
|
||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
||||
level: item.data.level,
|
||||
slug: item.data.slug,
|
||||
srs_score: 0,
|
||||
})
|
||||
const mapToReviewItem = (item: WaniKaniSubject, userId: string) => {
|
||||
let type: 'kanji' | 'vocab'
|
||||
const subjectType = item.data.subject_type || item.object
|
||||
if (subjectType === 'kanji') type = 'kanji'
|
||||
else if (['vocabulary', 'kana_vocabulary'].includes(subjectType)) type = 'vocab'
|
||||
else throw new Error(`Unknown WaniKani subject type: ${subjectType}`)
|
||||
|
||||
const mapVocab = (item: WaniKaniSubject, userId: string): VocabularyItem => ({
|
||||
userId: userId,
|
||||
characters: item.data.characters,
|
||||
meanings: item.data.meanings,
|
||||
readings: item.data.readings ?? [],
|
||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
||||
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
||||
level: item.data.level,
|
||||
slug: item.data.slug,
|
||||
srs_score: 0,
|
||||
})
|
||||
return {
|
||||
id: item.id,
|
||||
userId,
|
||||
type,
|
||||
characters: item.data.characters,
|
||||
meanings: item.data.meanings,
|
||||
readings: item.data.readings ?? [],
|
||||
auxiliary_meanings: item.data.auxiliary_meanings ?? [],
|
||||
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
||||
level: item.data.level,
|
||||
slug: item.data.slug,
|
||||
srs: {
|
||||
jp_en_meaning: {
|
||||
streak: 0,
|
||||
ease_factor: 2.5,
|
||||
interval: 1,
|
||||
next_review: new Date(),
|
||||
last_review: null,
|
||||
},
|
||||
jp_en_reading: {
|
||||
streak: 0,
|
||||
ease_factor: 2.5,
|
||||
interval: 1,
|
||||
next_review: new Date(),
|
||||
last_review: null,
|
||||
},
|
||||
en_jp_writing: {
|
||||
streak: 0,
|
||||
ease_factor: 2.5,
|
||||
interval: 1,
|
||||
next_review: new Date(),
|
||||
last_review: null,
|
||||
},
|
||||
writing_practice: {
|
||||
streak: 0,
|
||||
ease_factor: 2.5,
|
||||
interval: 1,
|
||||
next_review: new Date(),
|
||||
last_review: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
|
||||
const headers = { Authorization: `Bearer ${apiKey}` }
|
||||
@@ -92,50 +119,48 @@ export const syncWanikaniData = async (apiKey: string, userId: string): Promise<
|
||||
try {
|
||||
await ApiKeyModel.updateOne(
|
||||
{},
|
||||
{ $set: { user: userId, apiKey: apiKey, lastUsed: new Date() } },
|
||||
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
|
||||
{ upsert: true },
|
||||
)
|
||||
|
||||
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
||||
|
||||
const unlockedKanjiSubjectIds: number[] = []
|
||||
const unlockedVocabSubjectIds: number[] = []
|
||||
const unlockedKanjiIds: number[] = []
|
||||
const unlockedVocabIds: number[] = []
|
||||
|
||||
for (const a of assignments) {
|
||||
const assignmentData = a.data as any
|
||||
|
||||
if (!assignmentData.unlocked_at) continue
|
||||
|
||||
if (assignmentData.subject_type === 'kanji') {
|
||||
unlockedKanjiSubjectIds.push(assignmentData.subject_id)
|
||||
} else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) {
|
||||
unlockedVocabSubjectIds.push(assignmentData.subject_id)
|
||||
}
|
||||
if (assignmentData.subject_type === 'kanji') unlockedKanjiIds.push(assignmentData.subject_id)
|
||||
else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) unlockedVocabIds.push(assignmentData.subject_id)
|
||||
}
|
||||
|
||||
await AssignmentModel.updateOne(
|
||||
{ userId: userId, subject_type: 'kanji' },
|
||||
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
|
||||
{ userId, subject_type: 'kanji' },
|
||||
{ $set: { subject_ids: unlockedKanjiIds } },
|
||||
{ upsert: true },
|
||||
)
|
||||
|
||||
await AssignmentModel.updateOne(
|
||||
{ userId: userId, subject_type: 'vocabulary' },
|
||||
{ $set: { subject_ids: unlockedVocabSubjectIds } },
|
||||
{ userId, subject_type: 'vocab' },
|
||||
{ $set: { subject_ids: unlockedVocabIds } },
|
||||
{ upsert: true },
|
||||
)
|
||||
|
||||
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
|
||||
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
|
||||
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
|
||||
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(k => mapKanji(k, userId)))
|
||||
// Fetch and insert new review items
|
||||
const kanjiSubjects = await fetchSubjects(unlockedKanjiIds, headers)
|
||||
const vocabSubjects = await fetchSubjects(unlockedVocabIds, headers)
|
||||
|
||||
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
|
||||
const vocabSubjects = await fetchSubjects(unlockedVocabSubjectIds, headers)
|
||||
const newVocab = vocabSubjects.filter(s => !existingVocabSlugs.has(s.data.slug))
|
||||
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(v => mapVocab(v, userId)))
|
||||
const allSlugs = new Set((await ReviewItemModel.find({ userId }, { slug: 1 })).map(r => r.slug))
|
||||
|
||||
console.log('✅ Sync complete')
|
||||
const newItems = [...kanjiSubjects, ...vocabSubjects]
|
||||
.filter(s => !allSlugs.has(s.data.slug))
|
||||
.map(s => mapToReviewItem(s, userId))
|
||||
|
||||
if (newItems.length > 0) await ReviewItemModel.insertMany(newItems)
|
||||
|
||||
console.log('✅ WaniKani sync complete')
|
||||
} catch (err) {
|
||||
console.error('❌ Error syncing WaniKani data:', err)
|
||||
throw err
|
||||
|
||||
Reference in New Issue
Block a user