From b1d4f9f71b6078beb478711e64821fafabecb2eb Mon Sep 17 00:00:00 2001 From: Rene Kievits Date: Tue, 28 Apr 2026 21:57:34 +0200 Subject: [PATCH] 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 --- client/src/App.vue | 146 +++- client/src/components/kanji/KanjiCanvas.vue | 7 +- client/src/composables/useStrokeEvaluation.js | 66 ++ client/src/main.js | 2 + client/src/plugins/i18n.js | 87 +++ client/src/stores/appStore.js | 53 +- client/src/styles/pages/_index.scss | 1 + client/src/styles/pages/_vocabularies.scss | 322 +++++++++ client/src/utils/KanjiController.js | 211 +----- client/src/utils/StrokeConfig.js | 90 +++ client/src/utils/StrokeEvaluator.js | 432 ++++++++++++ client/src/views/Collection.vue | 7 +- client/src/views/Vocabularies.vue | 622 ++++++++++++++++++ docker-compose.dev.yml | 9 + server/seed-test-data.js | 393 +++++++++++ server/src/controllers/sync.controller.js | 11 + .../src/controllers/vocabulary.controller.js | 6 + server/src/models/Vocabulary.js | 31 + server/src/routes/v1.js | 2 + server/src/services/auth.service.js | 16 +- server/src/services/vocabulary.service.js | 111 ++++ 21 files changed, 2403 insertions(+), 222 deletions(-) create mode 100644 client/src/composables/useStrokeEvaluation.js create mode 100644 client/src/styles/pages/_vocabularies.scss create mode 100644 client/src/utils/StrokeConfig.js create mode 100644 client/src/utils/StrokeEvaluator.js create mode 100644 client/src/views/Vocabularies.vue create mode 100644 server/seed-test-data.js create mode 100644 server/src/controllers/vocabulary.controller.js create mode 100644 server/src/models/Vocabulary.js create mode 100644 server/src/services/vocabulary.service.js diff --git a/client/src/App.vue b/client/src/App.vue index 63d7b84..2346cc3 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -36,6 +36,16 @@ v-list-item-title.font-weight-bold ScrambleText(:text="$t('nav.collection')") + v-list-item.mb-2( + to="/vocabularies" + rounded="lg" + :active="$route.path === '/vocabularies'" + ) + template(v-slot:prepend) + v-icon(color="#00cec9" icon="mdi-translate") + v-list-item-title.font-weight-bold + ScrambleText(:text="$t('nav.vocabularies')") + v-divider.my-4.border-subtle .d-flex.flex-column.gap-2 @@ -116,6 +126,13 @@ ) ScrambleText(:text="$t('nav.collection')") + v-btn.mx-1( + to="/vocabularies" + variant="text" + :color="$route.path === '/vocabularies' ? '#00cec9' : 'grey'" + ) + ScrambleText(:text="$t('nav.vocabularies')") + v-divider.mx-2.my-auto(vertical length="20" color="grey-darken-2") v-tooltip(:text="$t('nav.settings')" location="bottom") @@ -230,24 +247,43 @@ ScrambleText(:text="$t('settings.items')") .text-caption.text-grey.mb-2 - ScrambleText(:text="$t('settings.drawingTolerance')") - v-slider( - v-model="tempDrawingAccuracy" - :min="1" - :max="20" - :step="1" - thumb-label - color="#00cec9" - track-color="grey-darken-3" + ScrambleText(:text="$t('settings.strokeStrictness')") + .d-flex.justify-center.gap-2.mb-2 + v-btn( + v-for="p in presetNames" + :key="p" + size="small" + :variant="tempPreset === p ? 'flat' : 'outlined'" + :color="tempPreset === p ? '#00cec9' : 'grey'" + @click="selectPreset(p)" + ) {{ p.charAt(0).toUpperCase() + p.slice(1) }} + + v-btn.mb-3( + variant="text" + size="x-small" + color="grey" + :prepend-icon="showAdvanced ? 'mdi-chevron-up' : 'mdi-chevron-down'" + @click="showAdvanced = !showAdvanced" ) - .d-flex.justify-space-between.text-caption.text-grey-lighten-1.mb-6.px-1 - span - ScrambleText(:text="$t('settings.strict')") - | (5) - span.font-weight-bold.text-body-1(color="#00cec9") {{ tempDrawingAccuracy }} - span - ScrambleText(:text="$t('settings.loose')") - | (20) + ScrambleText(:text="$t('settings.advanced')") + + v-expand-transition + .advanced-sliders(v-show="showAdvanced") + .slider-row(v-for="facet in facetSliders" :key="facet.key") + .d-flex.justify-space-between.align-center.mb-1 + .text-caption.text-grey {{ facet.label }} + .text-caption.font-weight-bold.text-teal-accent-3 {{ facet.value }}% + v-slider( + :model-value="facet.value" + @update:model-value="v => updateFacetSlider(facet.key, v)" + :min="5" + :max="95" + :step="5" + color="#00cec9" + track-color="grey-darken-3" + density="compact" + hide-details + ) .text-caption.text-grey.mb-2 ScrambleText(:text="$t('settings.language')") @@ -321,13 +357,16 @@ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 08cb4ec..272ec3e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,17 +14,26 @@ services: build: context: ./server container_name: zen_server + ports: + - "3000:3000" env_file: - ./server/.env + depends_on: + - mongo networks: - zen-network client: build: context: ./client + target: dev-stage container_name: zen_client + ports: + - "5173:5173" env_file: - ./client/.env + volumes: + - ./client/src:/app/src depends_on: - server networks: diff --git a/server/seed-test-data.js b/server/seed-test-data.js new file mode 100644 index 0000000..0458dc6 --- /dev/null +++ b/server/seed-test-data.js @@ -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); +}); diff --git a/server/src/controllers/sync.controller.js b/server/src/controllers/sync.controller.js index b6073df..b6183ff 100644 --- a/server/src/controllers/sync.controller.js +++ b/server/src/controllers/sync.controller.js @@ -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 }); diff --git a/server/src/controllers/vocabulary.controller.js b/server/src/controllers/vocabulary.controller.js new file mode 100644 index 0000000..8678f59 --- /dev/null +++ b/server/src/controllers/vocabulary.controller.js @@ -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); +}; diff --git a/server/src/models/Vocabulary.js b/server/src/models/Vocabulary.js new file mode 100644 index 0000000..24c0dde --- /dev/null +++ b/server/src/models/Vocabulary.js @@ -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); diff --git a/server/src/routes/v1.js b/server/src/routes/v1.js index 06bab4b..15894f9 100644 --- a/server/src/routes/v1.js +++ b/server/src/routes/v1.js @@ -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); }); } diff --git a/server/src/services/auth.service.js b/server/src/services/auth.service.js index da80370..76b7024 100644 --- a/server/src/services/auth.service.js +++ b/server/src/services/auth.service.js @@ -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, diff --git a/server/src/services/vocabulary.service.js b/server/src/services/vocabulary.service.js new file mode 100644 index 0000000..83b5dc0 --- /dev/null +++ b/server/src/services/vocabulary.service.js @@ -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 }; +};