got kanji with ja and en translation working (not both) and all options at random
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/roboto": "5.2.7",
|
"@fontsource/roboto": "5.2.7",
|
||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
|
"axios": "^1.12.2",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pug": "^3.0.3",
|
"pug": "^3.0.3",
|
||||||
"vue": "^3.5.21",
|
"vue": "^3.5.21",
|
||||||
|
|||||||
86
client/pnpm-lock.yaml
generated
86
client/pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
|||||||
'@mdi/font':
|
'@mdi/font':
|
||||||
specifier: 7.4.47
|
specifier: 7.4.47
|
||||||
version: 7.4.47
|
version: 7.4.47
|
||||||
|
axios:
|
||||||
|
specifier: ^1.12.2
|
||||||
|
version: 1.12.2
|
||||||
pinia:
|
pinia:
|
||||||
specifier: ^3.0.3
|
specifier: ^3.0.3
|
||||||
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
|
version: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3))
|
||||||
@@ -800,6 +803,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
|
||||||
engines: {node: '>=20.19.0'}
|
engines: {node: '>=20.19.0'}
|
||||||
|
|
||||||
|
asynckit@0.4.0:
|
||||||
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
|
axios@1.12.2:
|
||||||
|
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
|
||||||
|
|
||||||
babel-walk@3.0.0-canary-5:
|
babel-walk@3.0.0-canary-5:
|
||||||
resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
|
resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
@@ -897,6 +906,10 @@ packages:
|
|||||||
colorjs.io@0.5.2:
|
colorjs.io@0.5.2:
|
||||||
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
comment-parser@1.4.1:
|
comment-parser@1.4.1:
|
||||||
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -948,6 +961,10 @@ packages:
|
|||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0:
|
||||||
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@@ -987,6 +1004,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
esbuild@0.25.10:
|
esbuild@0.25.10:
|
||||||
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1218,6 +1239,19 @@ packages:
|
|||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11:
|
||||||
|
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
|
||||||
|
engines: {node: '>=4.0'}
|
||||||
|
peerDependencies:
|
||||||
|
debug: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
debug:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
form-data@4.0.4:
|
||||||
|
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1440,6 +1474,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
mime-db@1.52.0:
|
||||||
|
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
@@ -1597,6 +1639,9 @@ packages:
|
|||||||
promise@7.3.1:
|
promise@7.3.1:
|
||||||
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
pug-attrs@3.0.0:
|
pug-attrs@3.0.0:
|
||||||
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
|
resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==}
|
||||||
|
|
||||||
@@ -2793,6 +2838,16 @@ snapshots:
|
|||||||
'@babel/parser': 7.28.4
|
'@babel/parser': 7.28.4
|
||||||
ast-kit: 2.1.3
|
ast-kit: 2.1.3
|
||||||
|
|
||||||
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
|
axios@1.12.2:
|
||||||
|
dependencies:
|
||||||
|
follow-redirects: 1.15.11
|
||||||
|
form-data: 4.0.4
|
||||||
|
proxy-from-env: 1.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
babel-walk@3.0.0-canary-5:
|
babel-walk@3.0.0-canary-5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/types': 7.28.4
|
'@babel/types': 7.28.4
|
||||||
@@ -2891,6 +2946,10 @@ snapshots:
|
|||||||
|
|
||||||
colorjs.io@0.5.2: {}
|
colorjs.io@0.5.2: {}
|
||||||
|
|
||||||
|
combined-stream@1.0.8:
|
||||||
|
dependencies:
|
||||||
|
delayed-stream: 1.0.0
|
||||||
|
|
||||||
comment-parser@1.4.1: {}
|
comment-parser@1.4.1: {}
|
||||||
|
|
||||||
concat-map@0.0.1: {}
|
concat-map@0.0.1: {}
|
||||||
@@ -2930,6 +2989,8 @@ snapshots:
|
|||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
detect-libc@1.0.3:
|
detect-libc@1.0.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2957,6 +3018,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
es-set-tostringtag@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
has-tostringtag: 1.0.2
|
||||||
|
hasown: 2.0.2
|
||||||
|
|
||||||
esbuild@0.25.10:
|
esbuild@0.25.10:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.25.10
|
'@esbuild/aix-ppc64': 0.25.10
|
||||||
@@ -3274,6 +3342,16 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
|
form-data@4.0.4:
|
||||||
|
dependencies:
|
||||||
|
asynckit: 0.4.0
|
||||||
|
combined-stream: 1.0.8
|
||||||
|
es-set-tostringtag: 2.1.0
|
||||||
|
hasown: 2.0.2
|
||||||
|
mime-types: 2.1.35
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3465,6 +3543,12 @@ snapshots:
|
|||||||
braces: 3.0.3
|
braces: 3.0.3
|
||||||
picomatch: 2.3.1
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
mime-db@1.52.0: {}
|
||||||
|
|
||||||
|
mime-types@2.1.35:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.52.0
|
||||||
|
|
||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
@@ -3613,6 +3697,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
asap: 2.0.6
|
asap: 2.0.6
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
pug-attrs@3.0.0:
|
pug-attrs@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
constantinople: 4.0.1
|
constantinople: 4.0.1
|
||||||
|
|||||||
152
client/src/composables/srs.scoring.ts
Normal file
152
client/src/composables/srs.scoring.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
type SubjectType = 'kanji' | 'vocab' | 'both'
|
||||||
|
type LearningDirection = 'jp->en' | 'en->jp'
|
||||||
|
type WritingMode = 'kana' | 'kanji'
|
||||||
|
|
||||||
|
interface SubjectOptions {
|
||||||
|
subject: SubjectType
|
||||||
|
direction?: LearningDirection
|
||||||
|
writingMode?: WritingMode
|
||||||
|
mode?: 'meaning' | 'reading'
|
||||||
|
writingPractice: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewItem {
|
||||||
|
type: 'kanji' | 'vocab'
|
||||||
|
subject: string
|
||||||
|
answers: string[]
|
||||||
|
pronunciation_audios?: string[],
|
||||||
|
mode: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentReviewItem: any = {}
|
||||||
|
let currentSubjectOptions: SubjectOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles logic for when a subject answer is correct.
|
||||||
|
*
|
||||||
|
* @param id - The unique identifier of the subject item.
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const correct = async () => (
|
||||||
|
await axios.post('/api/v1/review/correct', {
|
||||||
|
itemId: currentReviewItem.id,
|
||||||
|
currentSubjectOptions,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles logic for when a subject answer is incorrect
|
||||||
|
*
|
||||||
|
* @param id - The unique identifier of the subject item.
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
* @returns void
|
||||||
|
*/
|
||||||
|
const incorrect = async () => {
|
||||||
|
await axios.post('/api/v1/review/incorrect', {
|
||||||
|
itemId: currentReviewItem.id,
|
||||||
|
currentSubjectOptions,
|
||||||
|
}, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the next subject from the server
|
||||||
|
*
|
||||||
|
* @param subjectOptions - Configuration describing what kind of subject
|
||||||
|
* and learning mode is being used (kanji, vocab, etc.).
|
||||||
|
*
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
const nextSubject = async (subjectOptions: SubjectOptions): Promise<ReviewItem | null> => {
|
||||||
|
const res = await axios.post('/api/v1/review/', { subjectOptions }, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const item = res.data
|
||||||
|
if (!item || item.message === 'No reviews due') return null
|
||||||
|
|
||||||
|
const mode: 'writing_practice' | 'jp_en_meaning' | 'jp_en_reading' | 'en_jp_writing' = item._selectedMode
|
||||||
|
|
||||||
|
currentReviewItem = item
|
||||||
|
currentSubjectOptions = { ...subjectOptions, mode }
|
||||||
|
|
||||||
|
let subjectText = ''
|
||||||
|
let answers: string[] = []
|
||||||
|
let pronunciation_audios: string[] | undefined = item.pronunciation_audios
|
||||||
|
|
||||||
|
if (mode === 'writing_practice') {
|
||||||
|
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
||||||
|
answers = [item.characters]
|
||||||
|
} else if (mode === 'jp_en_meaning') {
|
||||||
|
subjectText = item.characters
|
||||||
|
answers = item.meanings?.filter(m => m.accepted_answer).map(m => m.meaning) || []
|
||||||
|
} else if (mode === 'jp_en_reading') {
|
||||||
|
subjectText = item.characters
|
||||||
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
} else if (mode === 'en_jp_writing') {
|
||||||
|
subjectText = item.meanings?.filter(m => m.primary).map(m => m.meaning).join(', ') || ''
|
||||||
|
if (subjectOptions.writingMode === 'kanji') {
|
||||||
|
answers = [item.characters]
|
||||||
|
} else if (subjectOptions.writingMode === 'kana') {
|
||||||
|
answers = item.readings?.filter(r => r.accepted_answer).map(r => r.reading) || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
type: item.type,
|
||||||
|
subject: subjectText,
|
||||||
|
answers,
|
||||||
|
pronunciation_audios,
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: item.type,
|
||||||
|
subject: subjectText,
|
||||||
|
answers,
|
||||||
|
pronunciation_audios,
|
||||||
|
mode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const getStatistics = async () => {
|
||||||
|
const res = await axios.get('/api/v1/review/stats', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSubjectStatistics = async (id: string) => {
|
||||||
|
const res = await axios.get(`/api/v1/review/stats/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
correct,
|
||||||
|
incorrect,
|
||||||
|
nextSubject,
|
||||||
|
getStatistics,
|
||||||
|
getSubjectStatistics,
|
||||||
|
}
|
||||||
@@ -1,64 +1,73 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
template(v-if="!finished")
|
||||||
span.text-primary Full Trainer
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Full Trainer
|
||||||
|
|
||||||
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
:style="{backgroundColor: 'var(--color-all)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
:style="{ backgroundColor: subjectColor, fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap' }"
|
||||||
)
|
)
|
||||||
span {{ character }}
|
span {{ character }}
|
||||||
|
|
||||||
v-card-text.text-center.my-4.rounded-lg(
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-all)'}"
|
:style="{ backgroundColor: subjectColor + '20', fontSize: '1.1rem', fontWeight: '500', color: subjectColor }"
|
||||||
)
|
)
|
||||||
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
transition(name="alert-grow")
|
transition(name="alert-grow")
|
||||||
v-alert.mb-4(
|
v-alert.mb-4(
|
||||||
v-if="resultMessage"
|
v-if="resultMessage"
|
||||||
:type="isCorrect ? 'success' : 'error'"
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
border="top"
|
border="top"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
) {{ resultMessage }}
|
) {{ resultMessage }}
|
||||||
|
|
||||||
v-row.align-center
|
v-row.align-center
|
||||||
v-col(cols="9")
|
v-col(cols="9")
|
||||||
input#answer-input.customInput(
|
input#answer-input.customInput(
|
||||||
ref="answerInput"
|
ref="answerInput"
|
||||||
placeholder="Type your answer"
|
placeholder="Type your answer"
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
)
|
)
|
||||||
v-col(cols="3")
|
v-col(cols="3")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="primary"
|
color="primary"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Enter
|
) Enter
|
||||||
|
|
||||||
v-row.justify-space-between
|
v-row.justify-space-between
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="error"
|
color="error"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="quitSession"
|
@click="quitSession"
|
||||||
) Quit
|
) Quit
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="createReview"
|
@click="createReview"
|
||||||
) Skip
|
) Skip
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="success"
|
color="success"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Resolve
|
) Resolve
|
||||||
|
template(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-6.text-success
|
||||||
|
span.text-success.text-gradient Well Done!
|
||||||
|
v-card-text.text-center.text-h5
|
||||||
|
| You’ve completed all cards for this session.
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(color="primary" block @click="quitSession") Return Home
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -83,17 +92,36 @@ const subject = ref<any>(null)
|
|||||||
const direction = computed(() => route.query.direction)
|
const direction = computed(() => route.query.direction)
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
|
const finished = ref<boolean>(false)
|
||||||
|
const subjectType = ref<string>('')
|
||||||
|
|
||||||
|
const subjectColor = computed(() => {
|
||||||
|
if (!subject.value) return 'var(--color-all)'
|
||||||
|
|
||||||
|
console.log(subjectType.value)
|
||||||
|
|
||||||
|
switch (subjectType.value) {
|
||||||
|
case 'kanji':
|
||||||
|
return 'var(--color-kanji)'
|
||||||
|
case 'vocab':
|
||||||
|
return 'var(--color-vocab)'
|
||||||
|
default:
|
||||||
|
return 'var(--color-all)'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function createReview() {
|
function createReview() {
|
||||||
if (!reviews.value) return
|
if (!reviews.value) return
|
||||||
|
|
||||||
const next = reviews.value.nextSubject()
|
const next = reviews.value.nextSubject()
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// TODO: Add celebration or summary screen
|
finished.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject.value = next.subject
|
subject.value = next.subject
|
||||||
inputMode.value = next.mode
|
inputMode.value = next.mode
|
||||||
|
subjectType.value = next.type
|
||||||
|
|
||||||
if (direction.value === 'jp->en') {
|
if (direction.value === 'jp->en') {
|
||||||
character.value = subject.value.characters
|
character.value = subject.value.characters
|
||||||
@@ -128,8 +156,8 @@ function submitAnswer() {
|
|||||||
isCorrect.value = inputMode.value === 'kanji'
|
isCorrect.value = inputMode.value === 'kanji'
|
||||||
? subject.value.characters.trim() === userAnswer
|
? subject.value.characters.trim() === userAnswer
|
||||||
: (subject.value.readings?.length === 0
|
: (subject.value.readings?.length === 0
|
||||||
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
? subject.value.characters.trim() === userAnswer
|
||||||
: subject.value.characters.trim() === userAnswer)
|
: subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer))
|
||||||
|
|
||||||
|
|
||||||
const getAnswerText = () => {
|
const getAnswerText = () => {
|
||||||
@@ -294,4 +322,9 @@ $accent-color: hsl(320, 100%, 50%);
|
|||||||
box-shadow: 0 0 0 1px $error-color !important;
|
box-shadow: 0 0 0 1px $error-color !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
v-sheet,
|
||||||
|
v-card-text {
|
||||||
|
transition: background-color 0.4s ease, color 0.4s ease;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ const stats = ref<{ kanjiCount: number, vocabCount: number }>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const options = ref({
|
const options = ref({
|
||||||
meaning: true,
|
writing: false,
|
||||||
writing: true,
|
meaning: false,
|
||||||
kanji: false,
|
kanji: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -170,7 +170,13 @@ function _startTraining(type: 'kanji' | 'vocab' | 'all' | 'writing') {
|
|||||||
path: '/' + type,
|
path: '/' + type,
|
||||||
query: {
|
query: {
|
||||||
direction: direction.value,
|
direction: direction.value,
|
||||||
options: JSON.stringify(options.value),
|
options: JSON.stringify({
|
||||||
|
direction: direction.value,
|
||||||
|
type: type,
|
||||||
|
writing: options.value.writing,
|
||||||
|
meaning: options.value.meaning,
|
||||||
|
kanji: options.value.kanji,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,84 @@
|
|||||||
<template lang="pug">
|
<template lang="pug">
|
||||||
v-container.fill-height.d-flex.justify-center.align-center
|
v-container.fill-height.d-flex.justify-center.align-center
|
||||||
v-card.pa-12.rounded-lg.elevation-12
|
v-card.pa-12.rounded-lg.elevation-12
|
||||||
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
template(v-if="!allDone")
|
||||||
span.text-primary Kanji Trainer
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary Kanji Trainer
|
||||||
|
|
||||||
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
v-sheet.d-flex.justify-center.align-center.rounded-lg.py-8.px-4.elevation-12(
|
||||||
:style="{backgroundColor: 'var(--color-kanji)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
:style="{backgroundColor: 'var(--color-kanji)', fontSize: '5rem', fontWeight: 'bold', whiteSpace: 'nowrap'}"
|
||||||
)
|
)
|
||||||
span {{ character }}
|
span {{ character }}
|
||||||
|
|
||||||
v-card-text.text-center.my-4.rounded-lg(
|
v-card-text.text-center.my-4.rounded-lg(
|
||||||
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-kanji)'}"
|
:style="{ backgroundColor: 'hsl(320, 100%, 50%, 0.1)', fontSize: '1.1rem', fontWeight: '500', color: 'var(--color-kanji)'}"
|
||||||
)
|
)
|
||||||
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
| {{ inputMode === 'kanji' ? 'Kanji' : inputMode === 'writing' ? 'Writing' : inputMode }}
|
||||||
|
|
||||||
transition(name="alert-grow")
|
transition(name="alert-grow")
|
||||||
v-alert.mb-4(
|
v-alert.mb-4(
|
||||||
v-if="resultMessage"
|
v-if="resultMessage"
|
||||||
:type="isCorrect ? 'success' : 'error'"
|
:type="isCorrect ? 'success' : 'error'"
|
||||||
border="top"
|
border="top"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="compact"
|
density="compact"
|
||||||
) {{ resultMessage }}
|
) {{ resultMessage }}
|
||||||
|
|
||||||
v-row.align-center
|
v-row.align-center
|
||||||
v-col(cols="9")
|
v-col(cols="9")
|
||||||
input#answer-input.customInput(
|
input#answer-input.customInput(
|
||||||
ref="answerInput"
|
ref="answerInput"
|
||||||
placeholder="Type your answer"
|
placeholder="Type your answer"
|
||||||
:disabled="isDisabled"
|
:disabled="isDisabled"
|
||||||
)
|
)
|
||||||
v-col(cols="3")
|
v-col(cols="3")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="primary"
|
color="primary"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Enter
|
) Enter
|
||||||
|
|
||||||
v-row.justify-space-between
|
v-row.justify-space-between
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="error"
|
color="error"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="quitSession"
|
@click="quitSession"
|
||||||
) Quit
|
) Quit
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="createReview"
|
@click="createReview"
|
||||||
) Skip
|
) Skip
|
||||||
v-col(cols="4")
|
v-col(cols="4")
|
||||||
v-btn(
|
v-btn(
|
||||||
color="success"
|
color="success"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
block
|
block
|
||||||
@click="submitAnswer"
|
@click="submitAnswer"
|
||||||
) Resolve
|
) Resolve
|
||||||
|
|
||||||
|
template(v-else)
|
||||||
|
v-card-title.text-center.text-h2.font-weight-bold.mb-4
|
||||||
|
span.text-primary 🎉 Congratulations!
|
||||||
|
v-card-text.text-center
|
||||||
|
| You have completed all reviews for today!
|
||||||
|
v-row.justify-center.mt-6
|
||||||
|
v-col(cols="6")
|
||||||
|
v-btn(
|
||||||
|
color="primary"
|
||||||
|
block
|
||||||
|
@click="quitSession"
|
||||||
|
) Back to Home
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
import { ref, onMounted, nextTick, onBeforeUnmount, computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import * as wanakana from 'wanakana'
|
import * as wanakana from 'wanakana'
|
||||||
import { Reviews } from '../composables/subject.ts'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -77,76 +90,62 @@ const isDisabled = ref<boolean>(false)
|
|||||||
const isWanakanaBound = ref<boolean>(false)
|
const isWanakanaBound = ref<boolean>(false)
|
||||||
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
const inputMode = ref<'meaning' | 'writing' | 'kanji'>('meaning')
|
||||||
const resultMessage = ref<string>('')
|
const resultMessage = ref<string>('')
|
||||||
const reviews = ref<Reviews | null>(null)
|
|
||||||
const subject = ref<any>(null)
|
const subject = ref<any>(null)
|
||||||
|
const allDone = ref(false)
|
||||||
|
|
||||||
const direction = computed(() => route.query.direction)
|
|
||||||
const options = computed(() => JSON.parse(route.query.options as string))
|
const options = computed(() => JSON.parse(route.query.options as string))
|
||||||
|
|
||||||
function createReview() {
|
const optionsCalculated = ref({
|
||||||
if (!reviews.value) return
|
subject: options.value.type === 'writing' ? 'kanji' : options.value.type === 'all' ? ['kanji', 'vocab'] : options.value.type,
|
||||||
|
mode: options.value.writing && options.value.meaning ? ['reading', 'meaning'] : options.value.writing ? 'reading' : options.value.meaning ? 'meaning' : 'reading',
|
||||||
|
writingPractice: options.value.type === 'writing',
|
||||||
|
direction: options.value.direction,
|
||||||
|
writingMode: options.value.kanji ? 'kanji' : 'kana',
|
||||||
|
})
|
||||||
|
|
||||||
const next = reviews.value.nextSubject()
|
import { nextSubject, correct, incorrect } from '../composables/srs.scoring.ts'
|
||||||
|
|
||||||
|
async function createReview() {
|
||||||
|
console.log(optionsCalculated.value.mode)
|
||||||
|
const next = await nextSubject(optionsCalculated.value)
|
||||||
if (!next) {
|
if (!next) {
|
||||||
// TODO: Add celebration or summary screen
|
allDone.value = true
|
||||||
|
character.value = ''
|
||||||
|
resultMessage.value = '✅ All done!'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
subject.value = next.subject
|
allDone.value = false
|
||||||
inputMode.value = next.mode
|
subject.value = next
|
||||||
|
|
||||||
if (direction.value === 'jp->en') {
|
if (optionsCalculated.value.direction === 'en->jp' && optionsCalculated.value.writingMode === 'kanji')
|
||||||
character.value = subject.value.characters
|
inputMode.value = 'kanji'
|
||||||
} else {
|
else if (subject.value.mode === 'jp_en_reading' || optionsCalculated.value.direction === 'en->jp')
|
||||||
character.value = subject.value.meanings
|
inputMode.value = 'writing'
|
||||||
.filter((m: any) => m.primary)
|
else
|
||||||
.map((m: any) => m.meaning)
|
inputMode.value = 'meaning'
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
nextTickWrapper()
|
character.value = subject.value.subject
|
||||||
isDisabled.value = false
|
isDisabled.value = false
|
||||||
resultMessage.value = ''
|
resultMessage.value = ''
|
||||||
isCorrect.value = false
|
isCorrect.value = false
|
||||||
|
nextTickWrapper()
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitAnswer() {
|
async function submitAnswer() {
|
||||||
if (isDisabled.value) {
|
if (!answerInput.value || !subject) return
|
||||||
createReview()
|
console.log(subject.value)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!answerInput.value || !subject.value) return
|
|
||||||
|
|
||||||
const userAnswer = answerInput.value.value.trim()
|
const userAnswer = answerInput.value.value.trim()
|
||||||
|
isCorrect.value = subject.value.answers.some(a => a.trim().toLowerCase() === userAnswer)
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
isCorrect.value = inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
|
||||||
: subject.value.meanings.some((m: any) => m.accepted_answer && m.meaning.trim().toLowerCase() === userAnswer.toLowerCase())
|
|
||||||
else
|
|
||||||
isCorrect.value = inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters.trim() === userAnswer
|
|
||||||
: (subject.value.readings?.length === 0
|
|
||||||
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
|
||||||
: subject.value.characters.trim() === userAnswer)
|
|
||||||
|
|
||||||
|
|
||||||
const getAnswerText = () => {
|
|
||||||
if (direction.value === 'jp->en')
|
|
||||||
return inputMode.value === 'writing'
|
|
||||||
? subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
: subject.value.meanings.filter((m: any) => m.accepted_answer).map((m: any) => m.meaning).join(', ')
|
|
||||||
else
|
|
||||||
return inputMode.value === 'kanji'
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings?.length === 0
|
|
||||||
? subject.value.characters
|
|
||||||
: subject.value.readings.filter((r: any) => r.accepted_answer).map((r: any) => r.reading).join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
resultMessage.value = isCorrect.value ? 'Correct: ' : 'Wrong: '
|
||||||
resultMessage.value += getAnswerText()
|
resultMessage.value += subject.value.answers.join(', ')
|
||||||
|
|
||||||
|
if (isCorrect.value) {
|
||||||
|
await correct()
|
||||||
|
} else {
|
||||||
|
await incorrect()
|
||||||
|
}
|
||||||
|
|
||||||
answerInput.value.value = ''
|
answerInput.value.value = ''
|
||||||
isDisabled.value = true
|
isDisabled.value = true
|
||||||
@@ -187,14 +186,6 @@ function nextTickWrapper() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const mode = options.value.meaning && options.value.writing
|
|
||||||
? 'both'
|
|
||||||
: options.value.meaning
|
|
||||||
? 'meaning'
|
|
||||||
: 'writing'
|
|
||||||
|
|
||||||
reviews.value = await Reviews.getInstance()
|
|
||||||
await reviews.value.setOptions({ type: 'kanji', mode })
|
|
||||||
createReview()
|
createReview()
|
||||||
window.addEventListener('keydown', handleGlobalKey)
|
window.addEventListener('keydown', handleGlobalKey)
|
||||||
nextTickWrapper()
|
nextTickWrapper()
|
||||||
|
|||||||
@@ -128,9 +128,8 @@ function submitAnswer() {
|
|||||||
isCorrect.value = inputMode.value === 'kanji'
|
isCorrect.value = inputMode.value === 'kanji'
|
||||||
? subject.value.characters.trim() === userAnswer
|
? subject.value.characters.trim() === userAnswer
|
||||||
: (subject.value.readings?.length === 0
|
: (subject.value.readings?.length === 0
|
||||||
? subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer)
|
? subject.value.characters.trim() === userAnswer
|
||||||
: subject.value.characters.trim() === userAnswer)
|
: subject.value.readings?.some((r: any) => r.accepted_answer && r.reading.trim() === userAnswer))
|
||||||
|
|
||||||
|
|
||||||
const getAnswerText = () => {
|
const getAnswerText = () => {
|
||||||
if (direction.value === 'jp->en')
|
if (direction.value === 'jp->en')
|
||||||
|
|||||||
232
server/src/api/v1/subject/index.ts
Normal file
232
server/src/api/v1/subject/index.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import express, { type Router, type Response } from 'express'
|
||||||
|
import { ReviewItemModel, type ReviewItemType } from '../../../models/reviewitem.model.ts'
|
||||||
|
import { verifyAccessToken, type AuthRequest } from '../../../middleware/auth.ts'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.use(verifyAccessToken)
|
||||||
|
|
||||||
|
router.post('/', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { subjectOptions } = req.body
|
||||||
|
if (!subjectOptions) return res.status(400).json({ error: 'Missing subject options' })
|
||||||
|
|
||||||
|
let typeFilter: ReviewItemType[] = ['kanji', 'vocab']
|
||||||
|
if (subjectOptions.subject) {
|
||||||
|
typeFilter = Array.isArray(subjectOptions.subject)
|
||||||
|
? subjectOptions.subject
|
||||||
|
: [subjectOptions.subject]
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleModes: { key: string; type: string }[] = []
|
||||||
|
|
||||||
|
if (subjectOptions.writingPractice) {
|
||||||
|
possibleModes.push({ key: 'writing_practice', type: 'writing_practice' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectOptions.direction === 'jp->en') {
|
||||||
|
const modes = Array.isArray(subjectOptions.mode) ? subjectOptions.mode : [subjectOptions.mode]
|
||||||
|
if (modes.includes('meaning')) possibleModes.push({ key: 'jp_en_meaning', type: 'jp_en_meaning' })
|
||||||
|
if (modes.includes('reading')) possibleModes.push({ key: 'jp_en_reading', type: 'jp_en_reading' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subjectOptions.direction === 'en->jp') {
|
||||||
|
if (subjectOptions.mode === 'writing' || subjectOptions.writingMode) {
|
||||||
|
possibleModes.push({ key: 'en_jp_writing', type: 'en_jp_writing' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (possibleModes.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const dueConditions = possibleModes.map(m => ({ [`srs.${m.key}.next_review`]: { $lte: now } }))
|
||||||
|
|
||||||
|
const dueItems = await ReviewItemModel.find({
|
||||||
|
userId,
|
||||||
|
type: { $in: typeFilter },
|
||||||
|
$or: dueConditions,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!dueItems || dueItems.length === 0) return res.status(200).json({ message: 'No reviews due' })
|
||||||
|
|
||||||
|
const nextItem = dueItems[Math.floor(Math.random() * dueItems.length)]
|
||||||
|
|
||||||
|
const dueModesForItem = possibleModes.filter(
|
||||||
|
m => nextItem.srs[m.key].next_review <= now
|
||||||
|
)
|
||||||
|
|
||||||
|
if (dueModesForItem.length === 0) {
|
||||||
|
return res.status(200).json({ message: 'No reviews due for this item' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMode = dueModesForItem[Math.floor(Math.random() * dueModesForItem.length)].type
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
...nextItem.toObject(),
|
||||||
|
_selectedMode: selectedMode,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching next review item:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch next review item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/correct', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.body
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
let key: string
|
||||||
|
if (req.body.currentSubjectOptions.writingPractice) {
|
||||||
|
key = 'srs.writing_practice'
|
||||||
|
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||||
|
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||||
|
} else {
|
||||||
|
key = 'srs.en_jp_writing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
item.set(`${key}.streak`, (item.get(`${key}.streak`) ?? 0) + 1)
|
||||||
|
item.set(`${key}.ease_factor`, Math.min(3.0, (item.get(`${key}.ease_factor`) ?? 2.5) + 0.1))
|
||||||
|
item.set(`${key}.interval`, Math.round((item.get(`${key}.interval`) ?? 1) * (item.get(`${key}.ease_factor`) ?? 2.5)))
|
||||||
|
item.set(`${key}.last_review`, now)
|
||||||
|
item.set(`${key}.next_review`, new Date(now.getTime() + (item.get(`${key}.interval`) ?? 1) * 3600 * 1000))
|
||||||
|
|
||||||
|
await item.save()
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Item marked as correct' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking item correct:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to update item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/incorrect', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.body
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
let key: string
|
||||||
|
if (req.body.currentSubjectOptions.writingPractice) {
|
||||||
|
key = 'srs.writing_practice'
|
||||||
|
} else if (req.body.currentSubjectOptions.direction === 'jp->en') {
|
||||||
|
key = req.body.currentSubjectOptions.mode === 'reading' ? 'srs.jp_en_reading' : 'srs.jp_en_meaning'
|
||||||
|
} else {
|
||||||
|
key = 'srs.en_jp_writing'
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
item.set(`${key}.streak`, 0)
|
||||||
|
item.set(`${key}.ease_factor`, Math.max(1.3, (item.get(`${key}.ease_factor`) ?? 2.5) - 0.2))
|
||||||
|
item.set(`${key}.interval`, 1)
|
||||||
|
item.set(`${key}.last_review`, now)
|
||||||
|
item.set(`${key}.next_review`, new Date(now.getTime() + 60 * 60 * 1000)) // 1h cooldown for wrong answers
|
||||||
|
|
||||||
|
await item.save()
|
||||||
|
|
||||||
|
res.status(200).json({ message: 'Item marked as incorrect' })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error marking item incorrect:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to update item' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/stats', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
const totalKanji = await ReviewItemModel.countDocuments({ userId, type: 'kanji' })
|
||||||
|
const totalVocab = await ReviewItemModel.countDocuments({ userId, type: 'vocab' })
|
||||||
|
|
||||||
|
const dueItems = await ReviewItemModel.countDocuments({
|
||||||
|
userId,
|
||||||
|
srs_next_review: { $lte: now },
|
||||||
|
})
|
||||||
|
|
||||||
|
const waitingItems = await ReviewItemModel.countDocuments({
|
||||||
|
userId,
|
||||||
|
srs_next_review: { $gt: now },
|
||||||
|
})
|
||||||
|
|
||||||
|
const kanjiAgg = await ReviewItemModel.aggregate([
|
||||||
|
{ $match: { userId, type: 'kanji' } },
|
||||||
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
|
])
|
||||||
|
const vocabAgg = await ReviewItemModel.aggregate([
|
||||||
|
{ $match: { userId, type: 'vocab' } },
|
||||||
|
{ $group: { _id: null, avgScore: { $avg: '$srs_score' } } },
|
||||||
|
])
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
totalKanji,
|
||||||
|
totalVocab,
|
||||||
|
dueItems,
|
||||||
|
waitingItems,
|
||||||
|
avgKanjiScore: kanjiAgg[0]?.avgScore ?? 0,
|
||||||
|
avgVocabScore: vocabAgg[0]?.avgScore ?? 0,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching stats:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch stats' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.get('/:itemId', verifyAccessToken, async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.userId
|
||||||
|
if (!userId) return res.status(401).json({ error: 'Unauthorized' })
|
||||||
|
|
||||||
|
const { itemId } = req.params
|
||||||
|
if (!itemId) return res.status(400).json({ error: 'Missing itemId' })
|
||||||
|
|
||||||
|
const item = await ReviewItemModel.findOne({ _id: itemId, userId })
|
||||||
|
if (!item) return res.status(404).json({ error: 'Item not found' })
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const due = item.srs_next_review && item.srs_next_review <= now
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
itemId: item._id,
|
||||||
|
type: item.type,
|
||||||
|
characters: item.characters,
|
||||||
|
meanings: item.meanings,
|
||||||
|
readings: item.readings,
|
||||||
|
auxiliary_meanings: item.auxiliary_meanings,
|
||||||
|
pronunciation_audios: item.pronunciation_audios ?? [],
|
||||||
|
level: item.level,
|
||||||
|
slug: item.slug,
|
||||||
|
srs_score: item.srs_score ?? 0,
|
||||||
|
srs_streak: item.srs_streak ?? 0,
|
||||||
|
srs_ease_factor: item.srs_ease_factor ?? 2.5,
|
||||||
|
srs_last_review: item.srs_last_review,
|
||||||
|
srs_next_review: item.srs_next_review,
|
||||||
|
srs_interval: item.srs_interval ?? 1,
|
||||||
|
isDue: due,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching item stats:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to fetch item stats' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router as Router
|
||||||
@@ -15,6 +15,7 @@ import kanjiRouter from './api/v1/subject/kanji.ts'
|
|||||||
import vocabRouter from './api/v1/subject/vocab.ts'
|
import vocabRouter from './api/v1/subject/vocab.ts'
|
||||||
import authRouter from './api/v1/auth/index.ts'
|
import authRouter from './api/v1/auth/index.ts'
|
||||||
import userRoutes from './api/v1/user/index.ts'
|
import userRoutes from './api/v1/user/index.ts'
|
||||||
|
import subjectRoutes from './api/v1/subject/index.ts'
|
||||||
|
|
||||||
import { verifyAccessToken } from './middleware/auth.ts'
|
import { verifyAccessToken } from './middleware/auth.ts'
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ app.use('/api/v1/subject/vocab', verifyAccessToken, vocabRouter)
|
|||||||
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
app.use('/api/v1/user', verifyAccessToken, userRoutes)
|
||||||
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
app.use('/api/v1/wanikani', verifyAccessToken, syncRouter)
|
||||||
app.use('/api/v1/auth', authRouter)
|
app.use('/api/v1/auth', authRouter)
|
||||||
|
app.use('/api/v1/review', verifyAccessToken, subjectRoutes)
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
connectMongo().then(() => {
|
connectMongo().then(() => {
|
||||||
|
|||||||
51
server/src/models/reviewitem.model.ts
Normal file
51
server/src/models/reviewitem.model.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import mongoose from 'mongoose'
|
||||||
|
|
||||||
|
export type ReviewItemType = 'kanji' | 'vocab'
|
||||||
|
|
||||||
|
const ReviewItemSchema = new mongoose.Schema({
|
||||||
|
userId: { type: String, required: true },
|
||||||
|
type: { type: String, enum: ['kanji', 'vocab'], required: true },
|
||||||
|
id: { type: Number, required: true },
|
||||||
|
|
||||||
|
characters: String,
|
||||||
|
meanings: Array,
|
||||||
|
readings: { type: Array, default: [] },
|
||||||
|
auxiliary_meanings: Array,
|
||||||
|
level: Number,
|
||||||
|
slug: String,
|
||||||
|
|
||||||
|
pronunciation_audios: { type: Array, default: [] },
|
||||||
|
|
||||||
|
srs: {
|
||||||
|
jp_en_meaning: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
jp_en_reading: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
en_jp_writing: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
writing_practice: {
|
||||||
|
streak: { type: Number, default: 0 },
|
||||||
|
ease_factor: { type: Number, default: 2.5 },
|
||||||
|
interval: { type: Number, default: 1 },
|
||||||
|
next_review: { type: Date, default: () => new Date() },
|
||||||
|
last_review: { type: Date, default: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ReviewItemModel = mongoose.model('ReviewItem', ReviewItemSchema)
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import fetch from 'node-fetch'
|
|
||||||
import { ApiKeyModel } from '../models/apikey.model.ts'
|
import { ApiKeyModel } from '../models/apikey.model.ts'
|
||||||
import { AssignmentModel } from '../models/assignments.model.ts'
|
import { AssignmentModel } from '../models/assignments.model.ts'
|
||||||
import { KanjiModel } from '../models/kanji.model.ts'
|
import { ReviewItemModel } from '../models/reviewitem.model.ts'
|
||||||
import { VocabularyModel } from '../models/vocabulary.model.ts'
|
|
||||||
|
|
||||||
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
import type { KanjiItem, VocabularyItem } from '../types/wanikani.ts'
|
||||||
|
|
||||||
@@ -24,6 +22,7 @@ export interface WaniKaniSubject {
|
|||||||
level: number
|
level: number
|
||||||
slug: string
|
slug: string
|
||||||
unlocked_at?: string
|
unlocked_at?: string
|
||||||
|
subject_type: 'kanji' | 'vocabulary' | 'kana_vocabulary'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,28 +62,56 @@ const fetchSubjects = async (
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapKanji = (item: WaniKaniSubject, userId: string): KanjiItem => ({
|
const mapToReviewItem = (item: WaniKaniSubject, userId: string) => {
|
||||||
userId: userId,
|
let type: 'kanji' | 'vocab'
|
||||||
characters: item.data.characters,
|
const subjectType = item.data.subject_type || item.object
|
||||||
meanings: item.data.meanings,
|
if (subjectType === 'kanji') type = 'kanji'
|
||||||
readings: item.data.readings,
|
else if (['vocabulary', 'kana_vocabulary'].includes(subjectType)) type = 'vocab'
|
||||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
else throw new Error(`Unknown WaniKani subject type: ${subjectType}`)
|
||||||
level: item.data.level,
|
|
||||||
slug: item.data.slug,
|
|
||||||
srs_score: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const mapVocab = (item: WaniKaniSubject, userId: string): VocabularyItem => ({
|
return {
|
||||||
userId: userId,
|
id: item.id,
|
||||||
characters: item.data.characters,
|
userId,
|
||||||
meanings: item.data.meanings,
|
type,
|
||||||
readings: item.data.readings ?? [],
|
characters: item.data.characters,
|
||||||
auxiliary_meanings: item.data.auxiliary_meanings,
|
meanings: item.data.meanings,
|
||||||
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
readings: item.data.readings ?? [],
|
||||||
level: item.data.level,
|
auxiliary_meanings: item.data.auxiliary_meanings ?? [],
|
||||||
slug: item.data.slug,
|
pronunciation_audios: item.data.pronunciation_audios ?? [],
|
||||||
srs_score: 0,
|
level: item.data.level,
|
||||||
})
|
slug: item.data.slug,
|
||||||
|
srs: {
|
||||||
|
jp_en_meaning: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
jp_en_reading: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
en_jp_writing: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
writing_practice: {
|
||||||
|
streak: 0,
|
||||||
|
ease_factor: 2.5,
|
||||||
|
interval: 1,
|
||||||
|
next_review: new Date(),
|
||||||
|
last_review: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
|
export const syncWanikaniData = async (apiKey: string, userId: string): Promise<void> => {
|
||||||
const headers = { Authorization: `Bearer ${apiKey}` }
|
const headers = { Authorization: `Bearer ${apiKey}` }
|
||||||
@@ -92,50 +119,48 @@ export const syncWanikaniData = async (apiKey: string, userId: string): Promise<
|
|||||||
try {
|
try {
|
||||||
await ApiKeyModel.updateOne(
|
await ApiKeyModel.updateOne(
|
||||||
{},
|
{},
|
||||||
{ $set: { user: userId, apiKey: apiKey, lastUsed: new Date() } },
|
{ $set: { userId: userId, apiKey: apiKey, lastUsed: new Date() } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
const assignments = await fetchAllPages(`${WANIKANI_API_BASE}/assignments`, headers)
|
||||||
|
|
||||||
const unlockedKanjiSubjectIds: number[] = []
|
const unlockedKanjiIds: number[] = []
|
||||||
const unlockedVocabSubjectIds: number[] = []
|
const unlockedVocabIds: number[] = []
|
||||||
|
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
const assignmentData = a.data as any
|
const assignmentData = a.data as any
|
||||||
|
|
||||||
if (!assignmentData.unlocked_at) continue
|
if (!assignmentData.unlocked_at) continue
|
||||||
|
|
||||||
if (assignmentData.subject_type === 'kanji') {
|
if (assignmentData.subject_type === 'kanji') unlockedKanjiIds.push(assignmentData.subject_id)
|
||||||
unlockedKanjiSubjectIds.push(assignmentData.subject_id)
|
else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) unlockedVocabIds.push(assignmentData.subject_id)
|
||||||
} else if (['vocabulary', 'kana_vocabulary'].includes(assignmentData.subject_type)) {
|
|
||||||
unlockedVocabSubjectIds.push(assignmentData.subject_id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await AssignmentModel.updateOne(
|
await AssignmentModel.updateOne(
|
||||||
{ userId: userId, subject_type: 'kanji' },
|
{ userId, subject_type: 'kanji' },
|
||||||
{ $set: { subject_ids: unlockedKanjiSubjectIds } },
|
{ $set: { subject_ids: unlockedKanjiIds } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
await AssignmentModel.updateOne(
|
await AssignmentModel.updateOne(
|
||||||
{ userId: userId, subject_type: 'vocabulary' },
|
{ userId, subject_type: 'vocab' },
|
||||||
{ $set: { subject_ids: unlockedVocabSubjectIds } },
|
{ $set: { subject_ids: unlockedVocabIds } },
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const existingKanjiSlugs = new Set((await KanjiModel.find({}, { slug: 1 })).map(k => k.slug))
|
// Fetch and insert new review items
|
||||||
const kanjiSubjects = await fetchSubjects(unlockedKanjiSubjectIds, headers)
|
const kanjiSubjects = await fetchSubjects(unlockedKanjiIds, headers)
|
||||||
const newKanji = kanjiSubjects.filter(s => !existingKanjiSlugs.has(s.data.slug))
|
const vocabSubjects = await fetchSubjects(unlockedVocabIds, headers)
|
||||||
if (newKanji.length > 0) await KanjiModel.insertMany(newKanji.map(k => mapKanji(k, userId)))
|
|
||||||
|
|
||||||
const existingVocabSlugs = new Set((await VocabularyModel.find({}, { slug: 1 })).map(v => v.slug))
|
const allSlugs = new Set((await ReviewItemModel.find({ userId }, { slug: 1 })).map(r => r.slug))
|
||||||
const vocabSubjects = await fetchSubjects(unlockedVocabSubjectIds, headers)
|
|
||||||
const newVocab = vocabSubjects.filter(s => !existingVocabSlugs.has(s.data.slug))
|
|
||||||
if (newVocab.length > 0) await VocabularyModel.insertMany(newVocab.map(v => mapVocab(v, userId)))
|
|
||||||
|
|
||||||
console.log('✅ Sync complete')
|
const newItems = [...kanjiSubjects, ...vocabSubjects]
|
||||||
|
.filter(s => !allSlugs.has(s.data.slug))
|
||||||
|
.map(s => mapToReviewItem(s, userId))
|
||||||
|
|
||||||
|
if (newItems.length > 0) await ReviewItemModel.insertMany(newItems)
|
||||||
|
|
||||||
|
console.log('✅ WaniKani sync complete')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Error syncing WaniKani data:', err)
|
console.error('❌ Error syncing WaniKani data:', err)
|
||||||
throw err
|
throw err
|
||||||
|
|||||||
Reference in New Issue
Block a user