3 Commits

Author SHA1 Message Date
Crylia 2179cc6dcf fix margins
Release Build / build-docker (push) Successful in 27s
Release Build / build-android-and-release (push) Successful in 5m52s
2026-04-28 22:49:42 +02:00
Crylia ede3fc61f1 fix(ci): add android platform before capacitor sync 2026-04-28 22:00:33 +02:00
Crylia b1d4f9f71b feat: add vocabulary browser with filters, audio playback, and example sentences
Release Build / build-docker (push) Successful in 2m44s
Release Build / build-android-and-release (push) Failing after 1m27s
- 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
2026-04-28 21:57:34 +02:00
24 changed files with 2432 additions and 227 deletions
+3 -1
View File
@@ -77,7 +77,9 @@ jobs:
- name: Sync Capacitor to Android
working-directory: client
run: npx cap sync android
run: |
npx cap add android
npx cap sync android
- name: Decode Keystore
run: |
+123 -18
View File
@@ -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')")
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"
)
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(
v-model="tempDrawingAccuracy"
:min="1"
:max="20"
:step="1"
thumb-label
: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
)
.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)
.text-caption.text-grey.mb-2
ScrambleText(:text="$t('settings.language')")
@@ -321,13 +357,16 @@
<script setup>
/* eslint-disable no-unused-vars */
import {
ref, watch, onMounted, computed, onUnmounted,
ref, watch, onMounted, computed, onUnmounted, reactive,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { App as CapacitorApp } from '@capacitor/app';
import { useAppStore } from '@/stores/appStore';
import { checkForUpdates } from '@/utils/autoUpdate';
import { SoundManager } from '@/utils/SoundManager';
import {
PRESET_NAMES, PRESETS, FACET_KEYS, thresholdToSlider, sliderToThreshold,
} from '@/utils/StrokeConfig.js';
import logo from '@/assets/icon.svg';
const drawer = ref(false);
@@ -345,7 +384,46 @@ const showLogoutDialog = ref(false);
const snackbar = ref({ show: false, text: '', color: 'success' });
const tempBatchSize = ref(store.batchSize);
const tempDrawingAccuracy = ref(store.drawingAccuracy);
const tempPreset = ref(store.strokeStrictness.preset || 'medium');
const tempOverrides = reactive({});
const showAdvanced = ref(false);
const presetNames = PRESET_NAMES;
// Human-readable facet labels
const facetLabels = {
proximityThreshold: 'Position',
directionThreshold: 'Direction',
shapeThreshold: 'Shape',
lengthThreshold: 'Length',
curvatureThreshold: 'Curvature',
};
const facetSliders = computed(() => FACET_KEYS.map((key) => {
const preset = PRESETS[tempPreset.value] || PRESETS.medium;
const raw = tempOverrides[key] !== undefined ? tempOverrides[key] : preset[key];
return {
key,
label: facetLabels[key] || key,
value: thresholdToSlider(raw),
};
}));
function selectPreset(p) {
tempPreset.value = p;
// Clear all overrides when selecting a named preset
FACET_KEYS.forEach((k) => { delete tempOverrides[k]; });
}
function updateFacetSlider(key, sliderVal) {
tempOverrides[key] = sliderToThreshold(sliderVal);
// If overrides differ from current preset, mark as custom
const preset = PRESETS[tempPreset.value] || PRESETS.medium;
const isCustom = FACET_KEYS.some((k) => {
if (tempOverrides[k] === undefined) return false;
return Math.abs(tempOverrides[k] - preset[k]) > 0.001;
});
if (isCustom) tempPreset.value = 'custom';
}
const availableLocales = ['en', 'de', 'ja'];
const tempLocale = ref(locale.value);
@@ -460,7 +538,14 @@ const parallaxStyle = computed(() => {
watch(showSettings, (isOpen) => {
if (isOpen) {
tempBatchSize.value = store.batchSize;
tempDrawingAccuracy.value = store.drawingAccuracy;
tempPreset.value = store.strokeStrictness.preset || 'medium';
// Restore any saved overrides
FACET_KEYS.forEach((k) => { delete tempOverrides[k]; });
if (store.strokeStrictness.overrides) {
Object.entries(store.strokeStrictness.overrides).forEach(([k, v]) => {
tempOverrides[k] = v;
});
}
tempLocale.value = locale.value;
soundEnabled.value = !SoundManager.isMuted;
}
@@ -516,9 +601,28 @@ function saveSettings() {
showSettings.value = false;
setTimeout(() => {
// Build the overrides object (only include keys that differ from preset)
const overrides = {};
const preset = PRESETS[tempPreset.value];
if (preset) {
FACET_KEYS.forEach((k) => {
if (tempOverrides[k] !== undefined && Math.abs(tempOverrides[k] - preset[k]) > 0.001) {
overrides[k] = tempOverrides[k];
}
});
} else {
// Custom preset — keep all overrides
FACET_KEYS.forEach((k) => {
if (tempOverrides[k] !== undefined) overrides[k] = tempOverrides[k];
});
}
store.saveSettings({
batchSize: tempBatchSize.value,
drawingAccuracy: tempDrawingAccuracy.value,
strokeStrictness: {
preset: tempPreset.value,
overrides,
},
});
SoundManager.setMuted(!soundEnabled.value);
@@ -678,6 +782,7 @@ function confirmLogout() {
z-index: 10;
width: 100%;
height: 100%;
overflow-x: hidden;
opacity: 0;
transform: scale(0.95);
filter: blur(10px);
+4 -3
View File
@@ -89,7 +89,7 @@ function handlePointerUp(e) {
onMounted(() => {
controller = new KanjiController({
size: props.size,
accuracy: store.drawingAccuracy,
config: store.resolvedStrokeConfig,
onComplete: () => emit('complete'),
onMistake: (needsHint) => {
isShaking.value = true;
@@ -134,13 +134,14 @@ watch(() => props.size, (newSize) => {
if (controller) controller.resize(newSize);
});
watch(() => store.drawingAccuracy, (newVal) => {
if (controller) controller.setAccuracy(newVal);
watch(() => store.resolvedStrokeConfig, (newConfig) => {
if (controller) controller.setConfig(newConfig);
});
defineExpose({
reset: () => controller?.reset(),
showHint: () => controller?.showHint(),
getLastEvaluation: () => controller?.getLastEvaluation(),
drawGuide: (enableAutoHint) => {
if (!controller) return;
if (enableAutoHint) controller.setAutoHint(true);
@@ -0,0 +1,66 @@
/**
* useStrokeEvaluation.js
*
* Vue composable that provides reactive access to stroke evaluation
* results and the resolved config from the store.
*
* Usage in a component:
* const { config, lastResult, hasFeedback } = useStrokeEvaluation();
*
* The `lastResult` ref is updated whenever a KanjiCanvas emits an
* evaluation result via its `getLastEvaluation()` exposed method.
*/
import { computed, ref } from 'vue';
import { useAppStore } from '@/stores/appStore';
export function useStrokeEvaluation() {
const store = useAppStore();
/** Resolved frozen config object, reactively derived from the store. */
const config = computed(() => store.resolvedStrokeConfig);
/** Last evaluation result, manually updated by the parent component. */
const lastResult = ref(null);
/**
* Call this after each stroke (on @complete or @mistake) to capture
* the detailed score breakdown from the canvas controller.
*
* @param {import('@/components/kanji/KanjiCanvas.vue').default} canvasRef
*/
function captureResult(canvasRef) {
if (canvasRef?.getLastEvaluation) {
lastResult.value = canvasRef.getLastEvaluation();
}
}
/** Whether we have a non-null result to display. */
const hasFeedback = computed(() => lastResult.value !== null);
/** Per-facet pass/fail booleans for UI badges. */
const facetResults = computed(() => {
if (!lastResult.value) return null;
const { scores, thresholds } = lastResult.value;
return {
proximity: scores.proximity >= thresholds.proximity,
direction: scores.direction >= thresholds.direction,
shape: scores.shape >= thresholds.shape,
length: scores.length >= thresholds.length,
curvature: scores.curvature >= thresholds.curvature,
};
});
function clear() {
lastResult.value = null;
}
return {
config,
lastResult,
hasFeedback,
facetResults,
captureResult,
clear,
};
}
+2
View File
@@ -17,6 +17,7 @@ import Dashboard from './views/Dashboard.vue';
import Collection from './views/Collection.vue';
import Review from './views/Review.vue';
import Lesson from './views/Lesson.vue';
import Vocabularies from './views/Vocabularies.vue';
const app = createApp(App);
const pinia = createPinia();
@@ -26,6 +27,7 @@ const router = createRouter({
routes: [
{ path: '/', component: Dashboard },
{ path: '/collection', component: Collection },
{ path: '/vocabularies', component: Vocabularies },
{ path: '/review', component: Review },
{ path: '/lesson', component: Lesson },
{ path: '/:pathMatch(.*)*', redirect: '/' },
+87
View File
@@ -10,6 +10,7 @@ const messages = {
dashboard: 'Dashboard',
review: 'Review',
collection: 'Collection',
vocabularies: 'Vocabularies',
settings: 'Settings',
sync: 'Sync',
logout: 'Logout',
@@ -99,6 +100,8 @@ const messages = {
items: 'Items',
language: 'Language',
drawingTolerance: 'Drawing Tolerance',
strokeStrictness: 'Stroke Strictness',
advanced: 'Advanced',
strict: 'Strict',
loose: 'Loose',
save: 'Save & Close',
@@ -142,6 +145,32 @@ const messages = {
startLesson: 'Start Lesson',
redoLesson: 'Redo Lesson',
},
vocabulary: {
searchLabel: 'Search Vocabulary...',
placeholder: "e.g. '大人', 'otona', 'adult'",
loading: 'Loading Vocabularies...',
noMatches: 'No matches found',
tryDifferent: 'Try searching for a different word or reading.',
levelHeader: 'LEVEL',
levelLabel: 'Level',
primary: 'Primary',
mnemonics: 'Mnemonics',
meaningMnemonic: 'Meaning Mnemonic',
readingMnemonic: 'Reading Mnemonic',
components: 'Component Kanji',
exampleSentences: 'Example Sentences',
filters: 'Filters',
filterType: 'Word Type',
filterVerbClass: 'Verb Class',
filterTransitivity: 'Transitivity',
filterEnding: 'Reading Ending',
filterLength: 'Character Count',
clearAll: 'Clear All',
matchCount: '{n} results',
listenMale: 'Male',
listenFemale: 'Female',
close: 'Close',
},
},
de: {
common: {
@@ -152,6 +181,7 @@ const messages = {
dashboard: 'Übersicht',
review: 'Lernen',
collection: 'Sammlung',
vocabularies: 'Vokabeln',
settings: 'Einstellungen',
sync: 'Sync',
logout: 'Abmelden',
@@ -241,6 +271,8 @@ const messages = {
items: 'Einträge',
language: 'Sprache',
drawingTolerance: 'Zeichentoleranz',
strokeStrictness: 'Strichgenauigkeit',
advanced: 'Erweitert',
strict: 'Strikt',
loose: 'Locker',
save: 'Speichern & Schließen',
@@ -284,6 +316,32 @@ const messages = {
startLesson: 'Lektion starten',
redoLesson: 'Lektion wiederholen',
},
vocabulary: {
searchLabel: 'Vokabel suchen...',
placeholder: "z.B. '大人', 'otona', 'Erwachsener'",
loading: 'Lade Vokabeln...',
noMatches: 'Keine Treffer',
tryDifferent: 'Versuche einen anderen Suchbegriff.',
levelHeader: 'STUFE',
levelLabel: 'Stufe',
primary: 'Primär',
mnemonics: 'Eselsbrücken',
meaningMnemonic: 'Bedeutungs-Eselsbrücke',
readingMnemonic: 'Lesungs-Eselsbrücke',
components: 'Kanji-Bestandteile',
exampleSentences: 'Beispielsätze',
filters: 'Filter',
filterType: 'Wortart',
filterVerbClass: 'Verbklasse',
filterTransitivity: 'Transitivität',
filterEnding: 'Lesung-Endung',
filterLength: 'Zeichenanzahl',
clearAll: 'Zurücksetzen',
matchCount: '{n} Ergebnisse',
listenMale: 'Männlich',
listenFemale: 'Weiblich',
close: 'Schließen',
},
},
ja: {
common: {
@@ -294,6 +352,7 @@ const messages = {
dashboard: 'ダッシュボード',
review: '復習',
collection: 'コレクション',
vocabularies: '単語',
settings: '設定',
sync: '同期',
logout: 'ログアウト',
@@ -383,6 +442,8 @@ const messages = {
items: '個',
language: '言語 (Language)',
drawingTolerance: '描画許容範囲',
strokeStrictness: '筆画の厳密さ',
advanced: '詳細設定',
strict: '厳しい',
loose: '甘い',
save: '保存して閉じる',
@@ -426,6 +487,32 @@ const messages = {
startLesson: 'レッスン開始',
redoLesson: 'レッスンをやり直す',
},
vocabulary: {
searchLabel: '単語を検索...',
placeholder: "例: '大人', 'おとな', 'adult'",
loading: '単語を読み込み中...',
noMatches: '見つかりませんでした',
tryDifferent: '別のキーワードで検索してください。',
levelHeader: 'レベル',
levelLabel: 'レベル',
primary: '主要',
mnemonics: '覚え方',
meaningMnemonic: '意味の覚え方',
readingMnemonic: '読みの覚え方',
components: '構成漢字',
exampleSentences: '例文',
filters: 'フィルター',
filterType: '品詞',
filterVerbClass: '動詞の種類',
filterTransitivity: '自他',
filterEnding: '読みの語尾',
filterLength: '文字数',
clearAll: 'クリア',
matchCount: '{n}件',
listenMale: '男性',
listenFemale: '女性',
close: '閉じる',
},
},
};
+46 -7
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { createConfig, migrateOldAccuracy } from '@/utils/StrokeConfig.js';
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
@@ -9,6 +10,7 @@ export const useAppStore = defineStore('app', {
queue: [],
lessonQueue: [],
collection: [],
vocabularies: [],
stats: {
distribution: {},
forecast: [],
@@ -19,10 +21,25 @@ export const useAppStore = defineStore('app', {
ghosts: [],
},
batchSize: parseInt(localStorage.getItem('zen_batch_size'), 10) || 20,
drawingAccuracy: parseInt(localStorage.getItem('zen_drawing_accuracy'), 10) || 10,
strokeStrictness: (() => {
// Try new format first, then migrate from old drawingAccuracy
const saved = localStorage.getItem('zen_stroke_strictness');
if (saved) {
try { return JSON.parse(saved); } catch (e) { /* fall through */ }
}
const oldVal = parseInt(localStorage.getItem('zen_drawing_accuracy'), 10);
if (oldVal) return migrateOldAccuracy(oldVal);
return { preset: 'medium', overrides: {} };
})(),
loading: false,
}),
getters: {
resolvedStrokeConfig(state) {
return createConfig(state.strokeStrictness.preset, state.strokeStrictness.overrides);
},
},
actions: {
async login(apiKey) {
const res = await fetch(`${BASE_URL}/api/auth/login`, {
@@ -39,10 +56,16 @@ export const useAppStore = defineStore('app', {
if (data.user.settings) {
this.batchSize = data.user.settings.batchSize || 20;
this.drawingAccuracy = data.user.settings.drawingAccuracy || 10;
// Handle both old and new settings format from server
if (data.user.settings.strokeStrictness) {
this.strokeStrictness = data.user.settings.strokeStrictness;
} else if (data.user.settings.drawingAccuracy) {
this.strokeStrictness = migrateOldAccuracy(data.user.settings.drawingAccuracy);
}
localStorage.setItem('zen_batch_size', this.batchSize);
localStorage.setItem('zen_drawing_accuracy', this.drawingAccuracy);
localStorage.setItem('zen_stroke_strictness', JSON.stringify(this.strokeStrictness));
}
localStorage.setItem('zen_token', data.token);
@@ -72,6 +95,7 @@ export const useAppStore = defineStore('app', {
this.queue = [];
this.lessonQueue = [];
this.collection = [];
this.vocabularies = [];
this.stats = {
distribution: {},
forecast: [],
@@ -83,7 +107,8 @@ export const useAppStore = defineStore('app', {
};
localStorage.removeItem('zen_token');
localStorage.removeItem('zen_batch_size');
localStorage.removeItem('zen_drawing_accuracy');
localStorage.removeItem('zen_stroke_strictness');
localStorage.removeItem('zen_drawing_accuracy'); // clean up old key
},
getHeaders() {
@@ -161,6 +186,20 @@ export const useAppStore = defineStore('app', {
this.collection = await res.json();
},
async fetchVocabularies() {
if (!this.token) return;
const res = await fetch(`${BASE_URL}/api/vocabulary`, { headers: this.getHeaders() });
if (res.status === 401) {
await this.logout();
return;
}
const data = await res.json();
this.vocabularies = Array.isArray(data) ? data : [];
},
async submitReview(subjectId, success) {
const res = await fetch(`${BASE_URL}/api/review`, {
method: 'POST',
@@ -196,9 +235,9 @@ export const useAppStore = defineStore('app', {
this.batchSize = settings.batchSize;
localStorage.setItem('zen_batch_size', settings.batchSize);
}
if (settings.drawingAccuracy !== undefined) {
this.drawingAccuracy = settings.drawingAccuracy;
localStorage.setItem('zen_drawing_accuracy', settings.drawingAccuracy);
if (settings.strokeStrictness !== undefined) {
this.strokeStrictness = settings.strokeStrictness;
localStorage.setItem('zen_stroke_strictness', JSON.stringify(settings.strokeStrictness));
}
await fetch(`${BASE_URL}/api/settings`, {
+1
View File
@@ -19,6 +19,7 @@ html,
color: $color-text-white;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
.v-card {
+1
View File
@@ -1,4 +1,5 @@
@use 'dashboard';
@use 'collection';
@use 'vocabularies';
@use 'review';
@use 'lesson';
+322
View File
@@ -0,0 +1,322 @@
@use '../abstracts' as *;
// ── Page layout ──────────────────────────────────────────────
.vocab-page {
max-width: $max-width-desktop;
}
// ── Toolbar ──────────────────────────────────────────────────
.vocab-toolbar {
display: flex;
gap: $spacing-sm;
align-items: stretch;
position: sticky;
top: $spacing-sm;
z-index: $z-sticky;
margin-bottom: $spacing-md;
.vocab-search {
flex: 1;
box-shadow: $shadow-md;
border-radius: $radius-sm;
}
.vocab-filter-toggle {
height: auto !important;
min-width: 7rem;
border-radius: $radius-sm;
font-weight: $weight-bold;
font-size: $font-xs;
text-transform: none;
letter-spacing: $tracking-normal;
}
}
// ── Active filter chips bar ──────────────────────────────────
.vocab-active-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: $spacing-xs 0;
margin-bottom: $spacing-sm;
}
// ── Filter panel ─────────────────────────────────────────────
.vocab-filter-panel {
background: $bg-glass-dark;
border: $border-width-sm solid $color-border;
border-radius: $radius-lg;
padding: $spacing-md $spacing-lg;
margin-bottom: $spacing-lg;
.filter-section {
margin-bottom: $spacing-md;
&:last-of-type {
margin-bottom: $spacing-sm;
}
.filter-label {
font-size: $font-xs;
font-weight: $weight-bold;
color: $color-text-grey;
text-transform: uppercase;
letter-spacing: $tracking-wider;
margin-bottom: $spacing-xs;
}
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: $spacing-xs;
}
}
.filter-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: $spacing-sm;
border-top: $border-width-sm solid $color-border;
}
}
// ── Level header ─────────────────────────────────────────────
.vocab-level-header {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-md;
.vocab-level-badge {
font-size: $font-xs;
font-weight: $weight-bold;
color: $color-primary;
text-transform: uppercase;
letter-spacing: $tracking-wider;
white-space: nowrap;
padding: $spacing-2xs $spacing-sm;
background: rgba(0, 206, 201, 0.1);
border-radius: $radius-sm;
border: $border-width-sm solid rgba(0, 206, 201, 0.2);
}
.vocab-level-line {
flex: 1;
height: 1px;
background: $color-border;
}
.vocab-level-count {
font-size: $font-xs;
color: $color-text-grey;
white-space: nowrap;
}
}
// ── Grid ─────────────────────────────────────────────────────
.vocab-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: $spacing-sm;
@media (max-width: $breakpoint-sm) {
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
}
}
// ── Card ─────────────────────────────────────────────────────
.vocab-card {
@include card-base;
@include hover-lift;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: $spacing-md $spacing-sm $spacing-sm;
cursor: pointer;
min-height: 5.5rem;
overflow: hidden;
border: $border-width-sm solid transparent;
transition:
transform $duration-fast $ease-default,
background $duration-fast $ease-default,
box-shadow $duration-fast $ease-default,
border-color $duration-fast $ease-default;
&:hover {
border-color: rgba(0, 206, 201, 0.3);
}
.vc-top {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-2xs;
margin-bottom: $spacing-xs;
}
.vc-char {
font-size: $font-lg;
font-weight: $weight-bold;
color: $color-primary;
line-height: $leading-tight;
text-align: center;
word-break: break-all;
overflow-wrap: anywhere;
}
.vc-reading {
font-size: $font-xs;
color: $color-text-grey;
text-align: center;
opacity: 0.7;
}
.vc-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: $spacing-2xs;
}
.vc-meaning {
font-size: $font-xs;
color: $color-text-grey;
text-align: center;
line-height: $leading-normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.vc-tags {
display: flex;
gap: $spacing-2xs;
justify-content: center;
flex-wrap: wrap;
.vc-tag {
font-size: 0.5625rem;
font-weight: $weight-bold;
color: rgba(0, 206, 201, 0.7);
background: rgba(0, 206, 201, 0.08);
padding: 0 $spacing-xs;
border-radius: $radius-xs;
line-height: 1.4rem;
letter-spacing: $tracking-wide;
text-transform: uppercase;
}
}
}
// ── Detail modal ─────────────────────────────────────────────
.vocab-detail-char {
font-family: 'Noto Serif JP', serif;
letter-spacing: $tracking-wide;
color: $color-primary;
}
.vocab-readings-container {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
justify-content: center;
.vocab-reading-group {
display: flex;
align-items: center;
background: $bg-glass-dark;
padding: $spacing-xs $spacing-md;
border-radius: $radius-md;
.vocab-reading-value {
font-size: $font-md;
color: $color-text-grey;
&.is-primary {
color: $color-text-white;
font-weight: $weight-bold;
}
}
}
}
.vocab-mnemonic-section {
background: $bg-glass-dark;
padding: $spacing-md;
border-radius: $radius-md;
.mnemonic-text {
line-height: $leading-loose;
}
}
.vocab-components-section {
text-align: center;
}
.vocab-collapsible {
.vocab-collapse-toggle {
text-transform: none;
letter-spacing: $tracking-normal;
font-size: $font-sm;
border: $border-width-sm solid $color-border;
border-radius: $radius-md;
}
.vocab-collapse-content {
margin-top: $spacing-sm;
}
}
.vocab-audio-row {
display: flex;
justify-content: center;
gap: $spacing-sm;
}
.vocab-audio-btn {
transition: all $duration-fast $ease-default;
text-transform: none !important;
letter-spacing: $tracking-normal !important;
&:hover:not(:disabled) {
box-shadow: $shadow-glow-base;
}
}
.vocab-sentences-section {
.vocab-sentence {
background: $bg-glass-dark;
padding: $spacing-md;
border-radius: $radius-md;
margin-bottom: $spacing-sm;
&:last-child {
margin-bottom: 0;
}
.vocab-sentence-ja {
font-size: $font-md;
color: $color-text-white;
margin-bottom: $spacing-xs;
line-height: $leading-normal;
}
.vocab-sentence-en {
font-size: $font-sm;
color: $color-text-grey;
line-height: $leading-normal;
font-style: italic;
}
}
}
+26 -185
View File
@@ -1,4 +1,6 @@
import { SoundManager } from './SoundManager.js';
import { evaluateStroke } from './StrokeEvaluator.js';
import { createConfig } from './StrokeConfig.js';
export const KANJI_CONSTANTS = {
BASE_SIZE: 109,
@@ -12,10 +14,6 @@ export const KANJI_CONSTANTS = {
ANIMATION_DURATION: 300,
SAMPLE_POINTS: 60,
VALIDATION: {
SAMPLES: 20,
},
COLORS: {
USER: { r: 255, g: 118, b: 117 },
FINAL: { r: 0, g: 206, b: 201 },
@@ -27,10 +25,13 @@ export const KANJI_CONSTANTS = {
export class KanjiController {
constructor(options = {}) {
this.size = options.size || 300;
this.accuracy = options.accuracy || 10;
this.onComplete = options.onComplete || (() => {});
this.onMistake = options.onMistake || (() => {});
// Stroke evaluation config (from StrokeConfig presets)
this.config = options.config || createConfig('medium');
this.lastEvaluation = null;
this.scale = this.size / KANJI_CONSTANTS.BASE_SIZE;
this.paths = [];
this.currentStrokeIdx = 0;
@@ -101,8 +102,12 @@ export class KanjiController {
this.resize(this.size);
}
setAccuracy(val) {
this.accuracy = val;
setConfig(config) {
this.config = config;
}
getLastEvaluation() {
return this.lastEvaluation;
}
resize(newSize) {
@@ -306,12 +311,19 @@ export class KanjiController {
validateStroke() {
const targetD = this.paths[this.currentStrokeIdx];
const userNormalized = this.userPath.map((p) => ({
x: p.x / this.scale,
y: p.y / this.scale,
}));
const pathEl = KanjiController.createPathElement(targetD);
if (this.checkMatch(userNormalized, targetD)) {
const result = evaluateStroke(
this.userPath,
pathEl,
this.scale,
this.config,
{ isFirstStroke: this.currentStrokeIdx === 0 },
);
this.lastEvaluation = result;
if (result.pass) {
if (this.hintAnimationFrame) {
cancelAnimationFrame(this.hintAnimationFrame);
this.hintAnimationFrame = null;
@@ -345,179 +357,8 @@ export class KanjiController {
}
}
checkMatch(userPts, targetD) {
if (userPts.length < 3) return false;
const pathEl = KanjiController.createPathElement(targetD);
const len = pathEl.getTotalLength();
const { SAMPLES } = KANJI_CONSTANTS.VALIDATION;
const dist = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y);
// ── 1. Start / end point check ──────────────────
const targetStart = pathEl.getPointAtLength(0);
const targetEnd = pathEl.getPointAtLength(len);
const userStart = userPts[0];
const userEnd = userPts[userPts.length - 1];
const isFirstStroke = this.currentStrokeIdx === 0;
const startThreshold = this.accuracy * (isFirstStroke ? 3.5 : 2.5);
// End tolerance scales with stroke length — long strokes get more forgiveness
const lengthBonus = Math.min(len / 30, 1.5);
const endThreshold = this.accuracy * (2.5 + lengthBonus);
if (dist(userStart, targetStart) > startThreshold) return false;
if (dist(userEnd, targetEnd) > endThreshold) return false;
// ── 2. Overall direction check ──────────────────
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
const angleDiff = KanjiController.normalizeAngle(userAngle - targetAngle);
// Reject if direction is off by more than 90 degrees
if (angleDiff > Math.PI / 2) return false;
// ── 3. Orientation classification guard ─────────
const userOrientation = KanjiController.classifyOrientation(userStart, userEnd);
const targetOrientation = KanjiController.classifyOrientation(targetStart, targetEnd);
// Only block if both strokes are clearly H or V (not diagonal or dot)
if (userOrientation !== 'other' && targetOrientation !== 'other'
&& userOrientation !== targetOrientation) {
return false;
}
// ── 4. Sequential sample comparison ─────────────
const userResampled = KanjiController.resamplePoints(userPts, SAMPLES + 1);
let totalError = 0;
let maxError = 0;
for (let i = 0; i <= SAMPLES; i++) {
const t = i / SAMPLES;
const targetPt = pathEl.getPointAtLength(t * len);
const userPt = userResampled[i];
const d = dist(targetPt, userPt);
totalError += d;
if (d > maxError) maxError = d;
}
const avgError = totalError / (SAMPLES + 1);
const avgDistThreshold = this.accuracy * 1.0;
const maxDistThreshold = this.accuracy * 3.5;
if (avgError > avgDistThreshold) return false;
// No single point should be wildly off — catches "right average, wrong shape"
if (maxError > maxDistThreshold) return false;
// ── 4b. Stroke length ratio ─────────────────────
let userLen = 0;
for (let i = 1; i < userPts.length; i++) {
userLen += dist(userPts[i - 1], userPts[i]);
}
const lengthRatio = userLen / len;
// User stroke should be between 30% and 300% of target length
// Long straight strokes especially benefit from the upper bound
if (lengthRatio < 0.3 || lengthRatio > 3.0) return false;
// ── 4c. Curvature similarity ────────────────────
const targetCurvature = KanjiController.measureCurvature(
userResampled.map((_, i) => {
const t = i / SAMPLES;
const pt = pathEl.getPointAtLength(t * len);
return { x: pt.x, y: pt.y };
}),
);
const userCurvature = KanjiController.measureCurvature(userPts);
// If target is clearly curved but user drew straight (or vice versa)
const curvatureDiff = Math.abs(targetCurvature - userCurvature);
// Scale threshold: strict = 0.2, loose = 0.5
const curvatureThreshold = 0.15 + (this.accuracy / 20) * 0.35;
if (curvatureDiff > curvatureThreshold) return false;
// ── 5. Endpoint direction check (hook detection) ─
const tailFraction = 0.2;
const targetTailStart = pathEl.getPointAtLength(len * (1 - tailFraction));
const targetTailAngle = Math.atan2(
targetEnd.y - targetTailStart.y,
targetEnd.x - targetTailStart.x,
);
// Check if target has a significant direction change at the end (hook)
const mainTargetAngle = Math.atan2(
targetEnd.y - targetStart.y,
targetEnd.x - targetStart.x,
);
const targetHookAngle = KanjiController.normalizeAngle(targetTailAngle - mainTargetAngle);
const hasHook = targetHookAngle > Math.PI / 6; // > 30 degrees = hook
if (hasHook) {
// User must also have a direction change in the last portion
const tailIdx = Math.max(0, Math.floor(userPts.length * (1 - tailFraction)));
const userTailStart = userPts[tailIdx];
const userTailAngle = Math.atan2(
userEnd.y - userTailStart.y,
userEnd.x - userTailStart.x,
);
const mainUserAngle = Math.atan2(
userEnd.y - userStart.y,
userEnd.x - userStart.x,
);
const userHookAngle = KanjiController.normalizeAngle(userTailAngle - mainUserAngle);
// User's hook should be at least half as pronounced as the target's
// but use a generous threshold scaled by accuracy
const hookThreshold = Math.max(Math.PI / 12, targetHookAngle * 0.4);
if (userHookAngle < hookThreshold) {
// Only reject on strict accuracy (< 12), forgive on loose settings
if (this.accuracy < 12) return false;
}
}
return true;
}
// ── Static helpers for stroke validation ────────────
static normalizeAngle(angle) {
let a = Math.abs(angle);
if (a > Math.PI) a = 2 * Math.PI - a;
return a;
}
static classifyOrientation(start, end) {
const dx = Math.abs(end.x - start.x);
const dy = Math.abs(end.y - start.y);
const totalDist = Math.hypot(dx, dy);
// Very short strokes (dots) — don't classify
if (totalDist < 5) return 'other';
const ratio = dx / (dy || 0.001);
if (ratio > 2.5) return 'horizontal';
if (ratio < 0.4) return 'vertical';
return 'other'; // diagonal or ambiguous
}
static measureCurvature(points) {
if (points.length < 3) return 0;
// Curvature = ratio of actual path length to straight-line distance
// A perfectly straight line = 0, a semicircle ≈ 0.57
let pathLen = 0;
for (let i = 1; i < points.length; i++) {
pathLen += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
}
const straightDist = Math.hypot(
points[points.length - 1].x - points[0].x,
points[points.length - 1].y - points[0].y,
);
if (straightDist < 1) return 0; // dot-like stroke
// Returns 0 for straight, >0 for curved (ratio - 1)
return Math.max(0, (pathLen / straightDist) - 1);
}
// Old checkMatch, normalizeAngle, classifyOrientation, measureCurvature
// have been replaced by StrokeEvaluator.evaluateStroke().
animateErrorFade(userPath, onComplete) {
this.isAnimating = true;
+90
View File
@@ -0,0 +1,90 @@
/**
* StrokeConfig.js
*
* Strictness configuration for the stroke evaluation system.
* Provides preset difficulty levels and a factory to build
* resolved configs with optional per-facet overrides.
*
* Each threshold is a minimum score (0-1) that the corresponding
* evaluation facet must exceed for a stroke to pass.
*/
// ── Presets ──────────────────────────────────────────────────
export const PRESETS = {
easy: {
proximityThreshold: 0.25,
directionThreshold: 0.35,
shapeThreshold: 0.35,
lengthThreshold: 0.25,
curvatureThreshold: 0.20,
sampleCount: 32,
dtwBandWidth: 0.35,
firstStrokeLeniency: 1.5,
},
medium: {
proximityThreshold: 0.40,
directionThreshold: 0.50,
shapeThreshold: 0.50,
lengthThreshold: 0.40,
curvatureThreshold: 0.35,
sampleCount: 32,
dtwBandWidth: 0.30,
firstStrokeLeniency: 1.35,
},
hard: {
proximityThreshold: 0.55,
directionThreshold: 0.65,
shapeThreshold: 0.65,
lengthThreshold: 0.55,
curvatureThreshold: 0.50,
sampleCount: 48,
dtwBandWidth: 0.25,
firstStrokeLeniency: 1.2,
},
expert: {
proximityThreshold: 0.70,
directionThreshold: 0.80,
shapeThreshold: 0.78,
lengthThreshold: 0.65,
curvatureThreshold: 0.65,
sampleCount: 48,
dtwBandWidth: 0.20,
firstStrokeLeniency: 1.1,
},
};
// ── Factory ─────────────────────────────────────────────────
export function createConfig(preset = 'medium', overrides = {}) {
const base = PRESETS[preset] || PRESETS.medium;
return Object.freeze({ ...base, ...overrides });
}
// ── Slider helpers ──────────────────────────────────────────
export function sliderToThreshold(v) {
return Math.max(0, Math.min(1, v / 100));
}
export function thresholdToSlider(t) {
return Math.round(Math.max(0, Math.min(1, t)) * 100);
}
// ── Migration from old accuracy (1-20) ─────────────────────
export function migrateOldAccuracy(accuracy) {
const val = Math.max(1, Math.min(20, accuracy));
let preset;
if (val <= 5) preset = 'expert';
else if (val <= 10) preset = 'hard';
else if (val <= 15) preset = 'medium';
else preset = 'easy';
return { preset, overrides: {} };
}
export const PRESET_NAMES = ['easy', 'medium', 'hard', 'expert'];
export const FACET_KEYS = [
'proximityThreshold',
'directionThreshold',
'shapeThreshold',
'lengthThreshold',
'curvatureThreshold',
];
+432
View File
@@ -0,0 +1,432 @@
/**
* StrokeEvaluator.js
*
* Pure-function module for evaluating user-drawn strokes against
* SVG reference paths. No DOM, Canvas, or Vue dependencies — only
* math and array operations (except where an SVG path element is
* passed in for sampling).
*
* Core algorithm: Dynamic Time Warping (DTW) with Sakoe-Chiba band.
*
* Five evaluation facets:
* 1. Start/End Proximity
* 2. Directionality (angle match + reversal detection)
* 3. Shape Similarity (DTW)
* 4. Length Ratio
* 5. Curvature (sinuosity) Match
*/
// ── Helpers ─────────────────────────────────────────────────
/** Euclidean distance between two {x, y} points. */
function dist(a, b) {
return Math.hypot(a.x - b.x, a.y - b.y);
}
/**
* Normalize an angle difference to [0, PI].
* @param {number} angle - raw difference in radians
* @returns {number} absolute difference clamped to [0, PI]
*/
function normalizeAngle(angle) {
let a = Math.abs(angle);
if (a > Math.PI) a = 2 * Math.PI - a;
return a;
}
/**
* Total arc-length of a polyline [{x,y}, ...].
*/
function polylineLength(pts) {
let len = 0;
for (let i = 1; i < pts.length; i++) {
len += dist(pts[i - 1], pts[i]);
}
return len;
}
// ── Resampling ──────────────────────────────────────────────
/**
* Resample a polyline to `count` equidistant points along its arc.
*
* @param {Array<{x:number, y:number}>} points
* @param {number} count - desired number of output points
* @returns {Array<{x:number, y:number}>}
*/
export function resamplePath(points, count) {
if (!points || points.length === 0) return [];
if (points.length === 1 || count <= 1) return [{ ...points[0] }];
// Build cumulative distance array
let totalLen = 0;
const cumDist = [0];
for (let i = 1; i < points.length; i++) {
totalLen += dist(points[i - 1], points[i]);
cumDist.push(totalLen);
}
if (totalLen === 0) return Array.from({ length: count }, () => ({ ...points[0] }));
const step = totalLen / (count - 1);
const result = [];
for (let i = 0; i < count; i++) {
const targetDist = i * step;
// Binary search for the segment containing targetDist
let lo = 0;
let hi = cumDist.length - 1;
while (lo < hi - 1) {
const mid = (lo + hi) >> 1;
if (cumDist[mid] <= targetDist) lo = mid;
else hi = mid;
}
const segStart = lo;
const segLen = cumDist[segStart + 1] - cumDist[segStart];
const t = segLen === 0 ? 0 : (targetDist - cumDist[segStart]) / segLen;
const p1 = points[segStart];
const p2 = points[Math.min(segStart + 1, points.length - 1)];
result.push({
x: p1.x + (p2.x - p1.x) * t,
y: p1.y + (p2.y - p1.y) * t,
});
}
return result;
}
// ── Dynamic Time Warping ────────────────────────────────────
/**
* Compute DTW distance between two sequences of {x, y} points
* using a Sakoe-Chiba band constraint.
*
* Returns the total accumulated cost normalized by (N + M),
* producing a length-independent distance measure.
*
* @param {Array<{x:number, y:number}>} seq1
* @param {Array<{x:number, y:number}>} seq2
* @param {number} bandFraction - band width as fraction of max(N, M), default 0.3
* @returns {number} normalized DTW distance
*/
export function computeDTW(seq1, seq2, bandFraction = 0.3) {
const n = seq1.length;
const m = seq2.length;
if (n === 0 || m === 0) return Infinity;
const w = Math.max(1, Math.floor(Math.max(n, m) * bandFraction));
// Use two-row optimization to save memory (O(m) instead of O(n*m))
let prev = new Float64Array(m).fill(Infinity);
let curr = new Float64Array(m).fill(Infinity);
prev[0] = dist(seq1[0], seq2[0]);
// Fill first row within band
for (let j = 1; j < Math.min(w + 1, m); j++) {
prev[j] = prev[j - 1] + dist(seq1[0], seq2[j]);
}
for (let i = 1; i < n; i++) {
curr.fill(Infinity);
const jMin = Math.max(0, i - w);
const jMax = Math.min(m - 1, i + w);
for (let j = jMin; j <= jMax; j++) {
const cost = dist(seq1[i], seq2[j]);
const top = prev[j]; // (i-1, j)
const left = j > 0 ? curr[j - 1] : Infinity; // (i, j-1)
const diag = (j > 0) ? prev[j - 1] : Infinity; // (i-1, j-1)
curr[j] = cost + Math.min(top, left, diag);
}
// Swap rows
[prev, curr] = [curr, prev];
}
// Result is in prev after last swap
return prev[m - 1] / (n + m);
}
// ── Facet 1: Proximity ──────────────────────────────────────
/**
* Evaluate how close the user's start/end points are to the target's.
*
* Score is the average of start-proximity and end-proximity,
* each mapped through a smooth decay: score = exp(-d / radius).
*
* @param {{x:number,y:number}} userStart
* @param {{x:number,y:number}} userEnd
* @param {{x:number,y:number}} targetStart
* @param {{x:number,y:number}} targetEnd
* @param {number} canvasSize - size of the drawing area (for normalization)
* @returns {number} 0-1 score (1 = perfect alignment)
*/
export function evaluateProximity(userStart, userEnd, targetStart, targetEnd, canvasSize) {
// Radius = 15% of canvas size — distances beyond this decay rapidly
const radius = canvasSize * 0.15;
const startDist = dist(userStart, targetStart);
const endDist = dist(userEnd, targetEnd);
const startScore = Math.exp(-startDist / radius);
const endScore = Math.exp(-endDist / radius);
return (startScore + endScore) / 2;
}
// ── Facet 2: Directionality ─────────────────────────────────
/**
* Evaluate the directional match between user and target strokes.
*
* Uses the angle between start→end vectors. Also detects
* reversal (stroke drawn backwards) and orientation mismatch
* (horizontal vs vertical).
*
* @param {{x:number,y:number}} userStart
* @param {{x:number,y:number}} userEnd
* @param {{x:number,y:number}} targetStart
* @param {{x:number,y:number}} targetEnd
* @returns {{ score: number, reversed: boolean }}
*/
export function evaluateDirection(userStart, userEnd, targetStart, targetEnd) {
const userAngle = Math.atan2(userEnd.y - userStart.y, userEnd.x - userStart.x);
const targetAngle = Math.atan2(targetEnd.y - targetStart.y, targetEnd.x - targetStart.x);
const angleDiff = normalizeAngle(userAngle - targetAngle);
// Reversed if angle diff > 120 degrees
const reversed = angleDiff > (2 * Math.PI / 3);
// Score: 1.0 at 0 deg, 0.0 at 180 deg (linear mapping)
const score = Math.max(0, 1.0 - angleDiff / Math.PI);
return { score, reversed };
}
// ── Facet 3: Shape Similarity (DTW) ─────────────────────────
/**
* Evaluate shape similarity using DTW on resampled, translation-
* and scale-normalized paths.
*
* Both paths are centered at origin and scaled to unit diagonal
* before comparison, so DTW only measures *shape* — not position
* or size (those are handled by other facets).
*
* @param {Array<{x:number,y:number}>} userResampled
* @param {Array<{x:number,y:number}>} targetResampled
* @param {number} bandFraction
* @returns {number} 0-1 score (1 = identical shape)
*/
export function evaluateShape(userResampled, targetResampled, bandFraction) {
const normalizeSeq = (pts) => {
let minX = Infinity; let maxX = -Infinity;
let minY = Infinity; let maxY = -Infinity;
for (const p of pts) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const diag = Math.hypot(maxX - minX, maxY - minY) || 1;
return pts.map((p) => ({ x: (p.x - cx) / diag, y: (p.y - cy) / diag }));
};
const normUser = normalizeSeq(userResampled);
const normTarget = normalizeSeq(targetResampled);
const dtwDist = computeDTW(normUser, normTarget, bandFraction);
// Convert distance to 0-1 score using a tuned sigmoid
// sensitivity controls how fast score drops with distance
const sensitivity = 0.06;
return 1.0 / (1.0 + dtwDist / sensitivity);
}
// ── Facet 4: Length Ratio ───────────────────────────────────
/**
* Evaluate how well the user's stroke length matches the target.
*
* Uses a ratio-based score that peaks at 1.0 when lengths are equal
* and decays symmetrically for too-short or too-long strokes.
*
* @param {number} userLen - arc-length of user stroke
* @param {number} targetLen - arc-length of target stroke
* @returns {number} 0-1 score
*/
export function evaluateLength(userLen, targetLen) {
if (targetLen === 0) return userLen === 0 ? 1 : 0;
if (userLen === 0) return 0;
const ratio = userLen / targetLen;
// Score = 1 at ratio=1, drops off for ratios far from 1
// Using: 1 - |ln(ratio)| / ln(K), clamped to [0, 1]
// K=4 means ratio of 4x or 0.25x gives score=0
const K = 4;
const score = 1 - Math.abs(Math.log(ratio)) / Math.log(K);
return Math.max(0, Math.min(1, score));
}
// ── Facet 5: Curvature ──────────────────────────────────────
/**
* Measure sinuosity of a polyline: ratio of arc-length to
* straight-line distance minus 1. Returns 0 for straight lines,
* larger values for curvier paths.
*/
function measureSinuosity(pts) {
if (pts.length < 3) return 0;
const arcLen = polylineLength(pts);
const chord = dist(pts[0], pts[pts.length - 1]);
if (chord < 1) return 0; // effectively a dot
return Math.max(0, (arcLen / chord) - 1);
}
/**
* Evaluate curvature similarity between user and target strokes.
*
* Compares sinuosity values — if the target is curved and the
* user drew straight (or vice versa), the score drops.
*
* @param {Array<{x:number,y:number}>} userPts
* @param {Array<{x:number,y:number}>} targetPts
* @returns {number} 0-1 score
*/
export function evaluateCurvature(userPts, targetPts) {
const userSin = measureSinuosity(userPts);
const targetSin = measureSinuosity(targetPts);
const diff = Math.abs(userSin - targetSin);
// Tolerance: curvature differences below 0.05 are perfect,
// differences above 0.6 are completely wrong
const tolerance = 0.6;
const score = 1 - Math.min(diff / tolerance, 1);
return Math.max(0, score);
}
// ── Master Evaluation ───────────────────────────────────────
/**
* Run all 5 evaluation facets on a user stroke and return a
* detailed result.
*
* @param {Array<{x:number,y:number}>} userPoints - raw user stroke (canvas coords)
* @param {SVGPathElement} targetPathEl - SVG path element for the target stroke
* @param {number} scale - canvas-to-SVG scale factor
* @param {Object} config - resolved StrokeConfig
* @param {{ isFirstStroke: boolean }} options
* @returns {{
* pass: boolean,
* scores: { proximity: number, direction: number, shape: number, length: number, curvature: number },
* thresholds: { proximity: number, direction: number, shape: number, length: number, curvature: number },
* details: { reversed: boolean }
* }}
*/
export function evaluateStroke(userPoints, targetPathEl, scale, config, options = {}) {
const { isFirstStroke = false } = options;
// Minimum points to be considered a real stroke
if (userPoints.length < 3) {
return {
pass: false,
scores: {
proximity: 0, direction: 0, shape: 0, length: 0, curvature: 0,
},
thresholds: _getThresholds(config, isFirstStroke),
details: { reversed: false, reason: 'too_few_points' },
};
}
// ── Normalize user points to SVG coordinate space ──────
const userNorm = userPoints.map((p) => ({ x: p.x / scale, y: p.y / scale }));
// ── Extract target geometry ────────────────────────────
const targetLen = targetPathEl.getTotalLength();
const targetStart = targetPathEl.getPointAtLength(0);
const targetEnd = targetPathEl.getPointAtLength(targetLen);
const userStart = userNorm[0];
const userEnd = userNorm[userNorm.length - 1];
// SVG viewBox is 109x109, use that as the "canvas size" for normalization
const svgSize = 109;
// ── 1. Proximity ───────────────────────────────────────
const proximityScore = evaluateProximity(
userStart, userEnd, targetStart, targetEnd, svgSize,
);
// ── 2. Direction ───────────────────────────────────────
const { score: directionScore, reversed } = evaluateDirection(
userStart, userEnd, targetStart, targetEnd,
);
// ── 3. Shape (DTW) ────────────────────────────────────
const sampleCount = config.sampleCount || 32;
const userResampled = resamplePath(userNorm, sampleCount);
const targetResampled = [];
for (let i = 0; i < sampleCount; i++) {
const pt = targetPathEl.getPointAtLength((i / (sampleCount - 1)) * targetLen);
targetResampled.push({ x: pt.x, y: pt.y });
}
const shapeScore = evaluateShape(
userResampled, targetResampled, config.dtwBandWidth || 0.3,
);
// ── 4. Length ──────────────────────────────────────────
const userLen = polylineLength(userNorm);
const lengthScore = evaluateLength(userLen, targetLen);
// ── 5. Curvature ───────────────────────────────────────
const curvatureScore = evaluateCurvature(userResampled, targetResampled);
// ── Thresholds (adjusted for first stroke if applicable) ──
const thresholds = _getThresholds(config, isFirstStroke);
// ── Pass/fail determination ────────────────────────────
const pass = proximityScore >= thresholds.proximity
&& directionScore >= thresholds.direction
&& shapeScore >= thresholds.shape
&& lengthScore >= thresholds.length
&& curvatureScore >= thresholds.curvature;
return {
pass,
scores: {
proximity: proximityScore,
direction: directionScore,
shape: shapeScore,
length: lengthScore,
curvature: curvatureScore,
},
thresholds,
details: { reversed },
};
}
/**
* Build the threshold object from config, applying first-stroke
* leniency to the proximity threshold if applicable.
*/
function _getThresholds(config, isFirstStroke) {
const leniency = isFirstStroke ? (config.firstStrokeLeniency || 1) : 1;
return {
proximity: Math.max(0, config.proximityThreshold / leniency),
direction: config.directionThreshold,
shape: config.shapeThreshold,
length: config.lengthThreshold,
curvature: config.curvatureThreshold,
};
}
+6 -1
View File
@@ -157,13 +157,14 @@
/* eslint-disable no-unused-vars */
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
import KanjiSvgViewer from '@/components/kanji/KanjiSvgViewer.vue';
const { t } = useI18n();
const store = useAppStore();
const router = useRouter();
const route = useRoute();
const loading = ref(true);
const showModal = ref(false);
@@ -184,6 +185,10 @@ const accuracyStats = computed(() => {
onMounted(async () => {
await store.fetchCollection();
// Pre-fill search from query param (e.g. navigated from Vocabularies component kanji)
if (route.query.search) {
searchQuery.value = route.query.search;
}
loading.value = false;
});
+24 -4
View File
@@ -61,7 +61,7 @@
elevation="8"
)
.mb-4.d-flex.gap-2
.mb-2.d-flex.gap-2
v-btn.text-caption.font-weight-bold.opacity-80(
variant="text"
color="amber-lighten-1"
@@ -74,7 +74,7 @@
v-sheet.d-flex.flex-column.align-center.justify-center(
width="100%"
min-height="80"
min-height="56"
color="transparent"
)
transition(name="fade-slide" mode="out-in")
@@ -85,7 +85,7 @@
ScrambleText(:text="$t('review.' + statusCode)")
transition(name="scale")
v-chip.mt-2.font-weight-bold.elevation-2(
v-chip.mt-1.font-weight-bold.elevation-2(
v-if="showNext && rankChange"
:color="rankChange.type === 'up' ? 'teal-accent-4' : 'red-darken-1'"
variant="flat"
@@ -94,7 +94,7 @@
v-icon(start size="14") {{ rankChange.type === 'up' ? 'mdi-arrow-up-thick' : 'mdi-arrow-down-thick' }}
| Level {{ currentItem.srsLevel }} &rarr; {{ rankChange.level }}
v-progress-linear.mt-4.progress-bar(
v-progress-linear.mt-2.progress-bar(
v-model="progressPercent"
color="primary"
height="4"
@@ -402,4 +402,24 @@ const getStatusClass = (status) => {
font-size: $font-sm;
margin-left: $spacing-sm;
}
// Landscape optimizations (iPad, tablets)
@media (orientation: landscape) and (max-height: 900px) {
.review-view-container {
padding-top: $spacing-xs !important;
padding-bottom: $spacing-xs !important;
}
.review-card {
padding: $spacing-md !important;
.text-h3 {
font-size: $font-xl !important;
}
.mb-6 {
margin-bottom: $spacing-sm !important;
}
}
}
</style>
+622
View File
@@ -0,0 +1,622 @@
<template lang="pug">
v-container.vocab-page
//- Search + Filter toolbar
.vocab-toolbar
v-text-field.vocab-search(
v-model="searchQuery"
prepend-inner-icon="mdi-magnify"
:label="$t('vocabulary.searchLabel')"
:placeholder="$t('vocabulary.placeholder')"
variant="solo-filled"
density="comfortable"
bg-color="#2f3542"
color="white"
hide-details
clearable
)
v-btn.vocab-filter-toggle(
:variant="showFilters ? 'flat' : 'tonal'"
:color="hasActiveFilters ? '#00cec9' : 'grey'"
@click="showFilters = !showFilters"
)
v-icon(start) mdi-filter-variant
| {{ $t('vocabulary.filters') }}
v-badge.ml-2(
v-if="activeFilterCount > 0"
:content="activeFilterCount"
color="#00cec9"
inline
)
//- Active filter chips (always visible when filters active)
.vocab-active-filters(v-if="activeFilterLabels.length > 0 && !showFilters")
v-chip.mr-1.mb-1(
v-for="f in activeFilterLabels"
:key="f.key + f.value"
size="small"
color="#00cec9"
variant="tonal"
closable
@click:close="removeFilter(f.key, f.value)"
) {{ f.label }}
v-btn.ml-1(
size="x-small"
variant="text"
color="grey"
@click="clearAllFilters"
) {{ $t('vocabulary.clearAll') }}
//- Filter panel
v-expand-transition
.vocab-filter-panel(v-show="showFilters")
.filter-section
.filter-label {{ $t('vocabulary.filterType') }}
.filter-chips
v-chip(
v-for="t in TYPE_OPTIONS"
:key="t.value"
size="small"
:variant="filterTypes.includes(t.value) ? 'flat' : 'outlined'"
:color="filterTypes.includes(t.value) ? '#00cec9' : 'grey'"
@click="toggleFilter('types', t.value)"
) {{ t.label }}
.filter-section
.filter-label {{ $t('vocabulary.filterVerbClass') }}
.filter-chips
v-chip(
v-for="t in VERB_CLASS_OPTIONS"
:key="t.value"
size="small"
:variant="filterVerbClass.includes(t.value) ? 'flat' : 'outlined'"
:color="filterVerbClass.includes(t.value) ? '#00cec9' : 'grey'"
@click="toggleFilter('verbClass', t.value)"
) {{ t.label }}
.filter-section
.filter-label {{ $t('vocabulary.filterTransitivity') }}
.filter-chips
v-chip(
v-for="t in TRANSITIVITY_OPTIONS"
:key="t.value"
size="small"
:variant="filterTransitivity.includes(t.value) ? 'flat' : 'outlined'"
:color="filterTransitivity.includes(t.value) ? '#00cec9' : 'grey'"
@click="toggleFilter('transitivity', t.value)"
) {{ t.label }}
.filter-section
.filter-label {{ $t('vocabulary.filterEnding') }}
.filter-chips
v-chip(
v-for="t in ENDING_OPTIONS"
:key="t.value"
size="small"
:variant="filterEndings.includes(t.value) ? 'flat' : 'outlined'"
:color="filterEndings.includes(t.value) ? '#00cec9' : 'grey'"
@click="toggleFilter('endings', t.value)"
) {{ t.label }}
.filter-section
.filter-label {{ $t('vocabulary.filterLength') }}
.filter-chips
v-chip(
v-for="t in LENGTH_OPTIONS"
:key="t.value"
size="small"
:variant="filterLength.includes(t.value) ? 'flat' : 'outlined'"
:color="filterLength.includes(t.value) ? '#00cec9' : 'grey'"
@click="toggleFilter('length', t.value)"
) {{ t.label }}
.filter-actions
.text-caption.text-grey {{ $t('vocabulary.matchCount', { n: filteredVocabularies.length }) }}
v-btn(
size="small"
variant="text"
color="grey"
@click="clearAllFilters"
) {{ $t('vocabulary.clearAll') }}
//- Loading
.text-center.mt-10(v-if="loading")
v-progress-circular(indeterminate color="primary" size="48")
.text-body-2.text-grey.mt-4
ScrambleText(:text="$t('vocabulary.loading')")
//- Empty state
.text-center.mt-10.text-grey-lighten-1(
v-else-if="!loading && Object.keys(groupedItems).length === 0"
)
v-icon.mb-4(size="64" color="grey-darken-2") mdi-text-search-variant
.text-h6
ScrambleText(:text="$t('vocabulary.noMatches')")
.text-body-2
ScrambleText(:text="$t('vocabulary.tryDifferent')")
//- Vocabulary grid
.mb-8.fade-slide-up(
v-else
v-for="(group, level) in groupedItems"
:key="level"
)
.vocab-level-header
.vocab-level-badge {{ $t('vocabulary.levelHeader') }} {{ level }}
.vocab-level-line
.vocab-level-count {{ group.length }}
.vocab-grid
.vocab-card(
v-for="item in group"
:key="item._id"
@click="openDetail(item)"
)
.vc-top
.vc-char {{ item.characters }}
.vc-reading {{ getPrimaryReading(item) }}
.vc-bottom
.vc-meaning {{ item.meanings[0] }}
//- Detail modal
v-dialog(
v-model="showModal"
max-width="480"
transition="dialog-bottom-transition"
)
v-card.pa-5.pt-6.rounded-xl.border-subtle.vocab-detail-card(color="#1e1e24")
.d-flex.justify-space-between.align-center.px-2.mb-2
.text-caption.text-grey
ScrambleText(:text="$t('vocabulary.levelLabel') + ' ' + selectedItem?.level")
.d-flex.flex-wrap.gap-2
v-chip(
v-for="pos in selectedItem?.partsOfSpeech"
:key="pos"
size="x-small"
color="#00cec9"
variant="tonal"
) {{ formatPOS(pos) }}
.text-h3.font-weight-bold.text-center.mb-2.vocab-detail-char {{ selectedItem?.characters }}
.d-flex.flex-wrap.gap-2.justify-center.mb-4
v-chip(
v-for="(m, idx) in selectedItem?.meanings"
:key="idx"
:color="idx === 0 ? '#00cec9' : 'grey'"
:variant="idx === 0 ? 'flat' : 'outlined'"
size="small"
) {{ m }}
.vocab-readings-container.mb-4
.vocab-reading-group(v-for="r in selectedItem?.readings" :key="r.reading")
.vocab-reading-value(:class="{ 'is-primary': r.primary }") {{ r.reading }}
v-chip.ml-2(
v-if="r.primary"
size="x-small"
color="#00cec9"
variant="tonal"
)
ScrambleText(:text="$t('vocabulary.primary')")
.vocab-audio-row.mb-5
v-btn.vocab-audio-btn(
variant="tonal"
:color="hasMaleAudio ? '#00cec9' : 'grey-darken-2'"
:loading="audioPlayingGender === 'male'"
:disabled="!hasMaleAudio"
size="small"
prepend-icon="mdi-account"
@click="playAudio('male')"
)
| {{ $t('vocabulary.listenMale') }}
v-btn.vocab-audio-btn(
variant="tonal"
:color="hasFemaleAudio ? '#00cec9' : 'grey-darken-2'"
:loading="audioPlayingGender === 'female'"
:disabled="!hasFemaleAudio"
size="small"
prepend-icon="mdi-account-outline"
@click="playAudio('female')"
)
| {{ $t('vocabulary.listenFemale') }}
.vocab-sentences-section.mb-5(v-if="selectedItem?.contextSentences?.length")
.text-caption.text-grey-darken-1.text-uppercase.mb-3.font-weight-bold
ScrambleText(:text="$t('vocabulary.exampleSentences')")
.vocab-sentence(v-for="(s, idx) in selectedItem.contextSentences" :key="idx")
.vocab-sentence-ja {{ s.ja }}
.vocab-sentence-en {{ s.en }}
//- Collapsible mnemonics
.vocab-collapsible.mb-5(
v-if="selectedItem?.meaningMnemonic || selectedItem?.readingMnemonic"
)
v-btn.vocab-collapse-toggle(
variant="text"
size="small"
color="grey"
block
:prepend-icon="showMnemonics ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click="showMnemonics = !showMnemonics"
)
ScrambleText(:text="$t('vocabulary.mnemonics')")
v-expand-transition
.vocab-collapse-content(v-show="showMnemonics")
.vocab-mnemonic-section.mb-3(v-if="selectedItem?.meaningMnemonic")
.text-caption.text-grey-darken-1.text-uppercase.mb-2.font-weight-bold
ScrambleText(:text="$t('vocabulary.meaningMnemonic')")
.text-body-2.text-grey-lighten-1.mnemonic-text {{ selectedItem.meaningMnemonic }}
.vocab-mnemonic-section(v-if="selectedItem?.readingMnemonic")
.text-caption.text-grey-darken-1.text-uppercase.mb-2.font-weight-bold
ScrambleText(:text="$t('vocabulary.readingMnemonic')")
.text-body-2.text-grey-lighten-1.mnemonic-text {{ selectedItem.readingMnemonic }}
.vocab-components-section.mb-5(v-if="componentKanji.length > 0")
.text-caption.text-grey-darken-1.text-uppercase.mb-2.font-weight-bold
ScrambleText(:text="$t('vocabulary.components')")
.d-flex.flex-wrap.gap-2.justify-center
v-chip(
v-for="k in componentKanji"
:key="k._id || k.wkSubjectId"
variant="outlined"
color="grey-lighten-1"
@click.stop="navigateToKanji(k)"
)
span.font-weight-bold.mr-1 {{ k.char }}
span.text-caption.text-grey {{ k.meaning }}
v-btn.text-white.mt-2(
block
color="#2f3542"
@click="showModal = false"
)
ScrambleText(:text="$t('vocabulary.close')")
</template>
<script setup>
/* eslint-disable no-unused-vars */
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useAppStore } from '@/stores/appStore';
const { t } = useI18n();
const router = useRouter();
const store = useAppStore();
const loading = ref(true);
const showModal = ref(false);
const selectedItem = ref(null);
const searchQuery = ref('');
const audioPlayingGender = ref(null);
const showFilters = ref(false);
const showMnemonics = ref(false);
let currentAudio = null;
// ── Filter definitions ──────────────────────────────────────
const TYPE_OPTIONS = [
{ value: 'noun', label: 'Noun' },
{ value: 'verb', label: 'Verb' },
{ value: 'i_adjective', label: 'い Adj.' },
{ value: 'na_adjective', label: 'な Adj.' },
{ value: 'adverb', label: 'Adverb' },
{ value: 'numeral', label: 'Numeral' },
{ value: 'counter', label: 'Counter' },
{ value: 'suffix', label: 'Suffix' },
{ value: 'prefix', label: 'Prefix' },
{ value: 'expression', label: 'Expression' },
];
const VERB_CLASS_OPTIONS = [
{ value: 'godan_verb', label: 'Godan (五段)' },
{ value: 'ichidan_verb', label: 'Ichidan (一段)' },
{ value: 'suru_verb', label: 'Suru (する)' },
];
const TRANSITIVITY_OPTIONS = [
{ value: 'transitive_verb', label: 'Transitive' },
{ value: 'intransitive_verb', label: 'Intransitive' },
];
const ENDING_OPTIONS = [
{ value: 'eru', label: '〜える' },
{ value: 'iru', label: '〜いる' },
{ value: 'aru', label: '〜ある' },
{ value: 'uru', label: '〜うる' },
{ value: 'oru', label: '〜おる' },
{ value: 'su', label: '〜す' },
{ value: 'ku', label: '〜く' },
{ value: 'gu', label: '〜ぐ' },
{ value: 'mu', label: '〜む' },
{ value: 'tsu', label: '〜つ' },
{ value: 'nu', label: '〜ぬ' },
{ value: 'bu', label: '〜ぶ' },
];
const LENGTH_OPTIONS = [
{ value: '1', label: '1 Char' },
{ value: '2', label: '2 Char' },
{ value: '3', label: '3 Char' },
{ value: '4+', label: '4+ Char' },
];
// Use individual refs for reliable Vue reactivity tracking
const filterTypes = ref([]);
const filterVerbClass = ref([]);
const filterTransitivity = ref([]);
const filterEndings = ref([]);
const filterLength = ref([]);
const filterRefs = {
types: filterTypes,
verbClass: filterVerbClass,
transitivity: filterTransitivity,
endings: filterEndings,
length: filterLength,
};
const ENDING_MAP = {
eru: ['える', 'エル'],
iru: ['いる', 'イル'],
aru: ['ある', 'アル'],
uru: ['うる', 'ウル'],
oru: ['おる', 'オル'],
su: ['す', 'ス'],
ku: ['く', 'ク'],
gu: ['ぐ', 'グ'],
mu: ['む', 'ム'],
tsu: ['つ', 'ツ'],
nu: ['ぬ', 'ヌ'],
bu: ['ぶ', 'ブ'],
};
// ── Filter actions ──────────────────────────────────────────
const toggleFilter = (key, value) => {
const r = filterRefs[key];
const idx = r.value.indexOf(value);
if (idx >= 0) {
r.value = r.value.filter((v) => v !== value);
} else {
r.value = [...r.value, value];
}
};
const removeFilter = (key, value) => {
const r = filterRefs[key];
r.value = r.value.filter((v) => v !== value);
};
const clearAllFilters = () => {
filterTypes.value = [];
filterVerbClass.value = [];
filterTransitivity.value = [];
filterEndings.value = [];
filterLength.value = [];
};
// ── Filter computeds ────────────────────────────────────────
const activeFilterCount = computed(() => (
filterTypes.value.length
+ filterVerbClass.value.length
+ filterTransitivity.value.length
+ filterEndings.value.length
+ filterLength.value.length
));
const hasActiveFilters = computed(() => activeFilterCount.value > 0);
const activeFilterLabels = computed(() => {
const labels = [];
const addLabels = (key, arr, options) => {
arr.forEach((v) => {
const opt = options.find((o) => o.value === v);
if (opt) labels.push({ key, value: v, label: opt.label });
});
};
addLabels('types', filterTypes.value, TYPE_OPTIONS);
addLabels('verbClass', filterVerbClass.value, VERB_CLASS_OPTIONS);
addLabels('transitivity', filterTransitivity.value, TRANSITIVITY_OPTIONS);
addLabels('endings', filterEndings.value, ENDING_OPTIONS);
addLabels('length', filterLength.value, LENGTH_OPTIONS);
return labels;
});
// ── Helpers ─────────────────────────────────────────────────
const getPrimaryReading = (item) => {
if (!item.readings?.length) return '';
const primary = item.readings.find((r) => r.primary);
return primary ? primary.reading : item.readings[0].reading;
};
const getAudiosByGender = (gender) => {
const audios = selectedItem.value?.pronunciationAudios || [];
return audios.filter((a) => a.gender === gender);
};
const hasMaleAudio = computed(() => getAudiosByGender('male').length > 0);
const hasFemaleAudio = computed(() => getAudiosByGender('female').length > 0);
// ── Data loading ────────────────────────────────────────────
onMounted(async () => {
await store.fetchVocabularies();
loading.value = false;
});
// ── Filtered data ───────────────────────────────────────────
const filteredVocabularies = computed(() => {
let items = store.vocabularies;
// Text search
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase().trim();
items = items.filter((item) => {
if (item.characters && item.characters.includes(q)) return true;
if (item.meanings && item.meanings.some((m) => m.toLowerCase().includes(q))) return true;
if (item.readings && item.readings.some((r) => r.reading.includes(q))) return true;
return false;
});
}
// Read all filter refs explicitly so Vue tracks them
const fTypes = filterTypes.value;
const fVerbClass = filterVerbClass.value;
const fTransitivity = filterTransitivity.value;
const fEndings = filterEndings.value;
const fLength = filterLength.value;
if (fTypes.length + fVerbClass.length + fTransitivity.length + fEndings.length + fLength.length === 0) {
return items;
}
return items.filter((item) => {
const pos = item.partsOfSpeech || [];
// Normalize: convert spaces to underscores to match filter values
const normalizedPos = pos.map((p) => p.replace(/\s+/g, '_'));
// Type filter (OR within category)
if (fTypes.length > 0) {
const typeMatch = fTypes.some((ft) => {
if (ft === 'verb') return normalizedPos.some((p) => p.includes('verb'));
return normalizedPos.includes(ft);
});
if (!typeMatch) return false;
}
// Verb class filter
if (fVerbClass.length > 0) {
if (!fVerbClass.some((v) => normalizedPos.includes(v))) return false;
}
// Transitivity filter
if (fTransitivity.length > 0) {
if (!fTransitivity.some((v) => normalizedPos.includes(v))) return false;
}
// Ending filter
if (fEndings.length > 0) {
const reading = getPrimaryReading(item);
const endingMatch = fEndings.some((e) => {
const suffixes = ENDING_MAP[e] || [];
return suffixes.some((s) => reading.endsWith(s));
});
if (!endingMatch) return false;
}
// Length filter
if (fLength.length > 0) {
const len = (item.characters || '').length;
const lenMatch = fLength.some((l) => {
if (l === '1') return len === 1;
if (l === '2') return len === 2;
if (l === '3') return len === 3;
if (l === '4+') return len >= 4;
return false;
});
if (!lenMatch) return false;
}
return true;
});
});
const groupedItems = computed(() => {
const groups = {};
filteredVocabularies.value.forEach((i) => {
if (!groups[i.level]) groups[i.level] = [];
groups[i.level].push(i);
});
const sorted = {};
Object.keys(groups)
.sort((a, b) => parseInt(a) - parseInt(b))
.forEach((k) => { sorted[k] = groups[k]; });
return sorted;
});
const componentKanji = computed(() => {
if (!selectedItem.value?.componentSubjectIds?.length) return [];
return selectedItem.value.componentSubjectIds
.map((id) => store.collection.find((k) => k.wkSubjectId === id))
.filter(Boolean);
});
// ── Actions ─────────────────────────────────────────────────
const openDetail = (item) => {
selectedItem.value = item;
showMnemonics.value = false;
showModal.value = true;
};
const playAudio = (gender) => {
const audios = getAudiosByGender(gender);
if (audios.length === 0) return;
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
const pick = audios[Math.floor(Math.random() * audios.length)];
audioPlayingGender.value = gender;
currentAudio = new Audio(pick.url);
currentAudio.addEventListener('ended', () => {
audioPlayingGender.value = null;
currentAudio = null;
});
currentAudio.addEventListener('error', () => {
audioPlayingGender.value = null;
currentAudio = null;
});
currentAudio.play().catch(() => {
audioPlayingGender.value = null;
currentAudio = null;
});
};
const navigateToKanji = (kanji) => {
showModal.value = false;
router.push({ path: '/collection', query: { search: kanji.char } });
};
// Normalize POS: handle both "na adjective" and "na_adjective"
const formatPOS = (pos) => pos
.replace(/_/g, ' ')
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
const formatPOSShort = (pos) => {
const normalized = pos.replace(/\s+/g, '_');
const POS_SHORT = {
noun: 'N',
transitive_verb: 'Vt',
intransitive_verb: 'Vi',
godan_verb: '五',
ichidan_verb: '一',
suru_verb: 'する',
i_adjective: 'い',
na_adjective: 'な',
adverb: 'Adv',
numeral: '#',
counter: 'Ctr',
suffix: 'Sfx',
prefix: 'Pfx',
expression: 'Exp',
};
return POS_SHORT[normalized] || pos.charAt(0).toUpperCase();
};
</script>
+9
View File
@@ -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:
+393
View File
@@ -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);
});
+11
View File
@@ -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);
};
+31
View File
@@ -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
View File
@@ -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);
});
}
+6
View File
@@ -1,6 +1,10 @@
import { User } from '../models/User.js';
export const loginUser = async (apiKey) => {
// Allow test key to bypass WaniKani API validation in dev
const isTestKey = apiKey === 'test-key-12345';
if (!isTestKey) {
const response = await fetch('https://api.wanikani.com/v2/user', {
headers: { Authorization: `Bearer ${apiKey}` }
});
@@ -8,10 +12,12 @@ export const loginUser = async (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,
+111
View File
@@ -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 };
};