Compare commits

...
195 Commits
Author SHA1 Message Date
polv 8e8e018445 fix context sentences in YAML to ensure vocab emphasis 2026-10-31 12:44:24 +07:00
polv 363a728fc3 convert ALL notes HTML to markdown 2026-10-21 01:09:52 +07:00
polv df05d4e875 add weather theme 2026-10-19 00:17:29 +07:00
polv ef57ecbc9b rename theme unit lessons 2026-10-16 00:28:05 +07:00
polv e51ca48bb9 shopping sentences 2026-10-04 15:16:01 +07:00
polv f43a23bab2 shopping theme 2026-10-04 12:50:10 +07:00
polv 4c7605156a video fullscreen on orientation change to landscape 2026-09-24 00:05:30 +07:00
polv 30f48e1fc7 eating sentence quiz 2026-09-20 13:18:01 +07:00
polv ee6d704d48 eating sentences bold the vocab 2026-09-19 21:18:13 +07:00
polv 11b178f6f8 eating sentence translation blur 2026-09-19 21:04:04 +07:00
polv 9c5c4d3689 vocab quizzes 2026-09-19 20:56:14 +07:00
polv 162813e949 eating theme 2026-09-19 20:26:39 +07:00
polv f61aaa4b40 lesson 26 remaining grammar 2026-09-18 18:16:07 +07:00
polv ed5dc07f6c lesson 26 some grammar 2026-09-17 00:04:52 +07:00
polv ffdeee6a76 lesson 26 remaining vocab 2026-09-13 11:48:59 +07:00
polv f2195dd2fb lesson 26 nouns, and inter-lesson links 2026-09-13 11:11:09 +07:00
polv 5c014ba6e1 lesson 26 2026-09-13 10:33:32 +07:00
polv 4703409a35 youtube embed playlist may also accept current video id 2026-09-12 21:13:30 +07:00
polv e68493ba35 transportation sentences quiz 2026-09-12 19:24:15 +07:00
polv 382f642205 youtube embed playlist 2026-09-12 19:09:09 +07:00
polv 863142902c webview android insets https://github.com/tauri-apps/tauri/issues/11475 2026-09-12 17:58:28 +07:00
polv e9d02dbec7 transportation sentences and extra vocab 2026-09-11 17:06:05 +07:00
polv 15af343be7 transportation vocab quiz and blur 2026-09-11 16:50:07 +07:00
polv ddcffe7a63 transportation 2026-09-11 16:33:43 +07:00
polv 9da12902d7 school sentences quiz 2026-09-10 13:34:31 +07:00
polv ffeac1c0ae make TTS work with Android Auto 2026-09-08 23:09:56 +07:00
polv 5c25d24073 TTS for ko_text when ko with abbrev 2026-09-07 21:31:43 +07:00
polv 0c628cc3f5 school sentence en spoiler and fix en gloss 2026-09-06 14:59:26 +07:00
polv 608d02c12a fix school vocab quiz 2026-09-06 12:32:16 +07:00
polv 8cb95a5b9d school vocab quiz 2026-09-06 11:42:37 +07:00
polv 4f891ae123 school theme 2026-09-06 11:15:47 +07:00
polv e8e7ff86f6 lesson 25 remaining grammar; theme lessons 2026-09-06 10:53:14 +07:00
polv dd36c6fc93 fix LLM explainer for sentence: section + example sentences 2026-09-06 10:19:52 +07:00
polv daa5c73cec lesson 25 grammar 2026-09-06 09:04:44 +07:00
polv c2d5b4c2f2 lesson 25 remaining vocab 2026-09-05 23:41:27 +07:00
polv bb20f289ff lesson 25 some vocab 2026-09-05 22:26:09 +07:00
polv 745c7dfc86 lesson 25 2026-09-05 22:11:16 +07:00
polv 5e6022167d sentence explainer for all sentence fmkey 2026-09-05 21:44:04 +07:00
polv fc7446f62a lesson 24 remaining grammar 2026-09-02 00:01:56 +07:00
polv b07a977495 LLM button disabling, and lesson 24 some grammar 2026-09-01 17:37:20 +07:00
polv 6ef128292e lesson 24 remaining vocab 2026-09-01 14:55:14 +07:00
polv cfe0b238d9 lesson 24 nouns and some grammar 2026-09-01 14:10:42 +07:00
polv 4813cefd18 lesson 24 2026-09-01 13:51:35 +07:00
polv 2b5b836203 fixformat in some example sentences 2026-08-31 18:08:21 +07:00
polv cac640fdea sentence database builder script 2026-08-29 11:06:38 +07:00
polv 1118bda30f add example sentences for numbers 2026-08-28 13:09:57 +07:00
polv 9dfae88805 test add tokenizer 2026-08-28 00:54:20 +07:00
polv 7e8c38a80f gtts plugin, sounds better than opentts:ko 2026-08-26 18:21:47 +07:00
polv 7a4c71675b LLM explainer language and loading... 2026-08-26 16:29:03 +07:00
polv fced071837 lesson 23 remaining vocab 2026-08-25 18:07:25 +07:00
polv 82b51c0703 persist LLM sentence explainer with supabase 2026-08-25 16:33:05 +07:00
polv e734d09039 lesson 23 LLM sentence explainer 2026-08-25 14:29:00 +07:00
polv 075fe19382 lesson 23 nouns 2026-08-25 12:02:26 +07:00
polv 54277ced6b lesson 23 2026-08-25 11:27:49 +07:00
polv dca24a710c lesson 22 remaining grammar 2026-08-25 10:36:36 +07:00
polv 2c391af08b lesson 22 some grammar 2026-08-25 00:17:26 +07:00
polv 7a2571443c lesson 22 vocab 2026-08-24 15:41:50 +07:00
polv fe01d84777 lesson 22 2026-08-24 15:02:33 +07:00
polv 8711bd2b4d lesson 21 grammar 2026-08-22 10:35:18 +07:00
polv 60875f20d6 lesson 20 grammar 2026-08-20 17:15:05 +07:00
polv 7b93eccc00 lesson 21 remaining vocab 2026-08-20 14:48:12 +07:00
polv 1ef4f2c02d lesson 21 2026-08-20 13:22:19 +07:00
polv a2d6c47133 lesson 20 remaining vocab 2026-08-18 15:30:14 +07:00
polv 8da598e7be lesson 20 some vocab 2026-08-18 15:19:28 +07:00
polv 98bb90a380 lesson 20 2026-08-18 10:20:01 +07:00
polv a9ff08f0ef lesson 6 grammar 2026-08-14 17:05:34 +07:00
polv 0f26cdd272 save quiz state for chosen categories 2026-08-12 23:38:22 +07:00
polv 2ead6da243 lesson 19 vocab 2026-08-12 11:06:51 +07:00
polv 9e7440168e lesson 19 2026-08-12 10:24:17 +07:00
polv 870f6c890f lesson 7 remaining grammar quiz 2026-08-09 09:30:46 +07:00
polv b13eab46de lesson 7 some grammar quiz 2026-08-08 23:42:59 +07:00
polv 8c6ffd282c lesson 8 grammar quiz 2026-08-08 11:30:36 +07:00
polv 90bfbb9e72 save quiz state 2026-08-07 11:38:09 +07:00
polv bab239fb92 lesson 18 grammar 2026-08-07 10:15:40 +07:00
polv d22e27a285 lesson 18 vocab 2026-08-06 21:55:12 +07:00
polv 38605f1ad8 lesson 18 2026-08-06 21:05:57 +07:00
polv 0c3b7f9c6f lesson 9 grammar quiz 2026-08-05 17:01:32 +07:00
polv d0355514ed lesson 17 remaining quizzes 2026-08-03 12:10:00 +07:00
polv 501f21e532 lesson 17 a grammar quiz 2026-08-02 11:55:43 +07:00
polv c836385bde lesson 17 remaining vocab 2026-07-30 18:40:54 +07:00
polv f707f2025b leson 17 some vocab 2026-07-30 15:32:06 +07:00
polv 01c3ec7c91 lesso 17 grammar remaining 2026-07-30 12:07:37 +07:00
polv e4621ab0b8 lesson 17 2026-07-30 11:44:48 +07:00
polv bc46de9e41 fix media downloader 2026-07-28 22:20:06 +07:00
polv 16aea2f26f lesson 16 grammar 2026-07-28 19:11:40 +07:00
polv 338591c1b2 lesson 15 grammar remaining 2026-07-28 09:08:29 +07:00
polv 03238e23fa lesson 15 grammar 2026-07-27 13:06:56 +07:00
polv 5b4e32b8b9 lesson 14 grammar, example sent manual random 2026-07-25 10:24:28 +07:00
polv 614432579b yaml referential and lesson 10 quiz 2026-07-23 18:44:12 +07:00
polv b992d81733 lesson 11 grammar remaining 2026-07-23 09:20:59 +07:00
polv 95e204b728 lesson 11 grammar 2026-07-20 23:42:00 +07:00
polv 2c8dd1b145 lesson 12 grammar remaining 2026-07-19 21:56:58 +07:00
polv 564020bd5e some lesson 12 2026-07-19 12:44:38 +07:00
polv 6f431bb6d1 lesson 13 remaining grammar quiz 2026-07-19 12:02:39 +07:00
polv 5c236885ce add audio downloader 2026-07-19 11:02:47 +07:00
polv ea5991cc4e add alternate windows tts 2026-07-19 09:51:15 +07:00
polv 045f62d951 lesson 13 grammar sentence quiz + adapt component 2026-07-18 23:29:19 +07:00
polv e6a8a6027c lesson 1 quiz 2026-07-18 19:13:58 +07:00
polv afc805c50e lesson 2 quiz 2026-07-18 11:01:25 +07:00
polv b14b2fde89 add conj to lesson 5-16, focusing on irregulars for 13-16 2026-07-18 08:41:11 +07:00
polv b37403a6ba add conjugated forms to notes, and add lesson 3 quiz 2026-07-18 07:48:17 +07:00
polv 72cd3bcd09 edit button with validator and YAML 2026-07-17 18:03:49 +07:00
polv ee87eeb616 if (!import.meta.env.SSR) for client side 2026-07-15 19:26:06 +07:00
polv 58240ff520 add supabase editor 2026-07-15 19:00:32 +07:00
polv 7c48643759 add dummy edit button 2026-07-15 16:27:07 +07:00
polv e682dfae6e lesson 4 quiz 2026-07-15 13:23:41 +07:00
polv 8d5407767c lesson 5 quiz 2026-07-15 12:57:30 +07:00
polv 787aa1a4ac lesson 6 quiz 2026-07-11 12:16:25 +07:00
polv e29505cf1b lesson 7 quiz 2026-07-11 07:58:00 +07:00
polv 51b15046be lesson 8 quiz; prepare new sentence mini-quiz 2026-07-09 16:58:06 +07:00
polv 12cf6b135b lesson 16 remaining vocab 2026-07-09 10:17:42 +07:00
polv 05d8bb3828 lesson 16 more vocab 2026-07-09 08:28:31 +07:00
polv 116ce29cb3 lesson 16 some in-lesson vocab quiz 2026-07-08 16:02:11 +07:00
polv cb3678cc4f lesson 16 remaining grammar 2026-07-08 14:10:23 +07:00
polv 0a63a4a694 lesson 16 grammar 2026-07-08 11:58:27 +07:00
polv e642258f47 vocab no sentence audio; lesson 9-10 quizzes 2026-07-07 22:51:17 +07:00
polv 5184aefa88 lesson 11-12 quizzes 2026-07-07 18:32:10 +07:00
polv dd2708f905 vocab quiz convert ** to em 2026-07-07 17:34:54 +07:00
polv cfa93c6e12 lesson 13 quiz and fix <p> css in blockquote 2026-07-06 15:36:29 +07:00
polv e295704301 lesson 14 quiz 2026-07-06 13:14:12 +07:00
polv 1456704918 quiz notes and label nowrap 2026-07-06 13:04:34 +07:00
polv cfb455d6ad quiz EN-KO 2026-07-06 12:20:26 +07:00
polv 2b62c9b9e0 quiz clickable label 2026-07-06 08:30:41 +07:00
polv f3c3b2bbb3 quiz algo fix 2026-07-05 13:49:05 +07:00
polv 09af058fa1 quiz sentence hide details and stop audio before starting next 2026-07-05 12:01:32 +07:00
polv eff1227b20 add vocab quiz 2026-07-05 11:02:22 +07:00
polv 9a821b7117 TTS detector fix 2026-07-04 19:31:15 +07:00
polv 7f46518257 additional 2. vocab and 8. sections 2026-07-04 19:20:04 +07:00
polv b110ac9df8 all 7 rules in the original book 2026-07-04 18:25:01 +07:00
polv 49832aee8b another 2 rules, some edits 2026-07-04 18:00:59 +07:00
polv aa50b9b144 2 first vocab lists, according to real grammar book + TTS + AI gen explanation/translation 2026-07-04 17:14:45 +07:00
polv ade080e47f sound change rules 2026-07-04 14:07:04 +07:00
polv c1ecc0781d lesson 15 remaining vocab 2026-06-01 08:26:00 +01:00
polv cb89942991 lesson 15 nouns 2026-05-29 06:25:34 +02:00
polv 09c0f311fc lesson 15 remaining grammar 2026-05-28 21:53:02 +02:00
polv 1d35a7bd2c lesson 15 some grammar 2026-05-25 15:37:44 +07:00
polv c0eb4d68a7 lesson 15 2026-05-25 15:21:20 +07:00
polv 7b9b5691dd lesson 14 remaining vocab 2026-05-22 23:53:02 +07:00
polv 4dd8c6ad46 lesson 14 some verbs 2026-05-22 12:57:14 +07:00
polv a8e489a485 lesson 14 nouns 2026-05-22 11:06:03 +07:00
polv 593e0eb404 lesson 14 grammar 2026-05-21 22:43:25 +07:00
polv 3169289432 lesson 14 2026-05-21 21:34:11 +07:00
polv e0b1a03e38 lesson 13 remaining vocab 2026-05-17 09:14:49 +07:00
polv 01f7b4c638 lesson 13 verbs 2026-05-16 20:50:48 +07:00
polv 1c56b3fd53 lesson 13 nouns 2026-05-16 11:48:03 +07:00
polv 37bb023398 lesson 13 remaining grammar 2026-05-16 08:22:08 +07:00
polv 3cafe0af89 lesson 13 some grammar 2026-05-15 23:33:43 +07:00
polv 0ff8e1eff3 lesson 13 2026-05-15 23:20:02 +07:00
polv 70ed29f2c9 lesson 12 remaining vocab 2026-05-09 19:32:30 +07:00
polv f279917d91 lesson 12 verbs 2026-05-09 13:12:05 +07:00
polv d9c15fadb0 lesson 12 nouns 2026-05-07 14:01:56 +07:00
polv 073d077bb5 lesson 12 remaining grammar 2026-05-04 20:43:47 +07:00
polv 82065f55d5 lesson 12 some grammar 2026-05-04 19:36:03 +07:00
polv 183be56112 lesson 12 2026-05-04 19:12:09 +07:00
polv 5c8ef1fa4d lesson 11 remaining vocab 2026-05-01 17:16:10 +07:00
polv 7f9e8b52df lesson 11 some vocab 2026-05-01 00:00:14 +07:00
polv f02c72b745 lesson 11 remaining grammar 2026-04-30 23:43:52 +07:00
polv 1a2800f115 lesson 11 some grammar 2026-04-30 16:37:02 +07:00
polv 33712b7d82 lesson 11 2026-04-30 14:33:35 +07:00
polv 23a0a1ac4e lesson 8 quiz 2026-04-29 09:02:00 +07:00
polv 471fe87429 quiz add links to grammar sections 2026-04-28 23:00:21 +07:00
polv d11d095816 lesson 9 quiz 2026-04-28 16:42:29 +07:00
polv b2aa693f1a simple quiz vue component 2026-04-28 15:41:18 +07:00
polv eb51015c6c lesson 10 vocab 2026-04-28 15:16:29 +07:00
polv c2f7aa1258 lesson 10 grammar 2026-04-28 11:34:38 +07:00
polv d669842d01 lesson 10 2026-04-28 10:18:32 +07:00
polv 37e48e0132 lesson 9 remaining words 2026-04-25 20:02:44 +07:00
polv 5ece9265f1 lesson 9 nouns 2026-04-25 11:49:07 +07:00
polv ff80f700f9 lesson 9 remaining grammar 2026-04-24 19:50:11 +07:00
polv 7c39c708ae lesson 9 2026-04-24 17:07:42 +07:00
polv d72fe09cec lesson 1 remaining vocab 2026-04-24 15:32:33 +07:00
polv 4bbb881b68 lesson 1 more nouns 2026-04-24 10:57:04 +07:00
polv ca55f7da86 lesson 1 determiners 2026-04-24 09:44:52 +07:00
polv 0e2391f8bf lesson 1 some nouns 2026-04-24 00:16:02 +07:00
polv 38fa509148 lesson 1 2026-04-23 15:53:04 +07:00
polv 33d1be6b12 lesson 2 remaining vocab 2026-04-23 08:14:08 +07:00
polv 29914b4d35 lesson 2 nouns 2026-04-22 00:08:21 +07:00
polv 34cbb0d3fc lesson 2 some nouns 2026-04-21 18:33:43 +07:00
polv 3f175f89c7 lesson 2 2026-04-21 18:13:24 +07:00
polv 9d54a51729 lesson 3 remaing vocab 2026-04-17 14:36:56 +07:00
polv 895c6cfc18 lesson 4 nouns 2026-04-17 13:10:19 +07:00
polv aad21bad28 lesson 3 some nouns 2026-04-16 22:22:46 +07:00
polv cb43d051c4 lesson 3 format grammar 2026-04-16 22:09:47 +07:00
polv 03d14655fe lesson 3 2026-04-16 21:53:38 +07:00
polv 2f5633abf5 lesson 4 adj and adverbs 2026-04-14 12:53:51 +07:00
polv 0ec50d729c lesson 4 nouns and verbs 2026-04-14 11:02:12 +07:00
polv 22839fe4a6 lesson 4 more nouns 2026-04-12 18:27:35 +07:00
polv 9a5cc2cc09 lesson 4 first vocabs 2026-04-12 13:04:16 +07:00
polv ec639ca5f4 lesson 4 clean grammar sections 2026-04-12 12:52:10 +07:00
polv a3a4a23c68 lesson 4 make sections 2026-04-11 20:54:47 +07:00
polv 8e4beeaeaf lesson 4 2026-04-11 08:39:40 +07:00
polv 57cd89a511 lesson 5 remaining vocab 2026-04-08 12:48:38 +07:00
polv 53bf3b72e8 lesson 5 nouns 2026-04-08 08:34:44 +07:00
polv 5e4eb495f3 lesson clean some vocab 2026-04-06 11:45:22 +07:00
polv 63cc848540 lesson 6 clean grammar 2026-04-06 11:27:56 +07:00
64 changed files with 56416 additions and 3056 deletions

No files matched your search

+4
View File
@@ -145,3 +145,7 @@ vite.config.ts.timestamp-*
docs/.vitepress/dist
docs/.vitepress/cache
docs/public/uploads/
.gtts/
*.db
+11 -1
View File
@@ -29,5 +29,15 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.maxTokenizationLineLength": 2500
}
},
"[rust]": {
"editor.quickSuggestions": {
"other": true,
"comments": false,
"strings": true
},
"editor.tabSize": 4,
"editor.formatOnSave": true,
},
"vue3snippets.enable-compile-vue-file-on-did-save-code": false
}
+68
View File
@@ -1,3 +1,71 @@
Contents edited from <https://www.howtostudykorean.com> is at [/docs/howtostudykorean/](/docs/howtostudykorean/).
For specifics of how this is created, see <https://git.polv.cc/polv/zh-grammar#how-it-works-for-android>
## TTS
For Windows TTS, I don't like Korean language support pack sound, so I use
```shell
docker run -it -p 5500:5500 synesthesiam/opentts:ko
```
It's required to have Docker running.
## Full Text Search
This is done with [kuromoji-ko](https://www.npmjs.com/package/kuromoji-ko).
```js
import { MeCab } from "kuromoji-ko";
const mecab = await MeCab.create({
engine: "ko",
dictPath: "./node_modules/kuromoji-ko/dict",
});
```
### Tagset
From <https://www.sketchengine.eu/hannanum-korean-tagset-simplified/>.
| ORIGINAL | SIMPLIFIED (in Sketch Engine) | DESCRIPTION | Example |
| ----------------- | ----------------------------- | -------------- | --------------------- |
| NOMINALS | | | |
| NC | **N** | noun | |
| NQ | **NP** | proper noun | |
| NB | **N** | noun | |
| NP | **Pron** | pronoun | |
| NN | **Num** | numeral | |
| PREDICATES | | | |
| PV | **V** | verb | |
| PA | **Adj** | adjective | |
| PX | **VAux** | auxiliary verb | |
| MODIFIERS | | | |
| MM | **Det** | determiner | |
| MA | **Adv** | adverb | |
| INTERJECTION | | | |
| II | **Interj** | interjection | |
| RELATIONAL SUFFIX | | | |
| JC | **Suff** | | |
| JX | **Suff** | | |
| JP | **Suff** | | |
| ENDING | | | |
| EP | **Suff** | | |
| EC | **Suff** | | |
| ET | **Suff** | | |
| EF | **Suff** | | |
| AFFIXES | | | |
| XP | **Pref** | | 접두사 |
| XS | **Suff** | | |
| SYMBOLS | | | |
| SP | **Sym** | | 쉼표 |
| SL | **Sym** | | 여는 따옴표 및 묶음표 |
| SD | **Sym** | | 이음표 |
| SU | **Sym** | | 단위 기호 |
| SF | **Sym** | | 마침표 |
| SR | **Sym** | | 닫는 따옴표 및 묶음표 |
| SE | **Sym** | | 줄임표 |
| SY | **Sym** | | 기타 기호 |
| FOREIGN WORDS | | | |
| F | **X** | | |
+40 -2
View File
@@ -1,4 +1,6 @@
import { defineConfig, MarkdownOptions } from "vitepress";
import { defineConfig, type MarkdownOptions } from "vitepress";
import { mp3DownloaderPlugin } from "./vite-plugins/mp3-downloader";
import { gttsPlugin } from "./vite-plugins/gtts";
const host = process.env.TAURI_DEV_HOST;
@@ -7,9 +9,18 @@ export default defineConfig({
title: "ko-grammar",
description: "Korean grammar edited",
lang: "ko-KR",
head: [
[
"script",
{
async: "async",
src: "https://www.youtube.com/iframe_api",
},
],
],
themeConfig: {
socialLinks: [
{ icon: "github", link: "https://github.com/vuejs/vitepress" },
{ icon: "github", link: "https://git.polv.cc/polv/ko-grammar" },
],
outline: {
level: "deep",
@@ -65,10 +76,18 @@ export default defineConfig({
},
},
vite: {
plugins: [
mp3DownloaderPlugin() as any,
// (Python) pip install gtts
// gtts will intercept /api/tts away from opentts:ko container
gttsPlugin(),
],
envDir: process.cwd(),
ssr: {
noExternal: [],
external: ["pdfjs-dist"], // don't bundle it for SSR — leave it browser-only
},
clearScreen: false,
server: {
/**
* Allow Windows Firewall via Admin PowerShell with
@@ -80,6 +99,10 @@ export default defineConfig({
port: 5173,
strictPort: true,
host: host || false,
watch: {
// tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
hmr: host
? {
protocol: "ws",
@@ -87,6 +110,21 @@ export default defineConfig({
port: 5174,
}
: undefined,
proxy: {
// docker run -it -p 5500:5500 synesthesiam/opentts:ko
"/api/tts": "http://localhost:5500",
},
},
// Env variables starting with the item of `envPrefix` will be exposed in tauri's source code through `import.meta.env`.
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
// Tauri uses Chromium on Windows and WebKit on macOS and Linux
target:
process.env.TAURI_ENV_PLATFORM == "windows" ? "chrome105" : "safari13",
// don't minify for debug builds
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
},
});
@@ -1,5 +1,6 @@
<template>
<span>
<span ref="main"><slot /></span>
<button type="button" @click="playAudio()">▶️</button>
<audio ref="audio" style="display: none"></audio>
</span>
@@ -7,17 +8,50 @@
<script setup lang="ts">
import { useTemplateRef } from "vue";
import { stop as stopTTS } from "tauri-plugin-tts-api";
const props = defineProps<{
src: string;
}>();
defineExpose({
playAudio,
});
const mainEl = useTemplateRef("main");
const audioEl = useTemplateRef("audio");
function playAudio() {
async function playAudio() {
if (!audioEl.value) return;
document.querySelectorAll("audio").forEach((el) => {
el.pause();
});
stopTTS();
const uploadURL = "https://www.howtostudykorean.com/wp-content/uploads";
let src = props.src;
if (src.startsWith(uploadURL)) {
src = src.replace(uploadURL, "/uploads");
if (mainEl.value) {
src += `?text=${encodeURIComponent(mainEl.value.innerText.trim())}`;
}
audioEl.value.onerror = () => {
if (!audioEl.value) return;
console.log(audioEl.value.error, src);
audioEl.value.src = props.src;
audioEl.value.currentTime = 0;
audioEl.value.play();
};
} else {
audioEl.value.onerror = null;
}
audioEl.value.src = src;
audioEl.value.currentTime = 0;
await audioEl.value.play();
}
</script>
@@ -0,0 +1,50 @@
<template>
<component
:is="lang !== 'ko' || v.audio ? 'span' : TTS"
ref="stts"
:text="v.ko_text"
>
<span
v-for="(seg, i) in v[lang].split('**')"
:key="i"
:class="i % 2 === 0 ? '' : spoiler ? 'spoiler' : $style.em"
>
{{ seg }}
</span>
<AudioButton v-if="lang === 'ko' && v.audio" :src="v.audio" ref="saud" />
</component>
</template>
<script setup lang="ts">
import { useTemplateRef } from "vue";
import TTS from "./TTS.vue";
import AudioButton from "./AudioButton.vue";
import { IVoc } from "../types.ts";
defineProps<{
v: IVoc;
lang: "ko" | "en";
spoiler?: boolean;
}>();
defineExpose({
playAudio,
});
const stts = useTemplateRef<HTMLSpanElement | InstanceType<typeof TTS>>("stts");
const saud = useTemplateRef<InstanceType<typeof AudioButton>>("saud");
async function playAudio() {
if (saud.value) {
await saud.value.playAudio();
} else if (stts.value && "playAudio" in stts.value) {
await stts.value.playAudio();
}
}
</script>
<style module>
.em {
font-style: italic;
}
</style>
@@ -0,0 +1,73 @@
<template>
<div id="sentence-quiz" :class="$style.root" ref="main">
<button type="button" @click="nextSentence()">Next</button>
<slot />
</div>
</template>
<script setup lang="ts">
import { nextTick, onMounted, ref, useTemplateRef } from "vue";
const mainEl = useTemplateRef("main");
const sentences = ref<number[]>([]);
const sentences_array_index = ref(NaN);
function nextSentence() {
if (!mainEl.value) return;
const getCurrentSentenceIndex = () => {
if (sentences.value.length <= 1) {
return 0;
}
if (++sentences_array_index.value < sentences.value.length) {
return sentences.value[sentences_array_index.value];
}
sentences.value = sentences.value
.map((s) => ({ s, k: Math.random() }))
.sort((a, b) => a.k - b.k)
.map((a) => a.s);
sentences_array_index.value = 0;
return sentences.value[sentences_array_index.value];
};
const currentSentenceIndex = getCurrentSentenceIndex();
mainEl.value.querySelectorAll("#sentence-quiz > ul > li").forEach((el, i) => {
if (!(el instanceof HTMLElement)) return;
el.style.display = i === currentSentenceIndex ? "" : "none";
});
}
onMounted(() => {
nextTick(() => {
if (!mainEl.value) return;
sentences.value = Array.from(
mainEl.value.querySelectorAll("#sentence-quiz > ul > li"),
).map((el, i) => i);
nextSentence();
});
});
</script>
<style module>
.root {
margin-top: 1em;
&:hover li li:not(:hover) {
filter: blur(5px);
}
> button {
float: right;
border: 1px solid gray;
padding: 0 0.5em;
border-radius: 5px;
}
}
</style>
+58 -8
View File
@@ -3,8 +3,15 @@
<span ref="main" :class="class" :lang="lang">
<slot />
</span>
<audio
style="display: none"
ref="audio"
@error="playAudioTTSPlugin()"
></audio>
<span>
<button type="button" @click="playAudio()">🗣️</button>
<button type="button" @click="playAudio()" :class="$style.speak">
🗣️
</button>
</span>
</span>
</template>
@@ -13,25 +20,62 @@
import { useTemplateRef } from "vue";
import { speak, getVoices } from "tauri-plugin-tts-api";
const { lang = "ko-KR" } = defineProps<{
defineExpose({
playAudio,
});
const { lang = "ko-KR", text } = defineProps<{
class?: string;
lang?: string;
text?: string;
}>();
const mainEl = useTemplateRef("main");
const audioEl = useTemplateRef("audio");
function getMainText() {
if (text) return text;
if (!mainEl.value) return "";
return mainEl.value.innerText
.replace(/[*]/gu, " ")
.replace(/[\p{Ps}\p{Pe}]/gu, "")
.trim();
}
async function playAudio() {
if (!mainEl.value) return;
const mainText = getMainText();
if (!mainText) return;
if (navigator.platform === "Win32" || !(await playAudioTTSPlugin())) {
if (!audioEl.value) return;
const url = new URL("/api/tts", location.origin);
url.searchParams.set("voice", "glow-speak:ko_kss");
url.searchParams.set("text", mainText);
audioEl.value.src = url.href;
audioEl.value.currentTime = 0;
audioEl.value.play();
}
}
async function playAudioTTSPlugin(): Promise<boolean> {
const mainText = getMainText();
if (!mainText) return false;
const voices = await getVoices(lang);
if (!voices.length) return;
if (!voices.length) return false;
document.querySelectorAll("audio").forEach((el) => {
el.pause();
});
const v = voices[Math.floor(Math.random() * voices.length)];
console.log(JSON.stringify(v));
await speak({
text: mainEl.value.innerText
.replace(/[*]/gu, " ")
.replace(/[\p{Ps}\p{Pe}]/gu, ""),
text: mainText,
language: null,
rate: null,
volume: null,
@@ -39,7 +83,13 @@ async function playAudio() {
queueMode: null,
voiceId: v.id,
});
return true;
}
</script>
<style module></style>
<style module>
.speak {
margin-inline-start: 0.2em;
}
</style>
@@ -0,0 +1,705 @@
<template>
<div>
<nav v-if="v">
<Teleport to="body">
<div v-if="isEdit" :class="$style.modal" ref="modal">
<nav>
<span> ko: {{ v.ko }} </span>
<div style="flex-grow: 1"></div>
<button type="button" @click="onVContentSave()">Save</button>
<button type="button" @click="isEdit = false">×</button>
</nav>
<textarea
v-model="vEditContent"
spellcheck="false"
ref="textarea"
></textarea>
</div>
</Teleport>
<div :class="$style.mark">
<button type="button" name="edit" @click="isEdit = true">✎</button>
<button type="button" name="right" @click="mark(true)">✓</button>
<button
type="button"
name="wrong"
@click="mark(false)"
style="font-size: large"
>
↻
</button>
</div>
<div>
<span :class="quizState.isKoFirst ? '' : 'spoiler'">
<SentenceAudio :v="v" lang="ko" ref="vaud" />
</span>
<span>{{ " = " }}</span>
<span :class="quizState.isKoFirst ? 'spoiler' : ''">
<SentenceAudio :v="v" lang="en" />
</span>
</div>
<details v-if="sent_ko || v.notes" ref="sdetails">
<summary>
<SentenceAudio
v-if="example"
:v="example"
lang="ko"
:spoiler="!quizState.isKoFirst"
ref="saud"
/>
<button
type="button"
name="new-sentence"
v-if="v.examples && v.examples.length > 1"
@click="makeNewExIndex()"
style="margin-inline-start: 0.5em"
>
↻
</button>
</summary>
<SentenceAudio v-if="example" :v="example" lang="en" />
<blockquote
v-if="notes.length"
:class="$style.notes"
v-html="markdownIt.render(notes.join('\n\n'))"
></blockquote>
<div>
<button
type="button"
v-if="sent_ko"
:disabled="exCurrentKo !== sent_ko"
@click="
if (exNotes) {
exNotes = '';
}
explainSentence(sent_ko);
"
>
<span v-if="exCurrentKo !== sent_ko">
{{ exNotesLoading || "..." }}
</span>
<span v-else-if="!!exNotesLoading"> {{ exNotesLoading }} </span>
<span v-else-if="!exNotes"> Explain Sentence </span>
<span v-else> New explanation </span>
</button>
<blockquote v-if="exNotes" v-html="markdownIt.render(exNotes)" />
</div>
</details>
</nav>
<details :class="$style.options">
<summary>🧐 Quiz options</summary>
<nav v-if="vCats.length > 1">
<label v-for="c in vCats" :key="c">
<input
type="checkbox"
:name="c"
:checked="quizState.vChosenCats.includes(c)"
@change="updateCats(c)"
/>
{{ c.replace(/_/g, " ") }}
</label>
</nav>
<nav>
<span>
<label role="switch">
<span>KR</span>
<input
type="checkbox"
name="front"
role="switch"
v-model="quizState.isKoFirst"
/>
<span role="slider"></span>
<span>EN</span>
</label>
</span>
<label>
<input
type="checkbox"
name="autoplay-audio"
v-model="quizState.isAutoplayAudio"
/>
autoplay sentence audio
</label>
</nav>
</details>
</div>
</template>
<script setup lang="ts">
import { useData } from "vitepress";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
reactive,
ref,
useTemplateRef,
watch,
} from "vue";
import yaml from "yaml";
import SentenceAudio from "./SentenceAudio.vue";
import { supabase } from "../supabase.ts";
import { fmValueParser, IVoc, Voc } from "../types.ts";
import { LLMstream } from "../llm.ts";
import MarkdownIt from "markdown-it";
const { fmKey = "vocabulary", category } = defineProps<{
fmKey?: string;
category?: string;
}>();
const vCats = ref<string[]>([]);
const vList = ref<IVoc[]>([]);
const vListOriginalIndex = ref<{ s: IVoc; k: number }[]>([]);
const vIndex = ref(-1);
const exIndex = ref(0);
const isEdit = ref(false);
const vEditContent = ref("");
const exCurrentKo = ref("");
const exNotes = ref("");
const exNotesLoading = ref("");
const quizState = reactive({
vChosenCats: [] as string[],
isAutoplayAudio: true,
isKoFirst: true,
});
const modalRef = useTemplateRef("modal");
const textareaRef = useTemplateRef("textarea");
const sentenceDetails = useTemplateRef("sdetails");
const saud = useTemplateRef<InstanceType<typeof SentenceAudio>>("saud");
const vaud = useTemplateRef<InstanceType<typeof SentenceAudio>>("vaud");
const v = computed<IVoc | null>(() => vList.value[vIndex.value] || null);
const example = computed(() => {
if (!v.value?.examples?.length) return null;
const ex = v.value.examples;
return ex[exIndex.value] || null;
});
const sent_ko = computed(() => {
if (example.value) return example.value.ko || "";
if (fmKey === "vocabulary") return "";
return v.value?.ko || "";
});
const notes = computed(() => [
...[example.value?.notes || []].flat(),
...[v.value?.notes || []].flat(),
]);
const MAX_VOC = 5;
const { frontmatter, page } = useData();
const LOCALSTORAGE_STATE = JSON.stringify([
page.value.filePath,
fmKey,
category,
]);
if (typeof window !== "undefined" && window.localStorage) {
const oldStateString = window.localStorage.getItem(LOCALSTORAGE_STATE);
if (oldStateString) {
try {
Object.assign(quizState, JSON.parse(oldStateString));
} catch (e) {}
}
}
function saveQuizState() {
window.localStorage.setItem(LOCALSTORAGE_STATE, JSON.stringify(quizState));
}
watch(quizState, () => {
saveQuizState();
});
let timeoutSetExNotes = 0;
watch(sent_ko, () => {
if (timeoutSetExNotes) return;
timeoutSetExNotes = window.setTimeout(() => {
timeoutSetExNotes = 0;
if (!exNotesLoading.value) {
exNotes.value = "";
}
if (!sent_ko.value) return;
let ko = sent_ko.value;
ko = ko.replaceAll("**", "");
if (!exNotesLoading.value) {
exCurrentKo.value = sent_ko.value;
}
if (supabase) {
supabase
.from("sentence-explain")
.select<any, { explain: string }>("explain")
.eq("ko", ko)
.then((r) => {
if (!r.data?.[0]) return;
exNotes.value = r.data[0].explain;
});
}
}, 50);
});
const vocabulary: Record<string, IVoc[]> = {};
for (const [k, vs] of Object.entries(
fmValueParser.parse(frontmatter.value[fmKey]),
)) {
vocabulary[k] = [...(vocabulary[k] || []), ...vs];
if (!vCats.value.includes(k)) {
vCats.value = [...vCats.value, k];
}
}
if (category) {
const vCatsSet = new Set(category.split(/[, ]+/g));
const newVCats = vCats.value.filter((c) => vCatsSet.has(c));
if (newVCats.length) {
vCats.value = newVCats;
}
}
if (!quizState.vChosenCats.some((c) => vCats.value.includes(c))) {
quizState.vChosenCats = [...vCats.value];
}
watch(
() => quizState.isKoFirst,
() => {
makeVList();
},
);
watch(isEdit, () => {
if (!v.value) return;
if (isEdit.value) {
const { ko, ...vContent } = v.value;
vEditContent.value = yaml.stringify(vContent);
nextTick(() => {
if (textareaRef.value) {
textareaRef.value.style.height = textareaRef.value.scrollHeight + "px";
}
});
} else {
vEditContent.value = "";
}
});
function makeVList() {
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
});
const newVList = Object.entries(vocabulary)
.filter(([k]) => quizState.vChosenCats.includes(k))
.flatMap(([, vs]) => vs);
vListOriginalIndex.value = newVList.map((s) => ({
s,
k: Math.random(),
}));
vList.value = vListOriginalIndex.value
.sort((a, b) => a.k - b.k)
.map((a) => a.s);
vIndex.value = Math.floor(
Math.random() * Math.min(MAX_VOC, vList.value.length),
);
if (!v.value) return;
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function makeNewExIndex() {
if (!v.value) return;
if (v.value.examples && v.value.examples.length > 1) {
const newValue = Math.floor(Math.random() * (v.value.examples.length - 1));
exIndex.value = newValue >= exIndex.value ? newValue + 1 : newValue;
nextTick(() => {
if (saud.value && quizState.isAutoplayAudio) {
saud.value.playAudio();
}
});
}
}
function mark(isRight: boolean) {
exNotes.value = "";
nextTick(() => {
if (sentenceDetails.value) {
sentenceDetails.value.open = false;
}
if (quizState.isAutoplayAudio) {
nextTick(() => {
if (saud.value) {
saud.value.playAudio();
} else if (vaud.value && quizState.isKoFirst) {
vaud.value.playAudio();
}
});
}
});
if (isRight) {
vList.value = [...vList.value.filter((_, i) => i !== vIndex.value)];
vIndex.value = -1;
if (vList.value.length <= 1) {
makeVList();
return;
}
}
const i = Math.floor(
Math.random() * (Math.min(MAX_VOC, vList.value.length) - 1),
);
if (i >= vIndex.value) {
vIndex.value = i + 1;
} else {
vIndex.value = i;
}
if (!v.value) return;
const ex = v.value.examples;
exIndex.value = ex ? Math.floor(Math.random() * ex.length) : 0;
}
function updateCats(cat: string) {
const i = quizState.vChosenCats.indexOf(cat);
if (i === -1) {
quizState.vChosenCats = [...quizState.vChosenCats, cat];
} else {
quizState.vChosenCats = [
...quizState.vChosenCats.filter((_, j) => j !== i),
];
}
makeVList();
saveQuizState();
}
async function onVContentSave() {
if (!v.value) return;
const oldV = v.value;
const newV = Voc.parse({ ko: oldV.ko, ...yaml.parse(vEditContent.value) });
let key = "";
let vKey = oldV.ko;
Object.entries(vocabulary).map(([k, vs]) => {
const vCount = new Map<string, number>();
vs.map((v, i_vs) => {
let vK = v.ko;
let i = vCount.get(v.ko) || 0;
if (i) {
vK = vK + `(${i})`;
}
vCount.set(v.ko, i + 1);
if (oldV.ko === v.ko && oldV.en === v.en) {
key = JSON.stringify([fmKey, k]);
vKey = vK;
vocabulary[k][i_vs] = newV;
}
});
});
if (supabase && key) {
const user_id = await supabase.auth.getUser().then((r) => r.data.user?.id);
await supabase.from("quiz").upsert(
{
url: page.value.filePath,
key,
v: vKey,
data: newV,
user_id,
},
{ onConflict: "url,key,v,user_id" },
);
}
const newVList = [...vList.value];
newVList[vIndex.value] = newV;
vList.value = newVList;
isEdit.value = false;
}
const markdownIt = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
});
let loadingIntervalId = 0;
async function explainSentence(ko: string) {
if (loadingIntervalId) return;
ko = ko.replaceAll("**", "");
exNotesLoading.value = ".";
loadingIntervalId = window.setInterval(() => {
exNotesLoading.value += ".";
}, 1000);
// Needs to be specified, or DeepSeek will vacillate between Korean, English, and Chinese.
const LANG_EXPLAIN = "Japanese";
for await (const data of LLMstream({
model: "deepseek-v4-flash",
messages: [
{
role: "system",
content: `
Explain in ${LANG_EXPLAIN} how this sentence works in 500 characters.
Give me key vocabularies in a table.
Don't give romanization forms.
`,
},
{ role: "user", content: ko },
],
})) {
if (data.done) break;
if (data.content) {
exNotes.value += data.content;
}
}
clearInterval(loadingIntervalId);
exNotesLoading.value = "";
loadingIntervalId = 0;
if (exNotes.value && supabase) {
const user_id = await supabase.auth.getUser().then((r) => r.data.user?.id);
await supabase.from("sentence-explain").upsert(
{
user_id,
ko,
explain: exNotes.value,
},
{ onConflict: "ko,user_id" },
);
}
}
function onModalClickOutside(ev: MouseEvent) {
if (!(ev.target instanceof HTMLElement)) return;
if (ev.target.matches('[name="edit"]')) return;
if (!modalRef.value) {
isEdit.value = false;
} else if (!modalRef.value.contains(ev.target.parentNode)) {
isEdit.value = false;
}
}
onMounted(async () => {
document.addEventListener("click", onModalClickOutside);
makeVList();
if (supabase) {
await supabase
.from("quiz")
.select<any, { v: string; data: IVoc }>("v,data")
.eq("url", page.value.filePath)
.then((r) => {
if (!r.data?.length) return;
const replacement = new Map<string, IVoc>();
for (const { v, data } of r.data) {
replacement.set(v, data);
}
Object.entries(vocabulary).map(([k, vs]) => {
const vCount = new Map<string, number>();
vocabulary[k] = vs.map((v) => {
let vKey = v.ko;
let i = vCount.get(v.ko) || 0;
if (i) {
vKey = vKey + `(${i})`;
}
vCount.set(v.ko, i + 1);
return replacement.get(vKey) || v;
});
});
});
}
// const newVList = Object.entries(vocabulary)
// .filter(([k]) => vChosenCats.value.includes(k))
// .flatMap(([, vs]) => vs);
// vList.value = vListOriginalIndex.value
// .map((o, i) => {
// o.s = newVList[i] || o.s;
// return o;
// })
// .sort((a, b) => a.k - b.k)
// .map((o) => o.s);
makeVList();
});
onBeforeUnmount(() => {
document.addEventListener("click", onModalClickOutside);
if (loadingIntervalId) {
clearInterval(loadingIntervalId);
}
});
</script>
<style module>
.mark {
float: right;
> * + * {
margin-inline-start: 1em;
}
}
.notes {
div + div,
p + p {
margin-top: 0.5em !important;
}
}
.options {
label {
white-space: nowrap;
}
* + label {
margin-inline-start: 0.5em;
}
label[role="switch"] {
--slider-width: 50px;
position: relative;
display: inline-block;
width: calc(var(--slider-width) + 4em);
height: 24px;
input[role="switch"] {
opacity: 0;
width: 0;
height: 0;
}
/* The track */
[role="slider"] {
position: absolute;
cursor: pointer;
background-color: #ccc;
border-radius: 24px;
width: var(--slider-width);
height: 100%;
transition: background-color 0.3s;
+ span {
margin-left: calc(var(--slider-width) + 0.5em);
}
}
/* The circular slider */
[role="slider"]::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 4px;
bottom: 2px;
background-color: rgb(87, 87, 87);
border-radius: 50%;
transition: transform 0.3s;
}
/* Toggled state */
input:not(:checked) {
+ [role="slider"] {
/* background-color: #4caf50; */
background-color: gainsboro;
}
+ [role="slider"]::before {
transform: translateX(23px);
}
}
}
}
.modal {
--margin-top: 120px;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100vw - 4em);
max-width: 1000px;
nav {
display: flex;
flex-direction: row;
background-color: rgba(128, 128, 128, 0.8);
padding: 0.5em;
border-radius: 0.5em 0.5em 0 0;
* + * {
margin-inline-start: 1em;
}
}
textarea {
width: 100%;
max-height: calc(90vh - var(--margin-top));
padding: 1em;
border-radius: 0 0 0.5em 0.5em;
font-family: sans-serif;
resize: none;
}
}
</style>
+57 -2
View File
@@ -1,10 +1,12 @@
<template>
<div :class="$style.container">
<iframe
ref="iframe"
:class="$style.iframe"
width="100%"
height="100%"
:src="'https://www.youtube.com/embed/' + $props.id"
:src="url.href"
:title="$props.title"
frameborder="0"
allow="
accelerometer;
@@ -13,14 +15,67 @@
gyroscope;
picture-in-picture;
fullscreen;
clipboard-write;
web-share;
"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
>
</iframe>
</div>
</template>
<script setup lang="ts">
defineProps<{ id: string; title?: string }>();
import { onBeforeUnmount, onMounted, ref, useTemplateRef } from "vue";
const props = defineProps<{
id?: string;
list?: string;
title?: string;
}>();
const url = new URL(
`https://www.youtube.com/embed/${props.id || "videoseries"}`,
);
url.searchParams.set("enablejsapi", "1");
if (props.list) {
url.searchParams.set("list", props.list);
}
const iframe = useTemplateRef("iframe");
const player = ref();
function onOrientationChanged() {
if (player.value && iframe.value) {
if (
window.screen.orientation.type.startsWith("landscape") &&
player.value.getPlayerState() === 1
) {
iframe.value.requestFullscreen();
return;
}
}
if (document.fullscreenElement) {
document.exitFullscreen();
}
}
onMounted(() => {
const { YT } = window as any;
if (YT?.Player && iframe.value) {
player.value = new YT.Player(iframe.value);
}
window.screen.orientation.addEventListener("change", onOrientationChanged);
});
onBeforeUnmount(() => {
window.screen.orientation.removeEventListener("change", onOrientationChanged);
if (document.fullscreenElement) {
document.exitFullscreen();
}
});
</script>
<style module>
-7
View File
@@ -1,10 +1,3 @@
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.spoiler:not(:hover) {
filter: blur(5px);
}
+10 -1
View File
@@ -7,12 +7,14 @@ import { h } from "vue";
import AudioButton from "./components/AudioButton.vue";
import Details from "./components/Details.vue";
import SentenceQuiz from "./components/SentenceQuiz.vue";
// import PDF from "./components/PDF.vue";
import TTS from "./components/TTS.vue";
import VocabQuiz from "./components/VocabQuiz.vue";
import YouTube from "./components/YouTube.vue";
import type { Theme } from "vitepress";
import { signIn } from "./supabase.ts";
export default {
extends: DefaultTheme,
Layout: () => {
@@ -24,8 +26,15 @@ export default {
// ...
app.component("AudioButton", AudioButton);
app.component("Details", Details);
app.component("SentenceQuiz", SentenceQuiz);
app.component("TTS", TTS);
app.component("VocabQuiz", VocabQuiz);
app.component("YouTube", YouTube);
// app.component("PDF", PDF);
},
} satisfies Theme;
//@ts-ignore
if (!import.meta.env.SSR) {
signIn();
}
+115
View File
@@ -0,0 +1,115 @@
import { fetch } from "@tauri-apps/plugin-http";
//@ts-ignore
const { VITE_LLM_API_KEY } = import.meta.env;
// Minimal type definitions
type DeepSeekChunk = {
choices: Array<{
delta: {
content?: string;
reasoning_content?: string;
};
}>;
};
// Type guard - simple and effective
function isValidChunk(data: unknown): data is DeepSeekChunk {
return (
typeof data === "object" &&
data !== null &&
"choices" in data &&
Array.isArray((data as any).choices) &&
(data as any).choices.length > 0 &&
"delta" in (data as any).choices[0]
);
}
// Helper - one-liner safe parse
function safeParse<T>(text: string): T | null {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export async function* LLMstream(body: {
model: string;
messages: {
role: "system" | "user";
content: string;
}[];
}) {
if (!VITE_LLM_API_KEY) return;
let endpoint = "https://api.deepseek.com/chat/completions";
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${VITE_LLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
...body,
stream: true,
}),
});
if (!response.ok || !response.body) {
throw response;
}
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const event of events) {
for (const line of event.split("\n")) {
const DATA_START = "data: ";
if (!line.startsWith(DATA_START)) continue;
const payload = line.slice(DATA_START.length);
if (payload === "[DONE]") {
yield { content: "", done: true };
return;
}
const chunk = safeParse<DeepSeekChunk>(payload);
if (!chunk?.choices?.[0]?.delta) continue;
const delta = chunk.choices[0].delta;
// Yield content if present
if (delta.content) {
yield {
content: delta.content,
reasoning: delta.reasoning_content,
done: false,
};
}
// Yield reasoning separately if present and no content
if (delta.reasoning_content && !delta.content) {
yield {
content: "",
reasoning: delta.reasoning_content,
done: false,
};
}
}
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { createClient } from "@supabase/supabase-js";
const {
VITE_SUPABASE_URL,
VITE_SUPABASE_PUBLISHABLE_KEY,
VITE_SUPABASE_USER,
VITE_SUPABASE_USER_PASSWORD,
} =
//@ts-ignore
import.meta.env;
export const supabase = [
VITE_SUPABASE_URL,
VITE_SUPABASE_PUBLISHABLE_KEY,
VITE_SUPABASE_USER,
VITE_SUPABASE_USER_PASSWORD,
].every((v) => v)
? createClient(VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY)
: null;
export async function signIn() {
if (!supabase) return;
await supabase.auth.signInWithPassword({
email: VITE_SUPABASE_USER,
password: VITE_SUPABASE_USER_PASSWORD,
});
}
+40
View File
@@ -0,0 +1,40 @@
import * as z from "zod";
// 1. Define the interface manually (do not use z.infer here)
export interface IVoc {
ko: string;
ko_text?: string;
audio?: string;
en: string;
usage?: IVoc[];
notes?: string | string[];
examples?: IVoc[];
}
// 2. Annotate the schema with the manually defined interface
export const Voc: z.ZodType<IVoc> = z.lazy(() =>
z.object({
ko: z.string(),
ko_text: z.string().optional(),
audio: z.string().optional(),
en: z.string(),
usage: z.array(Voc).optional(),
notes: z.union([z.string(), z.array(z.string())]).optional(),
examples: z
.array(Voc)
.superRefine((val, ctx) => {
for (const x of val) {
if (!isEmp(x.ko)) {
ctx.addIssue(`${JSON.stringify(x)} must contain **emp** text`);
}
}
})
.optional(),
}),
);
export const fmValueParser = z.record(z.string(), z.array(Voc));
function isEmp(s: string) {
return /\*\*.+\*\*/.test(s);
}
+88
View File
@@ -0,0 +1,88 @@
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import type { Connect, Plugin, ViteDevServer } from "vite";
import { spawn } from "child_process";
export function gttsPlugin(): Plugin {
return {
name: "gtts",
configureServer(server: ViteDevServer) {
// Vite dev server middlewares are connect-style: (req, res, next)
server.middlewares.use(async (req, res, next) => {
const reqUrl = req.url;
if (!reqUrl) return next();
const url = new URL(reqUrl, "http://localhost");
if (url.pathname !== "/api/tts") return next();
const text = url.searchParams.get("text");
if (!text) return next();
try {
await gtts(text, req, res, next);
} catch (e) {
console.error(e, reqUrl);
next();
}
});
},
};
}
let fileList: Record<string, number> = {};
async function gtts(
text: string,
req: Connect.IncomingMessage,
res: ServerResponse<IncomingMessage>,
next: Connect.NextFunction,
) {
text = text.replace(/["]/g, " ");
const saveroot = ".gtts";
fs.mkdirSync(saveroot, { recursive: true });
const fileListJSON = path.join(saveroot, "gtts.json");
if (Object.keys(fileList).length === 0 && fs.existsSync(fileListJSON)) {
fileList = JSON.parse(fs.readFileSync(fileListJSON, "utf-8"));
}
let fileNo = fileList[text];
let isFileNoExists = Number.isInteger(fileNo);
if (!isFileNoExists) {
fileNo = Math.max(0, ...Object.values(fileList)) + 1;
}
const filename = `${fileNo}.mp3`;
const saveto = path.join(saveroot, filename);
if (!isFileNoExists || !fs.existsSync(saveto)) {
await new Promise((resolve, reject) => {
const p = spawn(
"gtts-cli",
[
`"${text}"`,
...["--lang", "ko"],
...["--output", path.join(process.cwd(), saveto)],
],
{
stdio: "inherit",
shell: true,
},
);
p.on("error", reject).on("close", resolve);
});
fileList[text] = fileNo;
console.log(`gtts: ${text} saved to ${saveto}`);
fs.writeFileSync(fileListJSON, JSON.stringify(fileList, null, 2));
} else {
// console.log(`gtts: ${text} loaded from ${saveto}`);
}
res.setHeader("Content-Type", "audio/mpeg");
fs.createReadStream(saveto).pipe(res);
}
@@ -0,0 +1,110 @@
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import type { Connect, Plugin, ViteDevServer } from "vite";
import sqlite3 from "better-sqlite3";
import { Tokenizer } from "./tokenizer";
export function mp3DownloaderPlugin(): Plugin {
return {
name: "mp3-downloader",
async configureServer(server: ViteDevServer) {
const db = sqlite3(".sentence-mp3.db");
db.exec(/* sql */ `
CREATE TABLE IF NOT EXISTS sentence (
id INTEGER NOT NULL PRIMARY KEY,
ko TEXT NOT NULL,
original TEXT NOT NULL,
[url] TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sentence_u_url ON sentence([url]);
CREATE VIRTUAL TABLE IF NOT EXISTS sentence_fts USING fts5 (
ko,
original UNINDEXED,
[url] UNINDEXED,
content='sentence',
content_rowid='id'
);
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER IF NOT EXISTS t1_ai AFTER INSERT ON sentence BEGIN
INSERT INTO sentence_fts(rowid, ko) VALUES (new.id, new.ko);
END;
CREATE TRIGGER IF NOT EXISTS t1_ad AFTER DELETE ON sentence BEGIN
INSERT INTO sentence_fts(fts_idx, rowid, ko) VALUES('delete', old.id, old.ko);
END;
CREATE TRIGGER IF NOT EXISTS t1_au AFTER UPDATE ON sentence BEGIN
INSERT INTO sentence_fts(fts_idx, rowid, ko) VALUES('delete', old.id, old.ko);
INSERT INTO sentence_fts(rowid, ko) VALUES (new.id, new.ko);
END;
`);
let tokenizer: Tokenizer | null = null;
// Vite dev server middlewares are connect-style: (req, res, next)
server.middlewares.use(async (req, res, next) => {
const reqUrl = req.url;
if (!reqUrl || !reqUrl.startsWith("/uploads/")) return next();
const url = new URL(reqUrl, "http://localhost");
if (!url.pathname.endsWith(".mp3")) return next();
try {
// if (!tokenizer) {
// tokenizer = await Tokenizer.init();
// }
await mp3Downloader(db, tokenizer, url, res, next);
} catch (e) {
console.error(e, reqUrl);
next();
}
});
},
};
}
async function mp3Downloader(
db: ReturnType<typeof sqlite3>,
tokenizer: Tokenizer,
url: URL,
res: ServerResponse<IncomingMessage>,
next: Connect.NextFunction,
) {
const downloadURL = new URL(
`https://www.howtostudykorean.com/wp-content${url.pathname}`,
);
// const text = url.searchParams.get("text");
// if (text) {
// db.prepare(
// "INSERT OR IGNORE INTO sentence ([url], ko, original) VALUES (?, ?, ?)",
// ).run(downloadURL.href, tokenizer.normalize(text), text);
// }
const segs = url.pathname.slice(1).split("/");
const filename = segs.pop()!;
const saveroot = "docs/public";
const saveto = path.join(saveroot, ...segs, filename);
if (fs.existsSync(saveto)) {
return next();
}
fs.mkdirSync(path.join(saveroot, ...segs), { recursive: true });
const r = await fetch(downloadURL, { redirect: "follow" });
if (!r.ok) {
console.error(r);
return next();
}
fs.writeFileSync(saveto, Buffer.from(await r.arrayBuffer()));
// console.log(fs.statSync(saveto));
console.log("saved to", saveto);
res.setHeader("Content-Type", "audio/mpeg");
fs.createReadStream(saveto).pipe(res);
}
+22
View File
@@ -0,0 +1,22 @@
import { MeCab } from "kuromoji-ko";
export class Tokenizer {
static async init() {
return new Tokenizer(
await MeCab.create({
engine: "ko",
dictPath: "./node_modules/kuromoji-ko/dict",
}),
);
}
private constructor(public mecab: MeCab) {}
normalize(s: string) {
return this.mecab
.parse(s)
.filter((t) => t.pos.some((p) => /^[NVM]/.test(p)))
.map((t) => t.lemma)
.join(" ");
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
# Theme Lessons
Although our regular lessons present all the grammar and vocabulary you need to learn Korean, they do not offer specific sentences or information tailored to a specific theme. You might be able to find some sentences related to school, some sentences related to weather, and some sentences related to transportation scattered across our various lessons, these are by no means organized in one place. That is… until now.
The purpose of our Theme Lessons is to present you with sentences and vocabulary that you would come across in a specific situation. For example, if you were a student (or teacher) in a school, you might be interested in learning sentences and vocabulary that would be useful in this setting.
The purpose of these Theme Lessons is _not_ to teach the grammar within the sentences provided. However, in order to understand most of the grammar within the Theme Lessons, we suggest that you complete at least Lesson 25 of our regular lessons. On top of this (especially for learners still in Unit 1 of our regular lessons), there will definitely be some grammar that you are not familiar with. My original goal was to have the Unit 1 Theme Lessons present sentences that only used grammar introduced in Unit 1. I realized that the grammar introduced until Lesson 25 is not sufficient to make a ton of natural sentences.
Use this as an opportunity to maybe skip ahead to some grammatical principles that are more common but a little bit advanced. The most common grammatical principles that you might find in the Theme Lessons of Unit 1 that have not been introduced yet are:
| \~는 것 | [Lesson 26](../unit-2/26-describing-nouns-with-verbs.md) |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 중 | [Lesson 33](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-26-33/lesson-33/ "Lesson 33: ~중 (하는 중), (~중에)") |
| \~아/어서 | [Lesson 37](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-34-41/lesson-37/ "Lesson 37: Because: ~아/어서") |
| 때문에 | [Lesson 38](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-34-41/lesson-38/ "Lesson 38: Because: ~기 때문에") |
| \~아/어 주다 | [Lesson 41](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-34-41/lesson-41/ "Lesson 41: To Give: 주다, 아/어 주다") |
| \~자 | [Lesson 44](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-42-50/lesson-44/) |
| \~ㄹ/을래(요) | [Lesson 44](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-42-50/lesson-44/) |
| \~ㄹ/을 수 있다 | [Lesson 45](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-42-50/lesson-45/ "Lesson 45: I can, I can’t – ~ㄹ 수 있다/없다") |
| \~아/어 야 하다 | [Lesson 46](https://www.howtostudykorean.com/unit-2-lower-intermediate-korean-grammar/unit-2-lessons-42-50/lesson-46/) |
| \~ㄹ/을게(요) | [Lesson 63](https://www.howtostudykorean.com/unit-3-intermediate-korean-grammar/unit-3-lessons-59-66/lesson-63/ "Lesson 63: ~ㄹ/을까(요), ~ㄹ/을게요") |
| \~ㄹ/을까(요) | [Lesson 63](https://www.howtostudykorean.com/unit-3-intermediate-korean-grammar/unit-3-lessons-59-66/lesson-63/ "Lesson 63: ~ㄹ/을까(요), ~ㄹ/을게요") |
| \~는데 | [Lesson 76](https://www.howtostudykorean.com/upper-intermediate-korean-grammar/unit-4-lessons-76-83/lesson-76/ "Lesson 76: ~는데 and ~는 데 in Korean") |
The list above is by no means complete, but by knowing the grammar introduced in Unit 1, and having a general idea of some of these more difficult grammatical principles, you will definitely be able to challenge yourself with learning some great sentences.
In addition to the pile of sentences that will be provided, common words that relate to the particular theme will be given. All of the vocabulary words introduced in our Theme Lessons will also be introduced somewhere in our regular lessons. The purpose of these Theme Lessons is to organize all of the words (and sentences) that focus on a central subject.
- [Unit 1: #1 – School](./01-school.md)
- [Unit 1: #2 – Transportation](./02-transportation.md)
- [Unit 1: #3 – Eating](./03-eating.md)
- [Unit 1: #4 – Shopping](./04-shopping.md)
- [Unit 1: #5 – Weather](./05-weather.md)
In time, we will continue to update more Theme Lessons to this Unit.
In addition, we will eventually make Theme Lessons that fall under later Units. Theme Lessons under later Units will gradually be focused on more and more complex themes.
## Analyzing Korean Signs
<YouTube list=PLpkY2PdR9aLO_V0LFnP3_cdapJBcuG3-O />
## Sentence Practice
<YouTube list=PLpkY2PdR9aLOKW0N5CqSDNJF7rVaQCwpX />
## Dictation Practice
<YouTube list=PLpkY2PdR9aLMLiZnUBQFJxvr2Bs4eMbI6 />
## 20 SECOND LOOKS AT RAW KOREA
<YouTube list=PLpkY2PdR9aLOiwxKcoih94VHS4wGvOmQx />
---
How to study Korean © 2026. All Rights Reserved. | [Privacy Policy](http://www.howtostudykorean.com/privacy-policy/) | [Contact Us](http://www.howtostudykorean.com/other-stuff/contact-us/)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+43
View File
@@ -7,7 +7,50 @@ hero:
tagline: "Korean grammar edited"
---
[Sound Rules](./ภาษาเกาหลีเรียนง่าย/1/04-sound-rules.md)
## [Themes](./howtostudykorean/theme/index.md)
- [School](./howtostudykorean/theme/01-school.md)
- [Transportation](./howtostudykorean/theme/02-transportation.md)
- [Eating](./howtostudykorean/theme/03-eating.md)
- [Shopping](./howtostudykorean/theme/04-shopping.md)
- [Weather](./howtostudykorean/theme/05-weather.md)
## Unit 1
- [Lesson 1: Basic Sentences](./howtostudykorean/unit-1/01-sentences.md)
- [Lesson 2: Particles](./howtostudykorean/unit-1/02-particles.md)
- [Lesson 3: Verbs/Adjectives](./howtostudykorean/unit-1/03-verbs.md)
- [Lesson 4: Adjectives ~ㄴ/은](./howtostudykorean/unit-1/04-adjectives.md)
- [Lesson 5: Past, Present, Future](./howtostudykorean/unit-1/05-tenses.md)
- [Lesson 6: Honorifics](./howtostudykorean/unit-1/06-honorifics.md)
- [Lesson 7: Irregulars](./howtostudykorean/unit-1/07-irregulars.md)
- [Lesson 8: Adverbs](./howtostudykorean/unit-1/08-adverbs.md)
---
- [Lesson 9: Conjugate 이다](./howtostudykorean/unit-1/09-conjugate-이다.md)
- [Lesson 10: Numbers](./howtostudykorean/unit-1/10-numbers.md)
- [Lesson 11: Date, Time, Duration](./howtostudykorean/unit-1/11-time.md)
- [Lesson 12: Particles 들, 만, 에서, 부터, 까지, (으)로](./howtostudykorean/unit-1/12-more-particles.md)
- [Lesson 13: and, with, to, from, for, about](./howtostudykorean/unit-1/13-and-with.md)
- [Lesson 14: Passive Verbs](./howtostudykorean/unit-1/14-passive.md)
- [Lesson 15: 들다, Homonyms, Being Sick, Different/Similar/Same](./howtostudykorean/unit-1/15-good-carry-similar.md)
- [Lesson 16: Noun + ~적, ~적으로, ~적이다, ~스럽다](./howtostudykorean/unit-1/16-relating-to.md)
---
- [Lesson 17: Connecting Particle \~고 and \~고 싶다 “I want to”](./howtostudykorean/unit-1/17-connecting-want.md)
- [Lesson 18: Present Progressive \~고 있다; To be Getting \~아/어지다](./howtostudykorean/unit-1/18-present-progressive.md)
- [Lesson 19: Comparatives and Superlatives: 더, 보다, 가장/제일](./howtostudykorean/unit-1/19-comparatives.md)
- [Lesson 20: Well and Poorly: 잘 and 못](./howtostudykorean/unit-1/20-well-poorly.md)
- [Lesson 21: Asking Questions: Why, When, Where, Who](./howtostudykorean/unit-1/21-why-when-where-who.md)
- [Lesson 22: Asking Questions: How, What, Which, How Many](./howtostudykorean/unit-1/22-how-which-what-how-many.md)
- [Lesson 23: ㅎ Irregular: Colors, 이렇다, 그렇다 and 저렇다](./howtostudykorean/unit-1/23-ㅎ-irregulars-colors.md)
- [Lesson 24: Before, After, Since, Within (전/후/이래로/이내)](./howtostudykorean/unit-1/24-before-after-since-within.md)
- [Lesson 25: Anybody, Everybody, Somebody, Nobody, etc.](./howtostudykorean/unit-1/25-anybody-everybody.md)
## Unit 2
- [Lesson 26: \~는 것 Describing Nouns with Verbs](./howtostudykorean/unit-2/26-describing-nouns-with-verbs.md)
File diff suppressed because it is too large. Load diff
+16 -4
View File
@@ -2,6 +2,7 @@
"name": "ko-grammar",
"version": "1.0.0",
"description": "",
"type": "module",
"main": "index.js",
"scripts": {
"start": "tauri dev",
@@ -20,12 +21,23 @@
"license": "ISC",
"packageManager": "pnpm@10.30.3",
"devDependencies": {
"@tauri-apps/cli": "^2.10.1",
"@tauri-apps/cli": "^2.11.4",
"vitepress": "2.0.0-alpha.17",
"vue": "^3.5.30"
"vue": "^3.5.39"
},
"dependencies": {
"pdfjs-dist": "^5.5.207",
"tauri-plugin-tts-api": "^0.1.3"
"@supabase/supabase-js": "^2.110.5",
"@tauri-apps/plugin-http": "2.6.0",
"@types/better-sqlite3": "^9.6.0",
"@types/markdown-it": "^14.2.0",
"@types/node": "^24.13.3",
"better-sqlite3": "^13.0.3",
"kuromoji-ko": "^1.0.8",
"markdown-it": "^14.3.0",
"pdfjs-dist": "^5.7.284",
"tauri-plugin-tts-api": "0.2.1",
"vite": "^8.1.5",
"yaml": "^2.9.0",
"zod": "^4.4.3"
}
}
+1194 -562
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,2 +1,3 @@
onlyBuiltDependencies:
- better-sqlite3
- esbuild
+98
View File
@@ -0,0 +1,98 @@
import sqlite3 from "better-sqlite3";
import { MeCab } from "kuromoji-ko";
import fs from "node:fs/promises";
import yaml from "yaml";
const mecab = await MeCab.create({
engine: "ko",
dictPath: "./node_modules/kuromoji-ko/dict",
});
const db = sqlite3(".sentence.db");
db.exec(/* sql */ `
CREATE TABLE IF NOT EXISTS sentence (
ko TEXT NOT NULL,
en TEXT,
original TEXT NOT NULL,
[url] TEXT NOT NULL,
notes TEXT,
filepath TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sentence_u ON sentence(original, [url]);
CREATE VIRTUAL TABLE IF NOT EXISTS sentence_fts USING fts5 (
ko,
en,
original UNINDEXED,
[url] UNINDEXED,
notes UNINDEXED,
filepath UNINDEXED,
content='sentence',
tokenize='porter'
);
-- Triggers to keep the FTS index up to date.
CREATE TRIGGER IF NOT EXISTS t1_ai AFTER INSERT ON sentence BEGIN
INSERT INTO sentence_fts(rowid, ko, en) VALUES (new.rowid, new.ko, new.en);
END;
CREATE TRIGGER IF NOT EXISTS t1_ad AFTER DELETE ON sentence BEGIN
INSERT INTO sentence_fts(fts_idx, rowid, ko, en) VALUES('delete', old.rowid, old.ko, old.en);
END;
CREATE TRIGGER IF NOT EXISTS t1_au AFTER UPDATE ON sentence BEGIN
INSERT INTO sentence_fts(fts_idx, rowid, ko, en) VALUES('delete', old.rowid, old.ko, old.en);
INSERT INTO sentence_fts(rowid, ko, en) VALUES (new.rowid, new.ko, new.en);
END;
`);
const stmt = db.prepare(/* sql */ `
INSERT OR IGNORE INTO sentence (ko, original, en, [url], notes, filepath)
VALUES (:parsed, :ko, :en, :audio, :notes, :filepath)
`);
const tx = db.transaction((ss) => {
for (const s of ss) {
if (s.notes === "Gemini") continue;
stmt.run({
ko: s.ko.replaceAll("**", ""),
parsed: mecab
.parse(s.ko)
.filter((t) => t.pos.some((p) => /^[NVM]/.test(p)))
.map((t) => t.lemma)
.join(" "),
en: (s.en || "").replaceAll("**", ""),
audio: s.audio || "",
notes: Array.isArray(s.notes) ? s.notes.join("\n") : s.notes || null,
filepath: s.filepath,
});
}
});
const SEPARATOR = "---\n";
for await (const md of fs.glob("docs/**/*.md")) {
let text = await fs.readFile(md, "utf-8");
if (!text.startsWith(SEPARATOR)) continue;
try {
const ss = [];
const { vocabulary, sentence } =
yaml.parse(text.slice(SEPARATOR.length).split(SEPARATOR, 1)[0]) || {};
if (!vocabulary) continue;
for (const { examples = [] } of Object.values(vocabulary).flat(1)) {
ss.push(...examples.map((o) => ({ ...o, filepath: md })));
}
if (sentence) {
ss.push(
...Object.values(sentence)
.flat(1)
.map((o) => ({ ...o, filepath: md })),
);
}
tx(ss);
} catch (e) {
console.error(e, md);
}
}
+1215 -1388
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -23,4 +23,5 @@ serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri-plugin-log = "2"
tauri-plugin-tts = "0.1.3"
tauri-plugin-tts = "0.2.1"
tauri-plugin-http = "2"
+5 -1
View File
@@ -7,6 +7,10 @@
],
"permissions": [
"core:default",
"tts:default"
"tts:default",
{
"identifier": "http:default",
"allow": [{ "url": "https://api.deepseek.com/chat/completions" }]
}
]
}
+1
View File
@@ -62,6 +62,7 @@ dependencies {
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
@@ -1,11 +1,44 @@
package cc.polv.ko_grammar
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val rootView: View = findViewById(android.R.id.content)
ViewCompat.setOnApplyWindowInsetsListener(rootView) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime())
val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val bottomPadding = if (imeVisible) imeHeight else systemBars.bottom
v.setPadding(
systemBars.left,
systemBars.top,
systemBars.right,
bottomPadding
)
insets
}
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
webView.setBackgroundColor(0x00000000)
webView.fitsSystemWindows = true
android.util.Log.i("MainActivity", "WebView created, waiting for Rust hide() call...")
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
<resources>
<string name="app_name">ko-grammar</string>
<string name="main_activity_title">ko-grammar</string>
<string name="app_name">"ko-grammar"</string>
<string name="main_activity_title">"ko-grammar"</string>
</resources>
+1
View File
@@ -1,6 +1,7 @@
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_tts::init())
.setup(|app| {
if cfg!(debug_assertions) {
+35
View File
@@ -0,0 +1,35 @@
// testCases.map(c=>mecab.parse(c.sentence).filter(t=>t.pos.some(p=>/^[NVM]/.test(p))).map(t=>t.lemma));
export const testCases = [
{
sentence: "학교에서 공부했어요",
expected: ["학교", "공부"],
},
{
sentence: "맛있는 김치찌개를 먹었어요",
expected: ["맛있다", "김치찌개", "먹다"],
},
{
sentence: "사랑하는 가족에게 선물을 줬어요",
expected: ["사랑", "가족", "선물", "주다"],
},
{
sentence: "빨리 달려가자",
expected: ["빨리", "달려가다"],
},
{
sentence: "예쁜 꽃이 피었어요",
expected: ["예쁘다", "꽃", "피다"],
},
{
sentence: "그는 학교에 갔다",
expected: ["그", "학교", "가다"],
},
{
sentence: "열심히 공부하는 학생입니다",
expected: ["열심히", "공부", "학생", "이다"],
},
{
sentence: "어제 영화를 봤어요",
expected: ["어제", "영화", "보다"],
},
];