169 lines
4.8 KiB
TypeScript
169 lines
4.8 KiB
TypeScript
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<string, string>,
|
|
): Promise<WaniKaniSubject[]> => {
|
|
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<string, string>,
|
|
): Promise<WaniKaniSubject[]> => {
|
|
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<void> => {
|
|
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
|
|
}
|
|
}
|