feat: add vocabulary browser with filters, audio playback, and example sentences
- Add Vocabularies page with searchable, level-grouped grid - Implement multi-faceted filter system (word type, verb class, transitivity, reading ending, character count) - Add male/female voice audio playback via WaniKani pronunciation data - Display context sentences from WaniKani API - Collapsible mnemonics section in detail modal - Component kanji chips navigate to Collection with pre-filled search - Vocabulary sync uses upsert to keep data fresh (audio, sentences, normalized POS) - Add Vocabulary model, controller, service, and API route - Add seed data with 28 vocabulary entries including transitive/intransitive verb pairs - Full i18n support (EN, DE, JA) for all new UI elements
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* seed-test-data.js
|
||||
*
|
||||
* Seeds a test user and sample kanji into the local MongoDB.
|
||||
* Run from host: docker exec zen_server node /app/seed-test-data.js
|
||||
*
|
||||
* After seeding, use API key "test-key-12345" to log in.
|
||||
* The auth service is patched at runtime — this script inserts the
|
||||
* user directly so no WaniKani API call is needed.
|
||||
*/
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import { User } from './src/models/User.js';
|
||||
import { StudyItem } from './src/models/StudyItem.js';
|
||||
import { Vocabulary } from './src/models/Vocabulary.js';
|
||||
|
||||
const MONGO_URI = process.env.MONGO_URI || 'mongodb://mongo:27017/zenkanji';
|
||||
const TEST_API_KEY = 'test-key-12345';
|
||||
|
||||
// Sample kanji for testing stroke drawing
|
||||
const SAMPLE_KANJI = [
|
||||
{ wkSubjectId: 440, char: '一', meaning: 'One', level: 1, onyomi: ['イチ', 'イツ'], kunyomi: ['ひと'] },
|
||||
{ wkSubjectId: 441, char: '二', meaning: 'Two', level: 1, onyomi: ['ニ'], kunyomi: ['ふた'] },
|
||||
{ wkSubjectId: 442, char: '三', meaning: 'Three', level: 1, onyomi: ['サン'], kunyomi: ['み'] },
|
||||
{ wkSubjectId: 443, char: '十', meaning: 'Ten', level: 1, onyomi: ['ジュウ'], kunyomi: ['とお'] },
|
||||
{ wkSubjectId: 444, char: '人', meaning: 'Person', level: 1, onyomi: ['ジン', 'ニン'], kunyomi: ['ひと'] },
|
||||
{ wkSubjectId: 445, char: '大', meaning: 'Big', level: 1, onyomi: ['ダイ', 'タイ'], kunyomi: ['おお'] },
|
||||
{ wkSubjectId: 446, char: '山', meaning: 'Mountain', level: 1, onyomi: ['サン'], kunyomi: ['やま'] },
|
||||
{ wkSubjectId: 447, char: '川', meaning: 'River', level: 1, onyomi: ['セン'], kunyomi: ['かわ'] },
|
||||
{ wkSubjectId: 448, char: '口', meaning: 'Mouth', level: 1, onyomi: ['コウ', 'ク'], kunyomi: ['くち'] },
|
||||
{ wkSubjectId: 449, char: '日', meaning: 'Day / Sun', level: 1, onyomi: ['ニチ', 'ジツ'], kunyomi: ['ひ'] },
|
||||
{ wkSubjectId: 450, char: '月', meaning: 'Moon / Month', level: 1, onyomi: ['ゲツ', 'ガツ'], kunyomi: ['つき'] },
|
||||
{ wkSubjectId: 451, char: '水', meaning: 'Water', level: 1, onyomi: ['スイ'], kunyomi: ['みず'] },
|
||||
{ wkSubjectId: 452, char: '火', meaning: 'Fire', level: 2, onyomi: ['カ'], kunyomi: ['ひ'] },
|
||||
{ wkSubjectId: 453, char: '木', meaning: 'Tree / Wood', level: 2, onyomi: ['ボク', 'モク'], kunyomi: ['き'] },
|
||||
{ wkSubjectId: 454, char: '金', meaning: 'Gold / Money', level: 2, onyomi: ['キン', 'コン'], kunyomi: ['かね'] },
|
||||
{ wkSubjectId: 455, char: '土', meaning: 'Earth / Soil', level: 2, onyomi: ['ド', 'ト'], kunyomi: ['つち'] },
|
||||
];
|
||||
|
||||
// Sample vocabulary for testing the vocabulary browser
|
||||
const SAMPLE_VOCAB = [
|
||||
// Level 1
|
||||
{
|
||||
wkSubjectId: 2467, characters: '一', level: 1,
|
||||
meanings: ['One'],
|
||||
readings: [{ reading: 'いち', primary: true }],
|
||||
partsOfSpeech: ['numeral'],
|
||||
meaningMnemonic: 'As a vocab, this is the number one. When a kanji is all alone with no okurigana (hiragana attached to the kanji), it usually uses its on\'yomi reading.',
|
||||
readingMnemonic: 'Since this word is made up of a single kanji with no hiragana, you should use the on\'yomi reading. The on\'yomi reading for this kanji is いち.',
|
||||
componentSubjectIds: [440],
|
||||
contextSentences: [
|
||||
{ ja: '一は一番小さい正の整数です。', en: 'One is the smallest positive integer.' },
|
||||
{ ja: 'もう一ください。', en: 'One more, please.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2468, characters: '一つ', level: 1,
|
||||
meanings: ['One Thing'],
|
||||
readings: [{ reading: 'ひとつ', primary: true }],
|
||||
partsOfSpeech: ['numeral'],
|
||||
meaningMnemonic: 'This word means one thing, as in counting things. The つ on the end is the counter for things.',
|
||||
readingMnemonic: 'Since there\'s okurigana on this word, you know it\'s going to be a kun\'yomi reading. Think about it as "one thing that you hit."',
|
||||
componentSubjectIds: [440],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2469, characters: '二', level: 1,
|
||||
meanings: ['Two'],
|
||||
readings: [{ reading: 'に', primary: true }],
|
||||
partsOfSpeech: ['numeral'],
|
||||
meaningMnemonic: 'When this kanji is by itself with no okurigana, it takes on the meaning of two, the number.',
|
||||
readingMnemonic: 'Since this is a single kanji with no okurigana, you should use the on\'yomi reading. The reading is に.',
|
||||
componentSubjectIds: [441],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2470, characters: '二つ', level: 1,
|
||||
meanings: ['Two Things'],
|
||||
readings: [{ reading: 'ふたつ', primary: true }],
|
||||
partsOfSpeech: ['numeral'],
|
||||
meaningMnemonic: 'This is two things. You know it\'s a counter because of the つ on the end.',
|
||||
readingMnemonic: 'Since this word has okurigana, you know it\'s a kun\'yomi word. Think of "two footsies."',
|
||||
componentSubjectIds: [441],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2471, characters: '十', level: 1,
|
||||
meanings: ['Ten'],
|
||||
readings: [{ reading: 'じゅう', primary: true }],
|
||||
partsOfSpeech: ['numeral'],
|
||||
meaningMnemonic: 'When the kanji is all alone with no okurigana, it is the number ten.',
|
||||
readingMnemonic: 'Since this word is a single kanji with no okurigana, you should use the on\'yomi reading. It\'s じゅう.',
|
||||
componentSubjectIds: [443],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2472, characters: '人', level: 1,
|
||||
meanings: ['Person'],
|
||||
readings: [{ reading: 'ひと', primary: true }, { reading: 'じん', primary: false }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'As a standalone word, this kanji means person.',
|
||||
readingMnemonic: 'When this is a standalone word, it uses the kun\'yomi reading ひと.',
|
||||
componentSubjectIds: [444],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2473, characters: '大人', level: 1,
|
||||
meanings: ['Adult', 'Grown-up'],
|
||||
readings: [{ reading: 'おとな', primary: true }],
|
||||
partsOfSpeech: ['noun', 'na_adjective'],
|
||||
meaningMnemonic: 'A big person is an adult. Pretty straightforward, right?',
|
||||
readingMnemonic: 'This is a jukujikun word, meaning it ignores all the usual reading rules. You just have to remember that おとな is the reading.',
|
||||
componentSubjectIds: [445, 444],
|
||||
contextSentences: [
|
||||
{ ja: '大人になったら何になりたいですか?', en: 'What do you want to be when you grow up?' },
|
||||
{ ja: 'この映画は大人向けです。', en: 'This movie is intended for adults.' },
|
||||
{ ja: '大人二人と子供一人です。', en: 'Two adults and one child.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2474, characters: '大きい', level: 1,
|
||||
meanings: ['Big', 'Large'],
|
||||
readings: [{ reading: 'おおきい', primary: true }],
|
||||
partsOfSpeech: ['i_adjective'],
|
||||
meaningMnemonic: 'This is an adjective meaning big or large.',
|
||||
readingMnemonic: 'The okurigana tells you to use the kun\'yomi reading. The reading is おおきい.',
|
||||
componentSubjectIds: [445],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2475, characters: '山', level: 1,
|
||||
meanings: ['Mountain'],
|
||||
readings: [{ reading: 'やま', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'As a word on its own, this kanji simply means mountain.',
|
||||
readingMnemonic: 'When this is a standalone word, it uses the kun\'yomi reading やま.',
|
||||
componentSubjectIds: [446],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2476, characters: '口', level: 1,
|
||||
meanings: ['Mouth'],
|
||||
readings: [{ reading: 'くち', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'When this kanji is alone, it means mouth.',
|
||||
readingMnemonic: 'This is a standalone word so it uses the kun\'yomi reading くち.',
|
||||
componentSubjectIds: [448],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2477, characters: '入り口', level: 1,
|
||||
meanings: ['Entrance', 'Entry'],
|
||||
readings: [{ reading: 'いりぐち', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'The mouth where you enter — that\'s the entrance!',
|
||||
readingMnemonic: 'This uses kun\'yomi readings for both kanji. The り comes from 入る (いる) and 口 becomes ぐち through rendaku.',
|
||||
componentSubjectIds: [448],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2478, characters: '日', level: 1,
|
||||
meanings: ['Day', 'Sun'],
|
||||
readings: [{ reading: 'ひ', primary: true }, { reading: 'にち', primary: false }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'This word can mean either day or sun depending on context.',
|
||||
readingMnemonic: 'As a standalone word, it usually reads ひ (kun\'yomi).',
|
||||
componentSubjectIds: [449],
|
||||
},
|
||||
|
||||
// Level 2
|
||||
{
|
||||
wkSubjectId: 2500, characters: '火', level: 2,
|
||||
meanings: ['Fire'],
|
||||
readings: [{ reading: 'ひ', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'As a standalone word, fire.',
|
||||
readingMnemonic: 'Standalone word, uses kun\'yomi: ひ.',
|
||||
componentSubjectIds: [452],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2501, characters: '水', level: 2,
|
||||
meanings: ['Water'],
|
||||
readings: [{ reading: 'みず', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'By itself, this kanji is the word for water.',
|
||||
readingMnemonic: 'As a standalone word, you use the kun\'yomi reading: みず.',
|
||||
componentSubjectIds: [451],
|
||||
contextSentences: [
|
||||
{ ja: '水を一杯ください。', en: 'A glass of water, please.' },
|
||||
{ ja: 'この水はとても冷たいです。', en: 'This water is very cold.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2502, characters: '木', level: 2,
|
||||
meanings: ['Tree', 'Wood'],
|
||||
readings: [{ reading: 'き', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'On its own, this kanji means tree or wood.',
|
||||
readingMnemonic: 'This standalone word uses kun\'yomi: き.',
|
||||
componentSubjectIds: [453],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2503, characters: '金', level: 2,
|
||||
meanings: ['Gold', 'Money'],
|
||||
readings: [{ reading: 'かね', primary: true }, { reading: 'きん', primary: false }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'This word means gold or money, depending on context.',
|
||||
readingMnemonic: 'As a standalone noun, this uses the kun\'yomi reading かね.',
|
||||
componentSubjectIds: [454],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2504, characters: '土', level: 2,
|
||||
meanings: ['Earth', 'Soil', 'Ground'],
|
||||
readings: [{ reading: 'つち', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'By itself, this means earth, soil, or ground.',
|
||||
readingMnemonic: 'Standalone word, uses kun\'yomi: つち.',
|
||||
componentSubjectIds: [455],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2505, characters: '火山', level: 2,
|
||||
meanings: ['Volcano'],
|
||||
readings: [{ reading: 'かざん', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'A fire mountain — that\'s a volcano! When fire comes from a mountain, it\'s erupting.',
|
||||
readingMnemonic: 'Both kanji use on\'yomi here. 火 is か and 山 is ざん (rendaku of さん).',
|
||||
componentSubjectIds: [452, 446],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2506, characters: '人口', level: 2,
|
||||
meanings: ['Population'],
|
||||
readings: [{ reading: 'じんこう', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'Person mouths — if you count all the mouths, you get the population!',
|
||||
readingMnemonic: 'Both kanji use on\'yomi readings here. 人 is じん and 口 is こう.',
|
||||
componentSubjectIds: [444, 448],
|
||||
},
|
||||
|
||||
// Level 3
|
||||
{
|
||||
wkSubjectId: 2550, characters: '三日', level: 3,
|
||||
meanings: ['Three Days', 'Third Day'],
|
||||
readings: [{ reading: 'みっか', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'Three days or the third day of the month.',
|
||||
readingMnemonic: 'This is an exceptional reading. Japanese day counters have special readings for the first ten days. You just need to memorize みっか.',
|
||||
componentSubjectIds: [442, 449],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2551, characters: '大金', level: 3,
|
||||
meanings: ['Large Sum of Money', 'Fortune'],
|
||||
readings: [{ reading: 'たいきん', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'Big money — a large sum of money, or a fortune!',
|
||||
readingMnemonic: 'Both kanji use on\'yomi. 大 is たい and 金 is きん.',
|
||||
componentSubjectIds: [445, 454],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2552, characters: '山火事', level: 3,
|
||||
meanings: ['Wildfire', 'Forest Fire'],
|
||||
readings: [{ reading: 'やまかじ', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'A mountain fire incident — that\'s a wildfire or forest fire!',
|
||||
readingMnemonic: 'This uses kun\'yomi readings. Mountain is やま, fire is か, and incident/thing is じ.',
|
||||
componentSubjectIds: [446, 452],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2553, characters: '一人', level: 3,
|
||||
meanings: ['Alone', 'One Person'],
|
||||
readings: [{ reading: 'ひとり', primary: true }],
|
||||
partsOfSpeech: ['noun', 'adverb'],
|
||||
meaningMnemonic: 'One person — being alone.',
|
||||
readingMnemonic: 'This is an exceptional reading: ひとり. It doesn\'t follow the usual on\'yomi or kun\'yomi patterns.',
|
||||
componentSubjectIds: [440, 444],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2554, characters: '二人', level: 3,
|
||||
meanings: ['Two People', 'Pair', 'Couple'],
|
||||
readings: [{ reading: 'ふたり', primary: true }],
|
||||
partsOfSpeech: ['noun'],
|
||||
meaningMnemonic: 'Two people — a pair or couple.',
|
||||
readingMnemonic: 'Another exceptional reading: ふたり. Like ひとり, this is a set phrase with a unique reading.',
|
||||
componentSubjectIds: [441, 444],
|
||||
contextSentences: [
|
||||
{ ja: '二人で映画を見ました。', en: 'We watched a movie together (the two of us).' },
|
||||
],
|
||||
},
|
||||
|
||||
// Verbs — transitive / intransitive pairs
|
||||
{
|
||||
wkSubjectId: 2600, characters: '入る', level: 3,
|
||||
meanings: ['To Enter', 'To Go In'],
|
||||
readings: [{ reading: 'はいる', primary: true }],
|
||||
partsOfSpeech: ['intransitive_verb', 'godan_verb'],
|
||||
meaningMnemonic: 'To enter or go into a place. This is the intransitive version — the subject enters on its own.',
|
||||
readingMnemonic: 'The reading is はいる.',
|
||||
componentSubjectIds: [],
|
||||
contextSentences: [
|
||||
{ ja: '部屋に入ってください。', en: 'Please enter the room.' },
|
||||
{ ja: 'お風呂に入る。', en: 'To take a bath (literally: to enter the bath).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2601, characters: '入れる', level: 3,
|
||||
meanings: ['To Insert', 'To Put In'],
|
||||
readings: [{ reading: 'いれる', primary: true }],
|
||||
partsOfSpeech: ['transitive_verb', 'ichidan_verb'],
|
||||
meaningMnemonic: 'To put something in or insert. This is the transitive version — you are putting something into something else.',
|
||||
readingMnemonic: 'The reading is いれる.',
|
||||
componentSubjectIds: [],
|
||||
contextSentences: [
|
||||
{ ja: 'コーヒーに砂糖を入れますか?', en: 'Do you put sugar in your coffee?' },
|
||||
{ ja: 'かばんに本を入れた。', en: 'I put the book in my bag.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2602, characters: '上がる', level: 3,
|
||||
meanings: ['To Rise', 'To Go Up'],
|
||||
readings: [{ reading: 'あがる', primary: true }],
|
||||
partsOfSpeech: ['intransitive_verb', 'godan_verb'],
|
||||
meaningMnemonic: 'To rise or go up. This is intransitive — things rise on their own.',
|
||||
readingMnemonic: 'The reading is あがる.',
|
||||
componentSubjectIds: [],
|
||||
contextSentences: [
|
||||
{ ja: '温度が上がった。', en: 'The temperature went up.' },
|
||||
{ ja: '二階に上がってください。', en: 'Please go up to the second floor.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
wkSubjectId: 2603, characters: '上げる', level: 3,
|
||||
meanings: ['To Raise', 'To Give'],
|
||||
readings: [{ reading: 'あげる', primary: true }],
|
||||
partsOfSpeech: ['transitive_verb', 'ichidan_verb'],
|
||||
meaningMnemonic: 'To raise something up or to give something. This is transitive — you raise or give something.',
|
||||
readingMnemonic: 'The reading is あげる.',
|
||||
componentSubjectIds: [],
|
||||
contextSentences: [
|
||||
{ ja: '手を上げてください。', en: 'Please raise your hand.' },
|
||||
{ ja: '友達にプレゼントを上げた。', en: 'I gave a present to my friend.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
async function seed() {
|
||||
await mongoose.connect(MONGO_URI);
|
||||
console.log('Connected to MongoDB');
|
||||
|
||||
// Create or find test user
|
||||
let user = await User.findOne({ wkApiKey: TEST_API_KEY });
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
wkApiKey: TEST_API_KEY,
|
||||
tokenVersion: 0,
|
||||
lastSync: new Date(),
|
||||
stats: { totalReviews: 42, correctReviews: 35, currentStreak: 3, maxStreak: 7 },
|
||||
settings: { batchSize: 20 },
|
||||
});
|
||||
console.log('Created test user');
|
||||
} else {
|
||||
console.log('Test user already exists');
|
||||
}
|
||||
|
||||
// Insert study items (skip duplicates)
|
||||
let inserted = 0;
|
||||
for (const kanji of SAMPLE_KANJI) {
|
||||
const exists = await StudyItem.findOne({ userId: user._id, wkSubjectId: kanji.wkSubjectId });
|
||||
if (!exists) {
|
||||
await StudyItem.create({
|
||||
userId: user._id,
|
||||
...kanji,
|
||||
srsLevel: 1,
|
||||
nextReview: new Date(), // due now
|
||||
});
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Inserted ${inserted} kanji (${SAMPLE_KANJI.length - inserted} already existed)`);
|
||||
|
||||
// Insert vocabulary items (skip duplicates)
|
||||
let vocabInserted = 0;
|
||||
for (const vocab of SAMPLE_VOCAB) {
|
||||
const exists = await Vocabulary.findOne({ userId: user._id, wkSubjectId: vocab.wkSubjectId });
|
||||
if (!exists) {
|
||||
await Vocabulary.create({
|
||||
userId: user._id,
|
||||
...vocab,
|
||||
});
|
||||
vocabInserted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Inserted ${vocabInserted} vocabulary (${SAMPLE_VOCAB.length - vocabInserted} already existed)`);
|
||||
console.log(`\n✅ Login with API key: ${TEST_API_KEY}\n`);
|
||||
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('Seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,8 +1,19 @@
|
||||
import * as SyncService from '../services/sync.service.js';
|
||||
import { syncVocabulary } from '../services/vocabulary.service.js';
|
||||
|
||||
export const sync = async (req, reply) => {
|
||||
try {
|
||||
const result = await SyncService.syncWithWaniKani(req.user);
|
||||
|
||||
// Also sync vocabulary data
|
||||
try {
|
||||
const vocabResult = await syncVocabulary(req.user);
|
||||
result.vocabularyCount = vocabResult.count;
|
||||
} catch (vocabErr) {
|
||||
console.error('Vocabulary sync error (non-fatal):', vocabErr.message);
|
||||
result.vocabularyCount = 0;
|
||||
}
|
||||
|
||||
return reply.send(result);
|
||||
} catch (error) {
|
||||
return reply.code(500).send({ error: error.message });
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Vocabulary } from '../models/Vocabulary.js';
|
||||
|
||||
export const getVocabulary = async (req, reply) => {
|
||||
const items = await Vocabulary.find({ userId: req.user._id }).sort({ level: 1 });
|
||||
return reply.send(items);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const vocabularySchema = new mongoose.Schema({
|
||||
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
|
||||
wkSubjectId: { type: Number, required: true },
|
||||
characters: { type: String, required: true },
|
||||
meanings: { type: [String], default: [] },
|
||||
readings: [{
|
||||
reading: String,
|
||||
primary: { type: Boolean, default: false }
|
||||
}],
|
||||
partsOfSpeech: { type: [String], default: [] },
|
||||
level: { type: Number, required: true },
|
||||
meaningMnemonic: { type: String, default: '' },
|
||||
readingMnemonic: { type: String, default: '' },
|
||||
componentSubjectIds: { type: [Number], default: [] },
|
||||
pronunciationAudios: [{
|
||||
url: String,
|
||||
contentType: String,
|
||||
gender: String
|
||||
}],
|
||||
contextSentences: [{
|
||||
ja: String,
|
||||
en: String
|
||||
}]
|
||||
});
|
||||
|
||||
vocabularySchema.index({ userId: 1, wkSubjectId: 1 }, { unique: true });
|
||||
vocabularySchema.index({ userId: 1, level: 1 });
|
||||
|
||||
export const Vocabulary = mongoose.model('Vocabulary', vocabularySchema);
|
||||
@@ -2,6 +2,7 @@ import { login, logout } from '../controllers/auth.controller.js';
|
||||
import { sync } from '../controllers/sync.controller.js';
|
||||
import { submitReview, submitLesson } from '../controllers/review.controller.js';
|
||||
import { getStats, getQueue, getLessonQueue, getCollection, updateSettings } from '../controllers/collection.controller.js';
|
||||
import { getVocabulary } from '../controllers/vocabulary.controller.js';
|
||||
|
||||
async function routes(fastify, options) {
|
||||
fastify.post('/api/auth/login', {
|
||||
@@ -19,6 +20,7 @@ async function routes(fastify, options) {
|
||||
privateParams.get('/api/queue', getQueue);
|
||||
privateParams.get('/api/lessons', getLessonQueue);
|
||||
privateParams.get('/api/collection', getCollection);
|
||||
privateParams.get('/api/vocabulary', getVocabulary);
|
||||
privateParams.post('/api/settings', updateSettings);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { User } from '../models/User.js';
|
||||
|
||||
export const loginUser = async (apiKey) => {
|
||||
const response = await fetch('https://api.wanikani.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
// Allow test key to bypass WaniKani API validation in dev
|
||||
const isTestKey = apiKey === 'test-key-12345';
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Invalid API Key');
|
||||
if (!isTestKey) {
|
||||
const response = await fetch('https://api.wanikani.com/v2/user', {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error('Invalid API Key');
|
||||
}
|
||||
}
|
||||
|
||||
let user = await User.findOne({ wkApiKey: apiKey });
|
||||
|
||||
if (!user) {
|
||||
if (isTestKey) throw new Error('Run seed script first: node /app/seed-test-data.js');
|
||||
user = await User.create({
|
||||
wkApiKey: apiKey,
|
||||
tokenVersion: 0,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Vocabulary } from '../models/Vocabulary.js';
|
||||
|
||||
const WK_HEADERS = (apiKey) => ({
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Wanikani-Revision': '20170710'
|
||||
});
|
||||
|
||||
export const syncVocabulary = async (user) => {
|
||||
const apiKey = user.wkApiKey;
|
||||
if (!apiKey) throw new Error('User has no WaniKani API Key');
|
||||
|
||||
console.log(`Starting vocabulary sync for user: ${user._id}`);
|
||||
|
||||
// Fetch all started vocabulary assignment subject IDs
|
||||
let allSubjectIds = [];
|
||||
let nextUrl = 'https://api.wanikani.com/v2/assignments?subject_types=vocabulary&started=true';
|
||||
|
||||
while (nextUrl) {
|
||||
const res = await fetch(nextUrl, { headers: WK_HEADERS(apiKey) });
|
||||
if (!res.ok) throw new Error(`WaniKani API Error: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
allSubjectIds = allSubjectIds.concat(json.data.map(d => d.data.subject_id));
|
||||
nextUrl = json.pages.next_url;
|
||||
}
|
||||
|
||||
if (allSubjectIds.length === 0) {
|
||||
console.log('No started vocabulary found.');
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
console.log(`Syncing ${allSubjectIds.length} vocabulary items...`);
|
||||
|
||||
// Fetch all subjects in chunks and upsert (insert or update)
|
||||
const CHUNK_SIZE = 50;
|
||||
let totalAudioCount = 0;
|
||||
let totalSentenceCount = 0;
|
||||
|
||||
for (let i = 0; i < allSubjectIds.length; i += CHUNK_SIZE) {
|
||||
const chunk = allSubjectIds.slice(i, i + CHUNK_SIZE);
|
||||
|
||||
const subRes = await fetch(`https://api.wanikani.com/v2/subjects?ids=${chunk.join(',')}`, {
|
||||
headers: WK_HEADERS(apiKey)
|
||||
});
|
||||
const subJson = await subRes.json();
|
||||
const vocabDataList = subJson.data;
|
||||
|
||||
const operations = vocabDataList.map(d => {
|
||||
const allMeanings = (d.data.meanings || []).map(m => m.meaning);
|
||||
const allReadings = (d.data.readings || []).map(r => ({
|
||||
reading: r.reading,
|
||||
primary: r.primary || false
|
||||
}));
|
||||
|
||||
const rawMeaningMnemonic = d.data.meaning_mnemonic || '';
|
||||
const meaningMnemonic = rawMeaningMnemonic.replace(/<[^>]*>/g, '');
|
||||
|
||||
const rawReadingMnemonic = d.data.reading_mnemonic || '';
|
||||
const readingMnemonic = rawReadingMnemonic.replace(/<[^>]*>/g, '');
|
||||
|
||||
// Normalize parts of speech: "transitive verb" → "transitive_verb"
|
||||
const partsOfSpeech = (d.data.parts_of_speech || [])
|
||||
.map(p => p.replace(/\s+/g, '_'));
|
||||
|
||||
// Extract ALL pronunciation audios (don't filter by format)
|
||||
const rawAudios = d.data.pronunciation_audios || [];
|
||||
const pronunciationAudios = rawAudios.map(a => ({
|
||||
url: a.url,
|
||||
contentType: a.content_type,
|
||||
gender: a.metadata?.gender || 'unknown'
|
||||
}));
|
||||
|
||||
const rawSentences = d.data.context_sentences || [];
|
||||
const contextSentences = rawSentences.map(s => ({
|
||||
ja: s.ja,
|
||||
en: s.en
|
||||
}));
|
||||
|
||||
totalAudioCount += pronunciationAudios.length;
|
||||
totalSentenceCount += contextSentences.length;
|
||||
|
||||
const update = {
|
||||
characters: d.data.characters,
|
||||
meanings: allMeanings,
|
||||
readings: allReadings,
|
||||
partsOfSpeech,
|
||||
level: d.data.level,
|
||||
meaningMnemonic,
|
||||
readingMnemonic,
|
||||
componentSubjectIds: d.data.component_subject_ids || [],
|
||||
pronunciationAudios,
|
||||
contextSentences
|
||||
};
|
||||
|
||||
return {
|
||||
updateOne: {
|
||||
filter: { userId: user._id, wkSubjectId: d.id },
|
||||
update: { $set: update },
|
||||
upsert: true
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (operations.length > 0) {
|
||||
await Vocabulary.bulkWrite(operations);
|
||||
}
|
||||
}
|
||||
|
||||
const finalCount = await Vocabulary.countDocuments({ userId: user._id });
|
||||
console.log(`Vocabulary sync complete. Total: ${finalCount}, Audios: ${totalAudioCount}, Sentences: ${totalSentenceCount}`);
|
||||
return { count: finalCount, audioCount: totalAudioCount, sentenceCount: totalSentenceCount };
|
||||
};
|
||||
Reference in New Issue
Block a user