v1
This commit is contained in:
0
server/.env
Normal file
0
server/.env
Normal file
15
server/Dockerfile
Normal file
15
server/Dockerfile
Normal 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
29
server/package.json
Normal 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
2471
server/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
server/src/api/v1/key/index.ts
Normal file
57
server/src/api/v1/key/index.ts
Normal 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
|
||||
18
server/src/api/v1/subject/kanji.ts
Normal file
18
server/src/api/v1/subject/kanji.ts
Normal 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
|
||||
18
server/src/api/v1/subject/vocab.ts
Normal file
18
server/src/api/v1/subject/vocab.ts
Normal 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
|
||||
31
server/src/api/v1/wanikani/sync.ts
Normal file
31
server/src/api/v1/wanikani/sync.ts
Normal 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
8
server/src/db/connect.ts
Normal 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
21
server/src/index.ts
Normal 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}`))
|
||||
})
|
||||
7
server/src/models/apikey.model.ts
Normal file
7
server/src/models/apikey.model.ts
Normal 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)
|
||||
8
server/src/models/assignments.model.ts
Normal file
8
server/src/models/assignments.model.ts
Normal 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)
|
||||
14
server/src/models/kanji.model.ts
Normal file
14
server/src/models/kanji.model.ts
Normal 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)
|
||||
15
server/src/models/vocabulary.model.ts
Normal file
15
server/src/models/vocabulary.model.ts
Normal 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)
|
||||
141
server/src/services/wanikaniService.ts
Normal file
141
server/src/services/wanikaniService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
57
server/src/types/wanikani.ts
Normal file
57
server/src/types/wanikani.ts
Normal 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
46
server/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user