This commit is contained in:
Rene Kievits
2025-10-19 22:52:32 +02:00
commit a728add8af
64 changed files with 11693 additions and 0 deletions

0
server/.env Normal file
View File

15
server/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:24-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY pnpm-lock.yaml package*.json ./
RUN pnpm i
RUN pnpm add -D nodemon
COPY . .
EXPOSE 3000
CMD [ "pnpm", "run", "dev" ]

29
server/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "srs-server",
"type": "module",
"scripts": {
"dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' src/index.ts",
"start": "node --loader ts-node/esm src/index.ts"
},
"dependencies": {
"@fastify/cors": "^11.1.0",
"@fastify/static": "^8.2.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"fastify": "^5.6.1",
"mongodb": "^6.20.0",
"mongoose": "^8.19.1",
"node": "^24.10.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^24.7.2",
"eslint": "^9.37.0",
"nodemon": "^3.1.10",
"pnpm": "^10.18.2",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}

2471
server/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
import express, { type Router, type Request, type Response } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await ApiKeyModel.findOne({})
const apiKey = doc?.apiKey || ''
res.json({ apiKey })
} catch (error) {
console.error('Error fetching API key:', error)
res.status(500).json({ error: 'Failed to fetch API key' })
}
})
router.post('/', async (req: Request, res: Response) => {
try {
const { apiKey } = req.body as { apiKey?: string }
if (!apiKey || !apiKey.trim()) {
return res.status(400).json({ error: 'Invalid API key' })
}
await ApiKeyModel.updateOne(
{},
{ $set: { apiKey: apiKey } },
{ upsert: true }
)
res.json({ success: true })
} catch (error) {
console.error('Error saving API key:', error)
res.status(500).json({ error: 'Failed to save API key' })
}
})
router.delete('/', async (_req: Request, res: Response) => {
try {
const result = await ApiKeyModel.deleteOne({})
if (result.deletedCount === 0) {
console.log('No API key found to delete.')
return res.status(204).end()
}
console.log('API key document deleted.')
res.json({ success: true, message: 'API key deleted' })
} catch (error) {
console.error('Error deleting API key:', error)
res.status(500).json({ error: 'Failed to delete API key' })
}
})
export default router as Router

View File

@@ -0,0 +1,18 @@
import express, { type Router, type Request, type Response } from 'express'
import { KanjiModel } from '../../../models/kanji.model.ts'
const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await KanjiModel.find()
console.log(doc)
res.json(doc)
} catch (error) {
console.error('Error fetching Kanji Subjects', error)
res.status(500).json({ error: 'Failed to fetch Kanji Subjects' })
}
})
export default router as Router

View File

@@ -0,0 +1,18 @@
import express, { type Router, type Request, type Response } from 'express'
import { VocabularyModel } from '../../../models/vocabulary.model.ts'
const router = express.Router()
router.get('/', async (_req: Request, res: Response) => {
try {
const doc = await VocabularyModel.find()
res.json(doc)
} catch (error) {
console.error('Error fetching API key:', error)
res.status(500).json({ error: 'Failed to fetch API key' })
}
})
export default router as Router

View File

@@ -0,0 +1,31 @@
import express, { Router } from 'express'
import { ApiKeyModel } from '../../../models/apikey.model.ts'
import { syncWanikaniData } from '../../../services/wanikaniService.ts'
const router = express.Router()
interface ApiKeyDocument {
apiKey?: string;
}
router.get('/sync', async (req, res) => {
try {
const apiKeyDoc = await ApiKeyModel.findOne() as ApiKeyDocument | null
const apiKey = apiKeyDoc?.apiKey
console.log(apiKey, apiKeyDoc)
if (!apiKey || apiKey.trim() === '') {
return res.status(401).json({ error: 'API Key not configured. Please sync your key first.' })
}
await syncWanikaniData(apiKey)
res.json({ success: true })
} catch (err) {
console.error(err)
res.status(500).json({ error: 'Failed to sync WaniKani data' })
}
})
export default router as Router

8
server/src/db/connect.ts Normal file
View File

@@ -0,0 +1,8 @@
import mongoose from 'mongoose'
export async function connectMongo() {
const url = process.env.MONGO_URL || 'mongodb://mongo:27017/srs'
if (mongoose.connection.readyState === 1) return
await mongoose.connect(url)
console.log('✅ Connected to MongoDB at', url)
}

21
server/src/index.ts Normal file
View File

@@ -0,0 +1,21 @@
import express from 'express'
import { connectMongo } from './db/connect.ts'
import cors from 'cors'
import keyRouter from './api/v1/key/index.ts'
import syncRouter from './api/v1/wanikani/sync.ts'
import kanjiRouter from './api/v1/subject/kanji.ts'
import vocabRouter from './api/v1/subject/vocab.ts'
const app = express()
app.use(cors())
app.use(express.json())
app.use('/api/v1/key', keyRouter)
app.use('/api/v1/wanikani', syncRouter)
app.use('/api/v1/subject/kanji', kanjiRouter)
app.use('/api/v1/subject/vocab', vocabRouter)
const PORT = process.env.PORT || 3000
connectMongo().then(() => {
app.listen(PORT, () => console.log(`Server running on port ${PORT}`))
})

View File

@@ -0,0 +1,7 @@
import mongoose from 'mongoose'
const ApiKeySchema = new mongoose.Schema({
apiKey: { type: String, required: true },
})
export const ApiKeyModel = mongoose.model('ApiKey', ApiKeySchema)

View File

@@ -0,0 +1,8 @@
import mongoose from 'mongoose'
const AssignmentSchema = new mongoose.Schema({
subject_type: String,
subject_ids: [Number],
})
export const AssignmentModel = mongoose.model('Assignment', AssignmentSchema)

View File

@@ -0,0 +1,14 @@
import mongoose from 'mongoose'
import type { KanjiItem } from '../types/wanikani.ts'
const KanjiSchema = new mongoose.Schema<KanjiItem>({
characters: String,
meanings: Array,
readings: Array,
auxiliary_meanings: Array,
level: Number,
slug: String,
srs_score: Number,
})
export const KanjiModel = mongoose.model('Kanji', KanjiSchema)

View File

@@ -0,0 +1,15 @@
import mongoose from 'mongoose'
import type { VocabularyItem } from '../types/wanikani.ts'
const VocabSchema = new mongoose.Schema<VocabularyItem>({
characters: String,
meanings: Array,
readings: Array,
auxiliary_meanings: Array,
pronunciation_audios: Array,
level: Number,
slug: String,
srs_score: Number,
})
export const VocabularyModel = mongoose.model('Vocabulary', VocabSchema)

View File

@@ -0,0 +1,141 @@
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 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
}
}
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 mapKanji = (item: WaniKaniSubject): KanjiItem => ({
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 mapVocab = (item: WaniKaniSubject): VocabularyItem => ({
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,
})
export const syncWanikaniData = async (apiKey: string): Promise<void> => {
const headers = { Authorization: `Bearer ${apiKey}` }
try {
await ApiKeyModel.updateOne(
{},
{ $set: { value: apiKey, lastUsed: new Date() } },
{ upsert: true },
)
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
const unlockedKanjiSubjectIds: number[] = []
const unlockedVocabSubjectIds: 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)
}
}
await AssignmentModel.updateOne(
{ subject_type: 'kanji' },
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
{ upsert: true },
)
await AssignmentModel.updateOne(
{ subject_type: 'vocabulary' },
{ $set: { subject_ids: unlockedVocabSubjectIds } },
{ 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(mapKanji))
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(mapVocab))
console.log('✅ Sync complete')
} catch (err) {
console.error('❌ Error syncing WaniKani data:', err)
throw err
}
}

View File

@@ -0,0 +1,57 @@
export interface KanjiItem {
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
}[]
level: number
slug: string
srs_score: number
}
export interface VocabularyItem {
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
srs_score: number
}
export interface Assignment {
unlocked_at?: Date
subject_ids: number[]
subject_type: string
}

46
server/tsconfig.json Normal file
View File

@@ -0,0 +1,46 @@
{
// Visit https://aka.ms/tsconfig to read more about this file
"compilerOptions": {
// File Layout
"rootDir": "./src",
"outDir": "./dist",
// Environment Settings
// See also https://aka.ms/tsconfig/module
"module": "nodenext",
"target": "esnext",
"types": [],
// For nodejs:
// "lib": ["esnext"],
// "types": ["node"],
// and npm install -D @types/node
// Other Outputs
"sourceMap": true,
"declaration": true,
"declarationMap": true,
// Stricter Typechecking Options
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
// Style Options
// "noImplicitReturns": true,
// "noImplicitOverride": true,
// "noUnusedLocals": true,
// "noUnusedParameters": true,
// "noFallthroughCasesInSwitch": true,
// "noPropertyAccessFromIndexSignature": true,
// Recommended Options
"strict": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": true,
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}