import { ApiKeyModel } from '../models/apikey.model.ts' import { AssignmentModel } from '../models/assignments.model.ts' import { ReviewItemModel } from '../models/reviewitem.model.ts' import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts' const WANIKANI_API_BASE = 'https://api.wanikani.com/v2' export interface WaniKaniSubject { id: number object: string data: { characters: string meanings: { meaning: string; primary: boolean; accepted_answers: boolean }[] readings: { type: string; primary: boolean; accepted_answer: boolean; reading: string }[] auxiliary_meanings: { meaning: string; type: string }[] pronunciation_audios?: { url: string content_type: string metadata: { gender: string; pronunciation: string }[] }[] level: number slug: string unlocked_at?: string subject_type: 'kanji' | 'vocabulary' | 'kana_vocabulary' } } const fetchAllPages = async ( url: string, headers: Record, ): Promise => { let results: WaniKaniSubject[] = [] let nextUrl: string | null = url while (nextUrl) { const res = await fetch(nextUrl, { headers }) if (!res.ok) throw new Error(`Failed fetching ${url}: ${res.statusText}`) const json = (await res.json()) as { data: WaniKaniSubject[]; pages: { next_url: string | null } } results = results.concat(json.data) nextUrl = json.pages.next_url } return results } const fetchSubjects = async ( ids: number[], headers: Record, ): Promise => { const chunkSize = 1000 let results: WaniKaniSubject[] = [] for (let i = 0; i < ids.length; i += chunkSize) { const chunk = ids.slice(i, i + chunkSize) const res = await fetch(`${WANIKANI_API_BASE}/subjects?ids=${chunk.join(',')}`, { headers }) if (!res.ok) throw new Error(`Failed fetching subjects: ${res.statusText}`) const json = (await res.json()) as { data: WaniKaniSubject[] } results = results.concat(json.data) } return results } 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}`) 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 => { const headers = { Authorization: `Bearer ${apiKey}` } try { await ApiKeyModel.updateOne( {}, { $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } }, { upsert: true }, ) const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers) 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') unlockedKanjiIds.push(assignmentData.subject_id) else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) unlockedVocabIds.push(assignmentData.subject_id) } await AssignmentModel.updateOne( { userId, subject_type: 'kanji' }, { $set: { subject_ids: unlockedKanjiIds } }, { upsert: true }, ) await AssignmentModel.updateOne( { userId, subject_type: 'vocab' }, { $set: { subject_ids: unlockedVocabIds } }, { upsert: true }, ) // Fetch and insert new review items const kanjiSubjects = await fetchSubjects(unlockedKanjiIds, headers) const vocabSubjects = await fetchSubjects(unlockedVocabIds, headers) const allSlugs = new Set((await ReviewItemModel.find({ userId }, { slug: 1 })).map(r => r.slug)) 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 } }