Compare commits
193
Commits
5e4eb495f3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e8e018445 | ||
|
|
363a728fc3 | ||
|
|
df05d4e875 | ||
|
|
ef57ecbc9b | ||
|
|
e51ca48bb9 | ||
|
|
f43a23bab2 | ||
|
|
4c7605156a | ||
|
|
30f48e1fc7 | ||
|
|
ee6d704d48 | ||
|
|
11b178f6f8 | ||
|
|
9c5c4d3689 | ||
|
|
162813e949 | ||
|
|
f61aaa4b40 | ||
|
|
ed5dc07f6c | ||
|
|
ffdeee6a76 | ||
|
|
f2195dd2fb | ||
|
|
5c014ba6e1 | ||
|
|
4703409a35 | ||
|
|
e68493ba35 | ||
|
|
382f642205 | ||
|
|
863142902c | ||
|
|
e9d02dbec7 | ||
|
|
15af343be7 | ||
|
|
ddcffe7a63 | ||
|
|
9da12902d7 | ||
|
|
ffeac1c0ae | ||
|
|
5c25d24073 | ||
|
|
0c628cc3f5 | ||
|
|
608d02c12a | ||
|
|
8cb95a5b9d | ||
|
|
4f891ae123 | ||
|
|
e8e7ff86f6 | ||
|
|
dd36c6fc93 | ||
|
|
daa5c73cec | ||
|
|
c2d5b4c2f2 | ||
|
|
bb20f289ff | ||
|
|
745c7dfc86 | ||
|
|
5e6022167d | ||
|
|
fc7446f62a | ||
|
|
b07a977495 | ||
|
|
6ef128292e | ||
|
|
cfe0b238d9 | ||
|
|
4813cefd18 | ||
|
|
2b5b836203 | ||
|
|
cac640fdea | ||
|
|
1118bda30f | ||
|
|
9dfae88805 | ||
|
|
7e8c38a80f | ||
|
|
7a4c71675b | ||
|
|
fced071837 | ||
|
|
82b51c0703 | ||
|
|
e734d09039 | ||
|
|
075fe19382 | ||
|
|
54277ced6b | ||
|
|
dca24a710c | ||
|
|
2c391af08b | ||
|
|
7a2571443c | ||
|
|
fe01d84777 | ||
|
|
8711bd2b4d | ||
|
|
60875f20d6 | ||
|
|
7b93eccc00 | ||
|
|
1ef4f2c02d | ||
|
|
a2d6c47133 | ||
|
|
8da598e7be | ||
|
|
98bb90a380 | ||
|
|
a9ff08f0ef | ||
|
|
0f26cdd272 | ||
|
|
2ead6da243 | ||
|
|
9e7440168e | ||
|
|
870f6c890f | ||
|
|
b13eab46de | ||
|
|
8c6ffd282c | ||
|
|
90bfbb9e72 | ||
|
|
bab239fb92 | ||
|
|
d22e27a285 | ||
|
|
38605f1ad8 | ||
|
|
0c3b7f9c6f | ||
|
|
d0355514ed | ||
|
|
501f21e532 | ||
|
|
c836385bde | ||
|
|
f707f2025b | ||
|
|
01c3ec7c91 | ||
|
|
e4621ab0b8 | ||
|
|
bc46de9e41 | ||
|
|
16aea2f26f | ||
|
|
338591c1b2 | ||
|
|
03238e23fa | ||
|
|
5b4e32b8b9 | ||
|
|
614432579b | ||
|
|
b992d81733 | ||
|
|
95e204b728 | ||
|
|
2c8dd1b145 | ||
|
|
564020bd5e | ||
|
|
6f431bb6d1 | ||
|
|
5c236885ce | ||
|
|
ea5991cc4e | ||
|
|
045f62d951 | ||
|
|
e6a8a6027c | ||
|
|
afc805c50e | ||
|
|
b14b2fde89 | ||
|
|
b37403a6ba | ||
|
|
72cd3bcd09 | ||
|
|
ee87eeb616 | ||
|
|
58240ff520 | ||
|
|
7c48643759 | ||
|
|
e682dfae6e | ||
|
|
8d5407767c | ||
|
|
787aa1a4ac | ||
|
|
e29505cf1b | ||
|
|
51b15046be | ||
|
|
12cf6b135b | ||
|
|
05d8bb3828 | ||
|
|
116ce29cb3 | ||
|
|
cb3678cc4f | ||
|
|
0a63a4a694 | ||
|
|
e642258f47 | ||
|
|
5184aefa88 | ||
|
|
dd2708f905 | ||
|
|
cfa93c6e12 | ||
|
|
e295704301 | ||
|
|
1456704918 | ||
|
|
cfb455d6ad | ||
|
|
2b62c9b9e0 | ||
|
|
f3c3b2bbb3 | ||
|
|
09af058fa1 | ||
|
|
eff1227b20 | ||
|
|
9a821b7117 | ||
|
|
7f46518257 | ||
|
|
b110ac9df8 | ||
|
|
49832aee8b | ||
|
|
aa50b9b144 | ||
|
|
ade080e47f | ||
|
|
c1ecc0781d | ||
|
|
cb89942991 | ||
|
|
09c0f311fc | ||
|
|
1d35a7bd2c | ||
|
|
c0eb4d68a7 | ||
|
|
7b9b5691dd | ||
|
|
4dd8c6ad46 | ||
|
|
a8e489a485 | ||
|
|
593e0eb404 | ||
|
|
3169289432 | ||
|
|
e0b1a03e38 | ||
|
|
01f7b4c638 | ||
|
|
1c56b3fd53 | ||
|
|
37bb023398 | ||
|
|
3cafe0af89 | ||
|
|
0ff8e1eff3 | ||
|
|
70ed29f2c9 | ||
|
|
f279917d91 | ||
|
|
d9c15fadb0 | ||
|
|
073d077bb5 | ||
|
|
82065f55d5 | ||
|
|
183be56112 | ||
|
|
5c8ef1fa4d | ||
|
|
7f9e8b52df | ||
|
|
f02c72b745 | ||
|
|
1a2800f115 | ||
|
|
33712b7d82 | ||
|
|
23a0a1ac4e | ||
|
|
471fe87429 | ||
|
|
d11d095816 | ||
|
|
b2aa693f1a | ||
|
|
eb51015c6c | ||
|
|
c2f7aa1258 | ||
|
|
d669842d01 | ||
|
|
37e48e0132 | ||
|
|
5ece9265f1 | ||
|
|
ff80f700f9 | ||
|
|
7c39c708ae | ||
|
|
d72fe09cec | ||
|
|
4bbb881b68 | ||
|
|
ca55f7da86 | ||
|
|
0e2391f8bf | ||
|
|
38fa509148 | ||
|
|
33d1be6b12 | ||
|
|
29914b4d35 | ||
|
|
34cbb0d3fc | ||
|
|
3f175f89c7 | ||
|
|
9d54a51729 | ||
|
|
895c6cfc18 | ||
|
|
aad21bad28 | ||
|
|
cb43d051c4 | ||
|
|
03d14655fe | ||
|
|
2f5633abf5 | ||
|
|
0ec50d729c | ||
|
|
22839fe4a6 | ||
|
|
9a5cc2cc09 | ||
|
|
ec639ca5f4 | ||
|
|
a3a4a23c68 | ||
|
|
8e4beeaeaf | ||
|
|
57cd89a511 | ||
|
|
53bf3b72e8 |
No files matched your search
@@ -145,3 +145,7 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
docs/.vitepress/dist
|
docs/.vitepress/dist
|
||||||
docs/.vitepress/cache
|
docs/.vitepress/cache
|
||||||
|
docs/public/uploads/
|
||||||
|
|
||||||
|
.gtts/
|
||||||
|
*.db
|
||||||
Vendored
+11
-1
@@ -29,5 +29,15 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.maxTokenizationLineLength": 2500
|
"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
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,71 @@
|
|||||||
Contents edited from <https://www.howtostudykorean.com> is at [/docs/howtostudykorean/](/docs/howtostudykorean/).
|
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>
|
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** | | |
|
||||||
@@ -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;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
@@ -7,9 +9,18 @@ export default defineConfig({
|
|||||||
title: "ko-grammar",
|
title: "ko-grammar",
|
||||||
description: "Korean grammar edited",
|
description: "Korean grammar edited",
|
||||||
lang: "ko-KR",
|
lang: "ko-KR",
|
||||||
|
head: [
|
||||||
|
[
|
||||||
|
"script",
|
||||||
|
{
|
||||||
|
async: "async",
|
||||||
|
src: "https://www.youtube.com/iframe_api",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
themeConfig: {
|
themeConfig: {
|
||||||
socialLinks: [
|
socialLinks: [
|
||||||
{ icon: "github", link: "https://github.com/vuejs/vitepress" },
|
{ icon: "github", link: "https://git.polv.cc/polv/ko-grammar" },
|
||||||
],
|
],
|
||||||
outline: {
|
outline: {
|
||||||
level: "deep",
|
level: "deep",
|
||||||
@@ -65,10 +76,18 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
|
plugins: [
|
||||||
|
mp3DownloaderPlugin() as any,
|
||||||
|
// (Python) pip install gtts
|
||||||
|
// gtts will intercept /api/tts away from opentts:ko container
|
||||||
|
gttsPlugin(),
|
||||||
|
],
|
||||||
|
envDir: process.cwd(),
|
||||||
ssr: {
|
ssr: {
|
||||||
noExternal: [],
|
noExternal: [],
|
||||||
external: ["pdfjs-dist"], // don't bundle it for SSR — leave it browser-only
|
external: ["pdfjs-dist"], // don't bundle it for SSR — leave it browser-only
|
||||||
},
|
},
|
||||||
|
clearScreen: false,
|
||||||
server: {
|
server: {
|
||||||
/**
|
/**
|
||||||
* Allow Windows Firewall via Admin PowerShell with
|
* Allow Windows Firewall via Admin PowerShell with
|
||||||
@@ -80,6 +99,10 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
|
watch: {
|
||||||
|
// tell vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
@@ -87,6 +110,21 @@ export default defineConfig({
|
|||||||
port: 5174,
|
port: 5174,
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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>
|
<template>
|
||||||
<span>
|
<span>
|
||||||
|
<span ref="main"><slot /></span>
|
||||||
<button type="button" @click="playAudio()">▶️</button>
|
<button type="button" @click="playAudio()">▶️</button>
|
||||||
<audio ref="audio" style="display: none"></audio>
|
<audio ref="audio" style="display: none"></audio>
|
||||||
</span>
|
</span>
|
||||||
@@ -7,17 +8,50 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTemplateRef } from "vue";
|
import { useTemplateRef } from "vue";
|
||||||
|
import { stop as stopTTS } from "tauri-plugin-tts-api";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
src: string;
|
src: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
playAudio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mainEl = useTemplateRef("main");
|
||||||
const audioEl = useTemplateRef("audio");
|
const audioEl = useTemplateRef("audio");
|
||||||
|
|
||||||
function playAudio() {
|
async function playAudio() {
|
||||||
if (!audioEl.value) return;
|
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.src = props.src;
|
||||||
|
audioEl.value.currentTime = 0;
|
||||||
audioEl.value.play();
|
audioEl.value.play();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
audioEl.value.onerror = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioEl.value.src = src;
|
||||||
|
audioEl.value.currentTime = 0;
|
||||||
|
await audioEl.value.play();
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
@@ -3,8 +3,15 @@
|
|||||||
<span ref="main" :class="class" :lang="lang">
|
<span ref="main" :class="class" :lang="lang">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
<audio
|
||||||
|
style="display: none"
|
||||||
|
ref="audio"
|
||||||
|
@error="playAudioTTSPlugin()"
|
||||||
|
></audio>
|
||||||
<span>
|
<span>
|
||||||
<button type="button" @click="playAudio()">🗣️</button>
|
<button type="button" @click="playAudio()" :class="$style.speak">
|
||||||
|
🗣️
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -13,25 +20,62 @@
|
|||||||
import { useTemplateRef } from "vue";
|
import { useTemplateRef } from "vue";
|
||||||
import { speak, getVoices } from "tauri-plugin-tts-api";
|
import { speak, getVoices } from "tauri-plugin-tts-api";
|
||||||
|
|
||||||
const { lang = "ko-KR" } = defineProps<{
|
defineExpose({
|
||||||
|
playAudio,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lang = "ko-KR", text } = defineProps<{
|
||||||
class?: string;
|
class?: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
text?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const mainEl = useTemplateRef("main");
|
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() {
|
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);
|
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)];
|
const v = voices[Math.floor(Math.random() * voices.length)];
|
||||||
console.log(JSON.stringify(v));
|
console.log(JSON.stringify(v));
|
||||||
await speak({
|
await speak({
|
||||||
text: mainEl.value.innerText
|
text: mainText,
|
||||||
.replace(/[*]/gu, " ")
|
|
||||||
.replace(/[\p{Ps}\p{Pe}]/gu, ""),
|
|
||||||
language: null,
|
language: null,
|
||||||
rate: null,
|
rate: null,
|
||||||
volume: null,
|
volume: null,
|
||||||
@@ -39,7 +83,13 @@ async function playAudio() {
|
|||||||
queueMode: null,
|
queueMode: null,
|
||||||
voiceId: v.id,
|
voiceId: v.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="$style.container">
|
<div :class="$style.container">
|
||||||
<iframe
|
<iframe
|
||||||
|
ref="iframe"
|
||||||
:class="$style.iframe"
|
:class="$style.iframe"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
:src="'https://www.youtube.com/embed/' + $props.id"
|
:src="url.href"
|
||||||
|
:title="$props.title"
|
||||||
frameborder="0"
|
frameborder="0"
|
||||||
allow="
|
allow="
|
||||||
accelerometer;
|
accelerometer;
|
||||||
@@ -13,14 +15,67 @@
|
|||||||
gyroscope;
|
gyroscope;
|
||||||
picture-in-picture;
|
picture-in-picture;
|
||||||
fullscreen;
|
fullscreen;
|
||||||
|
clipboard-write;
|
||||||
|
web-share;
|
||||||
"
|
"
|
||||||
|
referrerpolicy="strict-origin-when-cross-origin"
|
||||||
|
allowfullscreen
|
||||||
>
|
>
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<style module>
|
||||||
|
|||||||
@@ -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) {
|
.spoiler:not(:hover) {
|
||||||
filter: blur(5px);
|
filter: blur(5px);
|
||||||
}
|
}
|
||||||
@@ -7,12 +7,14 @@ import { h } from "vue";
|
|||||||
|
|
||||||
import AudioButton from "./components/AudioButton.vue";
|
import AudioButton from "./components/AudioButton.vue";
|
||||||
import Details from "./components/Details.vue";
|
import Details from "./components/Details.vue";
|
||||||
|
import SentenceQuiz from "./components/SentenceQuiz.vue";
|
||||||
// import PDF from "./components/PDF.vue";
|
// import PDF from "./components/PDF.vue";
|
||||||
import TTS from "./components/TTS.vue";
|
import TTS from "./components/TTS.vue";
|
||||||
|
import VocabQuiz from "./components/VocabQuiz.vue";
|
||||||
import YouTube from "./components/YouTube.vue";
|
import YouTube from "./components/YouTube.vue";
|
||||||
|
|
||||||
import type { Theme } from "vitepress";
|
import type { Theme } from "vitepress";
|
||||||
|
import { signIn } from "./supabase.ts";
|
||||||
export default {
|
export default {
|
||||||
extends: DefaultTheme,
|
extends: DefaultTheme,
|
||||||
Layout: () => {
|
Layout: () => {
|
||||||
@@ -24,8 +26,15 @@ export default {
|
|||||||
// ...
|
// ...
|
||||||
app.component("AudioButton", AudioButton);
|
app.component("AudioButton", AudioButton);
|
||||||
app.component("Details", Details);
|
app.component("Details", Details);
|
||||||
|
app.component("SentenceQuiz", SentenceQuiz);
|
||||||
app.component("TTS", TTS);
|
app.component("TTS", TTS);
|
||||||
|
app.component("VocabQuiz", VocabQuiz);
|
||||||
app.component("YouTube", YouTube);
|
app.component("YouTube", YouTube);
|
||||||
// app.component("PDF", PDF);
|
// app.component("PDF", PDF);
|
||||||
},
|
},
|
||||||
} satisfies Theme;
|
} satisfies Theme;
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
if (!import.meta.env.SSR) {
|
||||||
|
signIn();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -7,7 +7,50 @@ hero:
|
|||||||
tagline: "Korean grammar edited"
|
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 5: Past, Present, Future](./howtostudykorean/unit-1/05-tenses.md)
|
||||||
- [Lesson 6: Honorifics](./howtostudykorean/unit-1/06-honorifics.md)
|
- [Lesson 6: Honorifics](./howtostudykorean/unit-1/06-honorifics.md)
|
||||||
- [Lesson 7: Irregulars](./howtostudykorean/unit-1/07-irregulars.md)
|
- [Lesson 7: Irregulars](./howtostudykorean/unit-1/07-irregulars.md)
|
||||||
- [Lesson 8: Adverbs](./howtostudykorean/unit-1/08-adverbs.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
@@ -2,6 +2,7 @@
|
|||||||
"name": "ko-grammar",
|
"name": "ko-grammar",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "tauri dev",
|
"start": "tauri dev",
|
||||||
@@ -20,12 +21,23 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.30.3",
|
"packageManager": "pnpm@10.30.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.10.1",
|
"@tauri-apps/cli": "^2.11.4",
|
||||||
"vitepress": "2.0.0-alpha.17",
|
"vitepress": "2.0.0-alpha.17",
|
||||||
"vue": "^3.5.30"
|
"vue": "^3.5.39"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pdfjs-dist": "^5.5.207",
|
"@supabase/supabase-js": "^2.110.5",
|
||||||
"tauri-plugin-tts-api": "^0.1.3"
|
"@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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+1194
-562
File diff suppressed because it is too large.
Load diff
@@ -1,2 +1,3 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- better-sqlite3
|
||||||
- esbuild
|
- esbuild
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1215
-1388
File diff suppressed because it is too large.
Load diff
@@ -23,4 +23,5 @@ serde = { version = "1.0", features = ["derive"] }
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
tauri = { version = "2.10.3", features = [] }
|
tauri = { version = "2.10.3", features = [] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-tts = "0.1.3"
|
tauri-plugin-tts = "0.2.1"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
@@ -7,6 +7,10 @@
|
|||||||
],
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"tts:default"
|
"tts:default",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [{ "url": "https://api.deepseek.com/chat/completions" }]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+1
@@ -62,6 +62,7 @@ dependencies {
|
|||||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
implementation("com.google.android.material:material:1.12.0")
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
package cc.polv.ko_grammar
|
package cc.polv.ko_grammar
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.webkit.WebView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
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...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ko-grammar</string>
|
<string name="app_name">"ko-grammar"</string>
|
||||||
<string name="main_activity_title">ko-grammar</string>
|
<string name="main_activity_title">"ko-grammar"</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
.plugin(tauri_plugin_tts::init())
|
.plugin(tauri_plugin_tts::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
|
|||||||
@@ -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: ["어제", "영화", "보다"],
|
||||||
|
},
|
||||||
|
];
|
||||||
Reference in new issue
Block a user