From b80deb2f6f8342222fd220321a4c0b256f1855f3 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 8 Sep 2026 19:11:40 +0200 Subject: [PATCH] chore: scaffold, CI, and the export bundle verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export bundle is the input to this port, not a sketch: the curriculum, the tutor prompt and the five logic modules are finished and tested. They land here byte-identical and stay that way. diff -r export/data data && diff -r export/lib lib diff -r export/prompt prompt && diff export/validate.mjs validate.mjs data/, lib/, prompt/ and validate.mjs sit at the repo root so validate.mjs runs verbatim with no path edits. All four are excluded from lint and formatting — they are not ours to restyle. Types for lib/ live alongside in types/ rather than as sibling .d.ts files, so the verbatim check stays a plain directory diff. CI runs the curriculum gate first, before anything else can pass: node validate.mjs PASS — 0 blocking, 0 advisory Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 40 + .gitignore | 24 + NOTICE.md | 45 + data/curriculum.json | 1426 +++++++++++++++++++++++ data/deck.json | 2363 ++++++++++++++++++++++++++++++++++++++ data/gloss-extra.json | 841 ++++++++++++++ data/grammar.json | 961 ++++++++++++++++ data/hangul.json | 492 ++++++++ data/irregulars.json | 149 +++ data/sentences.json | 765 ++++++++++++ data/sfx.json | 85 ++ eslint.config.js | 75 ++ lib/blocks.js | 86 ++ lib/conjugation.js | 99 ++ lib/gate.js | 72 ++ lib/hangul.js | 125 ++ lib/srs.js | 46 + package.json | 38 + prompt/tutor-system.md | 100 ++ tsconfig.json | 37 + validate.mjs | 92 ++ vitest.config.ts | 23 + 22 files changed, 7984 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 NOTICE.md create mode 100644 data/curriculum.json create mode 100644 data/deck.json create mode 100644 data/gloss-extra.json create mode 100644 data/grammar.json create mode 100644 data/hangul.json create mode 100644 data/irregulars.json create mode 100644 data/sentences.json create mode 100644 data/sfx.json create mode 100644 eslint.config.js create mode 100644 lib/blocks.js create mode 100644 lib/conjugation.js create mode 100644 lib/gate.js create mode 100644 lib/hangul.js create mode 100644 lib/srs.js create mode 100644 package.json create mode 100644 prompt/tutor-system.md create mode 100644 tsconfig.json create mode 100644 validate.mjs create mode 100644 vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f601bf9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # The curriculum gate comes first, before anything else can run. + # validate.mjs reads only data/ and lib/ and exits non-zero on a + # blocking failure. Baseline: PASS — 0 blocking, 167 advisory. + - name: Curriculum validation + run: node validate.mjs + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Tests + run: npm test + + # Every word in every unit's words[] must resolve in lemma or surface. + # Runs against the committed band files in app/public/dict/. + - name: Roadmap word coverage + run: npm run dict:assert + + - name: Build + run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8cbfb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# the export bundle is the input to this port, not part of it +export/ +*.tar.gz + +# manually-vendored dictionary sources (see tools/dict/README.md) +vendor/ + +node_modules/ +dist/ +.vite/ +coverage/ + +# Capacitor / Android build output — the project itself is committed. +# assets/public is a copy of dist/, regenerated by `npx cap sync`. +app/android/build/ +app/android/app/build/ +app/android/.gradle/ +app/android/local.properties +app/android/app/src/main/assets/public/ +app/android/app/src/main/assets/capacitor.config.json +app/android/capacitor-cordova-android-plugins/ + +.DS_Store +*.log diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..2a62032 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,45 @@ +# Third-party data notices + +Hankan's application code is the authors'. The dictionary data it ships is not, and +carries share-alike obligations. The notices below are also surfaced in the app. + +## Dictionary + +Exactly one of these is used, depending on which source was vendored at build time. +`app/public/dict/manifest.json` records which one produced the shipped files. + +### 한국어기초사전 (Basic Korean Dictionary) — preferred + +Published by 국립국어원 (National Institute of Korean Language). + +> 이 저작물은 크리에이티브 커먼즈 저작자표시-동일조건변경허락 2.0 대한민국 +> 라이선스에 따라 이용할 수 있습니다. + +한국어기초사전, 국립국어원 — CC BY-SA 2.0 KR + + +Audio and image media referenced by the dictionary are **excluded** from that licence. +Hankan stores media URLs only and redistributes no media files. + +### English Wiktionary, via kaikki.org — fallback + +Extracted by wiktextract (Tatu Ylonen, LREC 2022; wiktextract itself is MIT). +Content is CC BY-SA 3.0 and GFDL, inherited from English Wiktionary. + + · + +## Frequency data + +hermitdave/FrequencyWords, `content/2018/ko` — derived from OpenSubtitles2018 via OPUS. +Word list content is CC BY-SA 4.0; the project's code is MIT. + + + +## A note on licence compatibility + +CC BY-SA 2.0 KR and CC BY-SA 4.0 are not automatically compatible in a single derived +work. Hankan therefore keeps frequency data as a separately-attributed adjunct: it +populates the `lemma.freq_rank` column only, and is not merged into the dictionary +content itself. Both notices ship together. + +Share-alike attaches to the derived dictionary data, not to Hankan's application code. diff --git a/data/curriculum.json b/data/curriculum.json new file mode 100644 index 0000000..5fedb0c --- /dev/null +++ b/data/curriculum.json @@ -0,0 +1,1426 @@ +{ + "version": 4, + "note": "Each unit declares what it TEACHES (adds to the running inventory), what it must AVOID, and the only new WORDS it may introduce. Anything a later unit teaches is, by construction, forbidden now. | v4: 1.5 liaison examples made word-internal; sound-example fences added to 1.6-1.8; duplicate words moved from words[] into revisits[].", + "phases": [ + { + "phase": 1, + "ko": "한글", + "name": "The writing system", + "units": [ + { + "id": "1.1", + "ko": "자음과 모음", + "name": "Consonants & vowels", + "goal": "The ten basic vowels and the plain consonants, and how one consonant plus one vowel makes a block. Reading blocks with NO final consonant only.", + "vocabUnit": false, + "teaches": [ + "the plain consonants ㄱㄴㄷㄹㅁㅂㅅㅇㅈㅎ", + "the ten basic vowels ㅏㅑㅓㅕㅗㅛㅜㅠㅡㅣ", + "one block = one syllable, initial + vowel" + ], + "avoid": [ + "any block with a final consonant", + "compound vowels", + "tense or aspirated consonants" + ], + "words": [ + "나", + "너", + "우리", + "이", + "그", + "저", + "여기", + "거기", + "저기", + "어디", + "누구", + "아이", + "어머니", + "아버지", + "나무", + "머리", + "소리", + "하나", + "바다", + "다리" + ], + "revisits": [] + }, + { + "id": "1.2", + "ko": "겹모음", + "name": "Compound vowels", + "goal": "The vowels built from two shapes: ㅐ ㅔ ㅒ ㅖ ㅘ ㅙ ㅚ ㅝ ㅞ ㅟ ㅢ. Still no final consonants.", + "vocabUnit": false, + "teaches": [ + "compound vowels ㅐㅔㅒㅖㅘㅙㅚㅝㅞㅟㅢ" + ], + "avoid": [ + "any block with a final consonant", + "tense or aspirated consonants" + ], + "words": [ + "개", + "새", + "배", + "네", + "세", + "의사", + "회사", + "뭐", + "왜", + "귀", + "위", + "돼지", + "가위", + "시계", + "매미", + "제비" + ], + "revisits": [] + }, + { + "id": "1.3", + "ko": "된소리와 거센소리", + "name": "Tense & aspirated", + "goal": "ㄲ ㄸ ㅃ ㅆ ㅉ against their plain partners, and ㅋ ㅌ ㅍ ㅊ as the breathy versions. Hearing the three-way contrast ㄱ / ㅋ / ㄲ on the page.", + "vocabUnit": false, + "teaches": [ + "tense consonants ㄲㄸㅃㅆㅉ", + "aspirated consonants ㅋㅌㅍㅊ", + "the plain / aspirated / tense three-way contrast" + ], + "avoid": [ + "any block with a final consonant" + ], + "words": [ + "코", + "차", + "커피", + "포도", + "치마", + "까치", + "아빠", + "오빠", + "토끼", + "꼬리", + "찌개", + "카페", + "피자", + "쿠키", + "바쁘다" + ], + "revisits": [] + }, + { + "id": "1.4", + "ko": "받침", + "name": "Batchim — final consonants", + "goal": "A consonant sitting under the block, and the seven sounds every written final collapses to: ㄱ ㄴ ㄷ ㄹ ㅁ ㅂ ㅇ. SINGLE finals only, each word read on its own.", + "vocabUnit": false, + "teaches": [ + "a single final consonant under the block", + "the seven representative final sounds", + "that ㅅ ㅆ ㅈ ㅊ ㅌ ㅎ all sound as ㄷ at the end" + ], + "avoid": [ + "double final consonants (겹받침)", + "any word followed by a syllable starting with ㅇ, which would trigger liaison", + "nasalisation before ㄴ or ㅁ", + "ㅎ-irregular verbs" + ], + "words": [ + "밥", + "물", + "책", + "집", + "손", + "발", + "눈", + "입", + "옷", + "산", + "강", + "문", + "방", + "곰", + "밤", + "꽃", + "앞", + "낮", + "밖", + "말" + ], + "revisits": [] + }, + { + "id": "1.5", + "ko": "연음", + "name": "Liaison — the slide", + "goal": "THE single most important reading rule. When a final consonant is followed by a block starting with ㅇ (which carries no sound of its own), the final slides across and is pronounced as that block's initial. 한국어 is read 한구거.", + "vocabUnit": false, + "teaches": [ + "liaison 연음: a final consonant slides into a following ㅇ block", + "that ㅇ at the start of a block is silent" + ], + "avoid": [ + "double final consonants", + "nasalisation", + "tensification and aspiration rules", + "particles — every example here is liaison inside a single word" + ], + "words": [ + "한국어", + "음악", + "국어", + "단어", + "언어", + "발음", + "얼음", + "직업", + "금요일", + "일요일", + "목요일", + "작은" + ], + "revisits": [] + }, + { + "id": "1.6", + "ko": "비음화", + "name": "Nasalisation", + "goal": "Before ㄴ or ㅁ, a stop final turns nasal: ㄱ becomes ㅇ, ㄷ becomes ㄴ, ㅂ becomes ㅁ. 감사합니다 is read 감사함니다 — which is why the polite endings never sound the way they are spelled.", + "vocabUnit": false, + "teaches": [ + "nasalisation 비음화 before ㄴ and ㅁ", + "why -습니다 is heard as -슴니다" + ], + "avoid": [ + "double final consonants", + "tensification, aspiration, palatalisation", + "do not analyse -습니다 / -ㅂ니다 or dictionary -다 as grammar here — they appear only as sound examples" + ], + "words": [ + "감사합니다", + "입니다", + "학년", + "국물", + "십만", + "작년", + "몇 명", + "습니다", + "한국말", + "꽃말" + ], + "revisits": [] + }, + { + "id": "1.7", + "ko": "겹받침", + "name": "Double batchim", + "goal": "Two consonants under one block. Only ONE of them is pronounced at the end of a word, and which one is largely per-word rather than fully rule-based — so these are learned as items. When a vowel follows, the second one slides across instead.", + "vocabUnit": false, + "teaches": [ + "double final consonants 겹받침", + "which half is pronounced, per word", + "a double final splitting under liaison: 읽어 → 일거" + ], + "avoid": [ + "tensification, aspiration, palatalisation, ㅎ-dropping", + "do not analyse -습니다 / -ㅂ니다 or dictionary -다 as grammar here — they appear only as sound examples" + ], + "words": [ + "값", + "닭", + "여덟", + "앉다", + "읽다", + "없다", + "많다", + "삶", + "넓다", + "짧다" + ], + "revisits": [] + }, + { + "id": "1.8", + "ko": "나머지 소리 변화", + "name": "The remaining sound changes", + "goal": "The four that are left: tensification 경음화 (학교 → 학꾜), aspiration 격음화 (좋다 → 조타), ㅎ-dropping (좋아 → 조아), palatalisation 구개음화 (같이 → 가치), and lateralisation 유음화 (신라 → 실라).", + "vocabUnit": false, + "teaches": [ + "tensification 경음화", + "aspiration 격음화", + "ㅎ-dropping", + "palatalisation 구개음화", + "lateralisation 유음화" + ], + "avoid": [ + "do not analyse -습니다 / -ㅂ니다 or dictionary -다 as grammar here — they appear only as sound examples" + ], + "words": [ + "학교", + "좋다", + "같이", + "신라", + "설날", + "못해", + "축하", + "싫어", + "놓다", + "앉히다" + ], + "revisits": [] + }, + { + "id": "1.9", + "ko": "첫 낱말", + "name": "First words, read correctly", + "goal": "Consolidation. Real words that need two or three of the rules at once, read for meaning rather than mechanics. No new phenomena — this unit only proves Phase 1 holds together.", + "vocabUnit": false, + "teaches": [ + "reading a written word and knowing how it actually sounds" + ], + "avoid": [], + "words": [ + "친구", + "사람", + "시간", + "이름", + "나라", + "하늘", + "마음", + "얼굴", + "목소리", + "이야기" + ], + "revisits": [] + } + ] + }, + { + "phase": 2, + "ko": "낱말과 뼈대", + "name": "Words & the basic frame", + "units": [ + { + "id": "2.1", + "ko": "이다 · 아니다", + "name": "X is Y", + "goal": "The simplest complete sentence: A B야. 야 after a vowel, 이야 after a consonant, 아니야 for the negative. No particles yet — just two nouns and an ending.", + "vocabUnit": false, + "teaches": [ + "the copula 야 / 이야 (casual 'is')", + "아니야 (is not)", + "that a question looks identical and is marked only by ?" + ], + "avoid": [ + "any particle", + "verbs other than the copula", + "polite endings" + ], + "words": [ + "학생", + "선생님", + "아니", + "맞아" + ], + "revisits": [ + { + "word": "의사", + "from": "1.2" + }, + { + "word": "이름", + "from": "1.9" + }, + { + "word": "뭐", + "from": "1.2" + }, + { + "word": "누구", + "from": "1.1" + } + ] + }, + { + "id": "2.2", + "ko": "있다 · 없다", + "name": "There is, there isn't", + "goal": "있어 and 없어 — existence, presence and having. The two most common predicates in the language, and the first real verbs.", + "vocabUnit": false, + "teaches": [ + "있어 / 없어 for existence and possession" + ], + "avoid": [ + "any particle", + "conjugation rules — teach 있어/없어 as fixed forms for now", + "past tense" + ], + "words": [ + "돈", + "형", + "누나", + "동생", + "가족", + "일", + "약속" + ], + "revisits": [ + { + "word": "시간", + "from": "1.9" + } + ] + }, + { + "id": "2.3", + "ko": "어순", + "name": "Word order & the ending word", + "goal": "Subject, then object, then verb — the last word carries the action or state, everything before it is setup. And Korean drops whatever is obvious, so a bare verb is a complete sentence.", + "vocabUnit": false, + "teaches": [ + "subject–object–verb order", + "the last word as the predicate", + "that subjects and objects get dropped when obvious" + ], + "avoid": [ + "any particle", + "conjugation rules — use only forms given in this unit's word list", + "past or future tense" + ], + "words": [ + "먹어", + "마셔", + "봐", + "읽어", + "가", + "와", + "자", + "해" + ], + "revisits": [] + }, + { + "id": "2.4", + "ko": "지시어와 의문사", + "name": "Pointing & asking", + "goal": "이 / 그 / 저 and the words built on them, plus the full question set — 뭐, 누구, 어디, 언제, 왜, 어떻게, 몇 — which sit in the same slot as a normal noun rather than moving to the front.", + "vocabUnit": false, + "teaches": [ + "이 / 그 / 저 and 이거 · 여기 · 이 사람 etc.", + "the question words, in normal word order" + ], + "avoid": [ + "any particle", + "tense" + ], + "words": [ + "이거", + "그거", + "저거", + "언제", + "어떻게", + "몇", + "이번", + "다음" + ], + "revisits": [] + }, + { + "id": "2.5", + "ko": "숫자 ① 한자어", + "name": "Sino-Korean numbers", + "goal": "일 이 삼 사 오 육 칠 팔 구 십, then 백 천 만. Used for dates, money, minutes, phone numbers, floors and anything above ninety-nine.", + "vocabUnit": false, + "teaches": [ + "Sino-Korean numerals and how they stack", + "what Sino numbers are used for: dates, money, minutes" + ], + "avoid": [ + "native numerals", + "counters" + ], + "words": [ + "삼", + "사", + "오", + "육", + "칠", + "팔", + "구", + "십", + "백", + "천", + "만", + "년", + "월", + "분", + "원" + ], + "revisits": [ + { + "word": "일", + "from": "2.2" + }, + { + "word": "이", + "from": "1.1" + } + ] + }, + { + "id": "2.6", + "ko": "숫자 ② 고유어와 단위", + "name": "Native numbers & counters", + "goal": "하나 둘 셋 넷 다섯…, the counters that follow them (개 명 마리 잔 살 번 시), and the shortening that happens in front of one: 하나 → 한, 둘 → 두, 셋 → 세, 넷 → 네, 스물 → 스무.", + "vocabUnit": false, + "teaches": [ + "native numerals", + "counters 개 명 마리 잔 살 번 시", + "한/두/세/네/스무 before a counter", + "a clock reading using both systems" + ], + "avoid": [], + "words": [ + "둘", + "셋", + "넷", + "다섯", + "여섯", + "일곱", + "아홉", + "열", + "스물", + "명", + "마리", + "살", + "잔", + "번", + "시" + ], + "revisits": [ + { + "word": "하나", + "from": "1.1" + }, + { + "word": "여덟", + "from": "1.7" + }, + { + "word": "개", + "from": "1.2" + } + ] + }, + { + "id": "2.7", + "ko": "어휘 · 사람과 사물", + "name": "Vocabulary — people & things", + "goal": "A vocabulary round using everything so far. Words you will meet on almost every page: people, everyday objects, places.", + "vocabUnit": true, + "teaches": [ + "everyday nouns for people, objects and places" + ], + "avoid": [ + "any particle", + "tense" + ], + "words": [ + "가방", + "신발", + "열쇠", + "우산", + "의자", + "책상", + "휴대폰", + "컴퓨터", + "가게", + "길" + ], + "revisits": [ + { + "word": "옷", + "from": "1.4" + }, + { + "word": "시계", + "from": "1.2" + }, + { + "word": "학교", + "from": "1.8" + }, + { + "word": "회사", + "from": "1.2" + } + ] + }, + { + "id": "2.8", + "ko": "부정 ① 안 · 못", + "name": "Negation in front", + "goal": "안 before the verb for 'doesn't', 못 for 'can't'. The difference is choice against ability, and manhwa leans on it constantly. 안 먹어 is a decision; 못 먹어 is a limit.", + "vocabUnit": false, + "teaches": [ + "안 + verb", + "못 + verb", + "the choice-versus-ability contrast" + ], + "avoid": [ + "-지 않다 and -지 마 — those come with the endings in Phase 5" + ], + "words": [ + "안", + "못", + "아직", + "벌써", + "전혀", + "별로" + ], + "revisits": [] + } + ] + }, + { + "phase": 3, + "ko": "활용", + "name": "Conjugation", + "units": [ + { + "id": "3.1", + "ko": "활용의 규칙", + "name": "The rule behind every verb", + "goal": "Dictionary form minus 다 is the stem. If the stem's last vowel is ㅏ or ㅗ, add 아; otherwise add 어; 하다 becomes 해. One rule, and it predicts forms he has never seen.", + "vocabUnit": false, + "teaches": [ + "stem = dictionary form minus 다", + "the 아/어 split by vowel harmony", + "하다 → 해" + ], + "avoid": [ + "all irregular classes", + "past and future tense", + "polite 요" + ], + "words": [ + "먹다", + "가다", + "오다", + "보다", + "웃다", + "잡다", + "살다", + "죽다", + "울다" + ], + "revisits": [ + { + "word": "좋다", + "from": "1.8" + }, + { + "word": "읽다", + "from": "1.7" + }, + { + "word": "앉다", + "from": "1.7" + } + ] + }, + { + "id": "3.2", + "ko": "불규칙 ① ㅡ · 르", + "name": "Irregulars: ㅡ and 르", + "goal": "A stem ending in ㅡ loses it, and the harmony then follows the vowel before it: 크다 → 커, 바쁘다 → 바빠. A 르 stem drops the ㅡ and doubles the ㄹ backwards: 모르다 → 몰라.", + "vocabUnit": false, + "teaches": [ + "the ㅡ irregular", + "the 르 irregular" + ], + "avoid": [ + "the ㅂ ㄷ ㅅ ㅎ ㄹ irregulars", + "tense" + ], + "words": [ + "크다", + "쓰다", + "아프다", + "슬프다", + "예쁘다", + "모르다", + "부르다", + "다르다", + "빠르다" + ], + "revisits": [ + { + "word": "바쁘다", + "from": "1.3" + } + ] + }, + { + "id": "3.3", + "ko": "불규칙 ② ㅂ · ㄷ · ㅅ", + "name": "Irregulars: ㅂ, ㄷ, ㅅ", + "goal": "ㅂ becomes 우 and fuses into 워 (덥다 → 더워). ㄷ becomes ㄹ (듣다 → 들어). ㅅ simply vanishes with no contraction (낫다 → 나아). Each has regular look-alikes that do not change.", + "vocabUnit": false, + "teaches": [ + "the ㅂ irregular", + "the ㄷ irregular", + "the ㅅ irregular", + "that 받다, 닫다, 웃다, 씻다 are regular despite appearances" + ], + "avoid": [ + "the ㅎ and ㄹ irregulars", + "tense" + ], + "words": [ + "덥다", + "춥다", + "쉽다", + "어렵다", + "무섭다", + "맵다", + "듣다", + "걷다", + "묻다", + "낫다", + "짓다" + ], + "revisits": [] + }, + { + "id": "3.4", + "ko": "불규칙 ③ ㅎ · ㄹ", + "name": "Irregulars: ㅎ and ㄹ", + "goal": "Colour words and 그렇다-types drop the ㅎ and shift the vowel to ㅐ (빨갛다 → 빨개). A ㄹ stem keeps its 아/어 form but loses the ㄹ before certain endings (살다 → 사는).", + "vocabUnit": false, + "teaches": [ + "the ㅎ irregular", + "the ㄹ irregular" + ], + "avoid": [ + "tense" + ], + "words": [ + "그렇다", + "어떻다", + "빨갛다", + "하얗다", + "까맣다", + "알다", + "놀다", + "멀다", + "길다", + "만들다" + ], + "revisits": [] + }, + { + "id": "3.5", + "ko": "과거", + "name": "The past tense", + "goal": "Built FROM the 아/어 form, not from the dictionary form: take 먹어, insert ㅆ, add 어 → 먹었어. 가 → 갔어. 해 → 했어. Irregulars carry through: 더워 → 더웠어, 몰라 → 몰랐어.", + "vocabUnit": false, + "teaches": [ + "past tense 았/었어", + "that the past is built on the 아/어 form, so irregulars carry through" + ], + "avoid": [ + "future tense", + "polite 요" + ], + "words": [ + "했어", + "갔어", + "왔어", + "봤어", + "먹었어", + "어제", + "아까", + "방금" + ], + "revisits": [] + }, + { + "id": "3.6", + "ko": "미래와 의지", + "name": "Future & intention", + "goal": "-(으)ㄹ 거야 for what is going to happen, and -(으)ㄹ게 for a promise made to the person listening. The second one is the vow panel: 내가 지킬게.", + "vocabUnit": false, + "teaches": [ + "-(으)ㄹ 거야 future", + "-(으)ㄹ게 promise" + ], + "avoid": [ + "polite 요" + ], + "words": [ + "내일", + "나중에", + "곧", + "이따가", + "진짜", + "정말" + ], + "revisits": [] + }, + { + "id": "3.7", + "ko": "어휘 · 동작과 상태", + "name": "Vocabulary — actions & states", + "goal": "A vocabulary round of verbs and adjectives, every one of them run through the conjugation rules just learned.", + "vocabUnit": true, + "teaches": [ + "common action verbs and descriptive adjectives" + ], + "avoid": [], + "words": [ + "만나다", + "기다리다", + "찾다", + "주다", + "받다", + "사다", + "팔다", + "열다", + "닫다", + "시작하다", + "끝나다", + "싸우다", + "도망치다", + "지키다" + ], + "revisits": [] + } + ] + }, + { + "phase": 4, + "ko": "조사", + "name": "Particles", + "units": [ + { + "id": "4.1", + "ko": "은 / 는", + "name": "The topic marker", + "goal": "은/는 names what the sentence is about, and quietly sets it against something else. 은 after a consonant, 는 after a vowel. Everything so far has managed without it — this unit shows what it adds.", + "vocabUnit": false, + "teaches": [ + "은/는 as topic marker", + "the consonant/vowel split" + ], + "avoid": [ + "이/가, 을/를 and every other particle" + ], + "words": [ + "오늘", + "요즘", + "원래", + "보통" + ], + "revisits": [] + }, + { + "id": "4.2", + "ko": "이 / 가", + "name": "The subject marker", + "goal": "이/가 marks who or what performs the verb, and it introduces information that is new. 이 after a consonant, 가 after a vowel; 저 + 가 contracts to 제가, 나 + 가 to 내가.", + "vocabUnit": false, + "teaches": [ + "이/가 as subject marker", + "제가 · 내가 · 네가 contractions" + ], + "avoid": [ + "을/를 and the later particles" + ], + "words": [ + "누가", + "내가", + "제가", + "네가" + ], + "revisits": [] + }, + { + "id": "4.3", + "ko": "은/는 vs 이/가", + "name": "The contrast", + "goal": "No new particle — a whole unit on the difference, because it is the one that never stops mattering. Same sentence, both markers, side by side: what changes is where the weight falls and what is being answered.", + "vocabUnit": false, + "teaches": [ + "choosing between 은/는 and 이/가 by what is old and what is new" + ], + "avoid": [ + "을/를 and the later particles" + ], + "words": [ + "그런데", + "하지만" + ], + "revisits": [] + }, + { + "id": "4.4", + "ko": "을 / 를", + "name": "The object marker", + "goal": "을/를 marks what the verb acts on. It is dropped constantly in speech bubbles, which is exactly why you need to recognise it when it does appear. 뭘, 널, 절 are the contractions.", + "vocabUnit": false, + "teaches": [ + "을/를 as object marker", + "the contractions 뭘 · 널 · 절" + ], + "avoid": [ + "에 / 에서 and the later particles" + ], + "words": [ + "뭘", + "널", + "절" + ], + "revisits": [] + }, + { + "id": "4.5", + "ko": "에 · 에서", + "name": "Place & time", + "goal": "에 for a destination, a point in time, or where something simply is. 에서 for where an action happens, and where something came from. 집에 있어 against 집에서 일해.", + "vocabUnit": false, + "teaches": [ + "에 for destination, time and static location", + "에서 for the site of an action and for origin" + ], + "avoid": [ + "도, 만 and the later particles" + ], + "words": [ + "아래", + "옆", + "뒤", + "사이" + ], + "revisits": [ + { + "word": "밖", + "from": "1.4" + }, + { + "word": "안", + "from": "2.8" + }, + { + "word": "위", + "from": "1.2" + }, + { + "word": "앞", + "from": "1.4" + } + ] + }, + { + "id": "4.6", + "ko": "도 · 만", + "name": "Also & only", + "goal": "도 (also, too) and 만 (only). The catch: they REPLACE 은/는, 이/가 and 을/를 rather than stacking on top of them. 저는도 is wrong; 저도 is right.", + "vocabUnit": false, + "teaches": [ + "도 (also) and 만 (only)", + "that 도 and 만 replace the other markers" + ], + "avoid": [ + "the remaining particles" + ], + "words": [ + "혼자", + "조금", + "많이" + ], + "revisits": [ + { + "word": "같이", + "from": "1.8" + } + ] + }, + { + "id": "4.7", + "ko": "나머지 조사", + "name": "The rest of the set", + "goal": "하고 · (이)랑 · 와/과 for and and with; (으)로 for by and toward; 부터 ~ 까지 for a span; 보다 for more than; 처럼 for like.", + "vocabUnit": false, + "teaches": [ + "하고 · (이)랑 · 와/과", + "(으)로", + "부터 ~ 까지", + "보다", + "처럼" + ], + "avoid": [], + "words": [ + "하고", + "랑", + "로", + "부터", + "까지", + "처럼" + ], + "revisits": [ + { + "word": "보다", + "from": "3.1" + } + ] + }, + { + "id": "4.8", + "ko": "생략과 지시", + "name": "Omission & tracking who", + "goal": "The reading skill this whole phase was building toward. Korean drops subjects and objects constantly, and a manhwa page expects you to infer who is speaking and who is being spoken about — from who spoke last, from the ending, from the level of politeness. Given a bare line, you should be able to say WHO.", + "vocabUnit": false, + "teaches": [ + "tracing a dropped subject through a exchange", + "reading the speaker from the ending and the register" + ], + "avoid": [], + "words": [ + "그때", + "이제", + "걔", + "쟤", + "얘" + ], + "revisits": [ + { + "word": "아까", + "from": "3.5" + }, + { + "word": "방금", + "from": "3.5" + } + ] + } + ] + }, + { + "phase": 5, + "ko": "어미", + "name": "Endings & clauses", + "units": [ + { + "id": "5.1", + "ko": "존댓말 읽기", + "name": "Reading polite speech", + "goal": "Everything so far has been 반말. Now the 요 form and the -습니다 form, for RECOGNITION: a character switching into them is a social event, not just a different ending.", + "vocabUnit": false, + "teaches": [ + "the polite 요 ending", + "the formal -습니다 ending", + "that a switch of register is a story signal" + ], + "avoid": [ + "honorific -(으)시- — that is the next unit" + ], + "words": [ + "안녕하세요", + "죄송합니다", + "아니요", + "그렇습니다" + ], + "revisits": [ + { + "word": "감사합니다", + "from": "1.6" + }, + { + "word": "네", + "from": "1.2" + } + ] + }, + { + "id": "5.2", + "ko": "높임말", + "name": "Honorifics", + "goal": "-(으)시- inside the verb, -세요, 께서 instead of 이/가, and the suppletive words: 드시다, 주무시다, 계시다, 드리다, 말씀. Recognition only — you are reading these, not producing them.", + "vocabUnit": false, + "teaches": [ + "honorific -(으)시- and -세요", + "께서", + "드시다 · 주무시다 · 계시다 · 드리다 · 말씀" + ], + "avoid": [], + "words": [ + "말씀", + "님", + "드리다", + "계시다", + "주무시다" + ], + "revisits": [ + { + "word": "분", + "from": "2.5" + } + ] + }, + { + "id": "5.3", + "ko": "부정 ② 지 않다 · 지 마", + "name": "Negation behind the verb", + "goal": "-지 않아 as the other way to say no, -지 못해 for the other kind of can't, and -지 마 for don't. The register split matters: 안 먹어 is a character speaking, 먹지 않는다 is narration.", + "vocabUnit": false, + "teaches": [ + "-지 않다", + "-지 못하다", + "-지 마 (don't)", + "the spoken/narrated register split with 안" + ], + "avoid": [], + "words": [ + "걱정하다", + "신경 쓰다", + "포기하다" + ], + "revisits": [] + }, + { + "id": "5.4", + "ko": "바람과 능력", + "name": "Wanting & being able", + "goal": "-고 싶어 (want to), -(으)ㄹ 수 있어/없어 (can, cannot), -아/어야 돼 (have to). Three things stacked onto the end of a verb.", + "vocabUnit": false, + "teaches": [ + "-고 싶다", + "-(으)ㄹ 수 있다 / 없다", + "-아/어야 되다" + ], + "avoid": [], + "words": [ + "싶다", + "수", + "필요하다", + "돼" + ], + "revisits": [] + }, + { + "id": "5.5", + "ko": "고 · 지만", + "name": "And, but", + "goal": "-고 joins two clauses with no claim of cause; -지만 sets them against each other. Tense sits on the final verb with -고, but -지만 takes it directly.", + "vocabUnit": false, + "teaches": [ + "-고 (and)", + "-지만 (but)" + ], + "avoid": [ + "-아서 and -(으)니까 — the next unit" + ], + "words": [ + "그리고", + "그렇지만" + ], + "revisits": [] + }, + { + "id": "5.6", + "ko": "아서 · 니까", + "name": "Because", + "goal": "Two reasons endings that are not interchangeable. -아/어서 never takes past tense and never comes before a command. -(으)니까 does both — which is why every 'so let's…' line uses it.", + "vocabUnit": false, + "teaches": [ + "-아/어서 (because; and then)", + "-(으)니까 (because, before commands and suggestions)", + "that -아/어서 cannot carry tense or precede a command" + ], + "avoid": [ + "-는데" + ], + "words": [ + "그래서", + "왜냐하면", + "때문에" + ], + "revisits": [] + }, + { + "id": "5.7", + "ko": "는데 ① 대조", + "name": "는데 as contrast", + "goal": "-는데 doing exactly one of its jobs: setting two things against each other. 이 옷은 예쁜데 비싸. Minimal pairs against -지만 so the difference in feel is visible.", + "vocabUnit": false, + "teaches": [ + "-는데 / -(으)ㄴ데 as contrast" + ], + "avoid": [ + "the other two jobs of -는데 — they are the next unit" + ], + "words": [], + "revisits": [] + }, + { + "id": "5.8", + "ko": "는데 ② 배경", + "name": "는데 as background & lead-in", + "goal": "Its other two jobs: setting the scene before the main clause, and opening a topic then trailing off so the listener responds. Three labelled uses now, and a sorting exercise where you say which one each is doing.", + "vocabUnit": false, + "teaches": [ + "-는데 as scene-setting background", + "-는데 as a soft trailing lead-in", + "telling the three uses apart" + ], + "avoid": [], + "words": [], + "revisits": [] + }, + { + "id": "5.9", + "ko": "조건과 목적", + "name": "Condition & purpose", + "goal": "-(으)면 (if, when), -(으)면서 (while), -(으)려고 (intending to), -(으)러 (in order to, only with verbs of movement), -기 전에 and -(으)ㄴ 후에.", + "vocabUnit": false, + "teaches": [ + "-(으)면", + "-(으)면서", + "-(으)려고", + "-(으)러", + "-기 전에 / -(으)ㄴ 후에" + ], + "avoid": [], + "words": [ + "만약", + "혹시", + "아마", + "먼저" + ], + "revisits": [] + }, + { + "id": "5.10", + "ko": "어휘 · 감정과 관계", + "name": "Vocabulary — feelings & relationships", + "goal": "A vocabulary round on the emotional register manhwa runs on, every word used inside the endings just learned.", + "vocabUnit": true, + "teaches": [ + "vocabulary for feelings, relationships and reactions" + ], + "avoid": [], + "words": [ + "사랑", + "기분", + "걱정", + "오해", + "비밀", + "거짓말", + "질투", + "고백", + "미안하다", + "고맙다" + ], + "revisits": [ + { + "word": "마음", + "from": "1.9" + }, + { + "word": "약속", + "from": "2.2" + } + ] + } + ] + }, + { + "phase": 6, + "ko": "실전 읽기", + "name": "Reading for real", + "units": [ + { + "id": "6.1", + "ko": "수식", + "name": "Modifiers before nouns", + "goal": "Korean puts the whole description in FRONT of the noun, and marks tense inside it: -는 (now), -(으)ㄴ (already), -(으)ㄹ (yet to come). 내가 죽인 몬스터. The skill is finding where the modifier ends and the noun begins.", + "vocabUnit": false, + "teaches": [ + "-는 / -(으)ㄴ / -(으)ㄹ modifying a noun", + "finding the boundary between modifier and noun" + ], + "avoid": [], + "words": [ + "것", + "거", + "때", + "곳", + "적" + ], + "revisits": [] + }, + { + "id": "6.2", + "ko": "인용", + "name": "Quotation", + "goal": "An isolation unit, drilled with choice and build tasks so gist-guessing cannot carry you. -라고 for a direct quote, -고 for reported speech, and the contractions -대 · -래 · -냬 · -재 that mean someone else said it first.", + "vocabUnit": false, + "teaches": [ + "-라고 direct quotation", + "-고 reported speech", + "the contractions -대 · -래 · -냬 · -재" + ], + "avoid": [], + "words": [ + "라고", + "대", + "래", + "냬", + "재" + ], + "revisits": [] + }, + { + "id": "6.3", + "ko": "말투 구분", + "name": "Narration vs speech", + "goal": "The other isolation unit: -다 endings (narration, thought, description) against -어 endings (someone speaking), and 며 against 면서. Given a line with no context, say which voice it is.", + "vocabUnit": false, + "teaches": [ + "the -다 narration register", + "telling narration from dialogue by the ending", + "며 vs 면서" + ], + "avoid": [], + "words": [ + "이다", + "했다", + "한다", + "였다" + ], + "revisits": [] + }, + { + "id": "6.4", + "ko": "띄어쓰기와 줄임", + "name": "Spacing & contractions", + "goal": "Where the spaces fall, and the contractions speech bubbles are full of: 나는 → 난, 너는 → 넌, 이것은 → 이건, 무엇을 → 뭘, 그 아이 → 걔.", + "vocabUnit": false, + "teaches": [ + "Korean word spacing and where a chunk ends", + "the common spoken contractions" + ], + "avoid": [], + "words": [ + "난", + "넌", + "건", + "이건" + ], + "revisits": [ + { + "word": "뭘", + "from": "4.4" + }, + { + "word": "걔", + "from": "4.8" + }, + { + "word": "쟤", + "from": "4.8" + }, + { + "word": "얘", + "from": "4.8" + } + ] + }, + { + "id": "6.5", + "ko": "의성어 · 의태어", + "name": "Sound & manner words", + "goal": "The words lettered into the artwork rather than spoken by anyone. Not in most textbooks, and they carry a great deal of what a panel is doing.", + "vocabUnit": false, + "teaches": [ + "sound words 의성어 and manner words 의태어" + ], + "avoid": [], + "words": [ + "쿵", + "쾅", + "헉", + "헐", + "두근두근", + "반짝", + "털썩", + "씨익", + "꿀꺽", + "덜덜" + ], + "revisits": [] + }, + { + "id": "6.6", + "ko": "어휘 · 장르", + "name": "Vocabulary — genre", + "goal": "The vocabulary specific to what you actually read: regression and tower fantasy, romance drama, school life, action and crime.", + "vocabUnit": true, + "teaches": [ + "genre vocabulary for fantasy, romance, school and action manhwa" + ], + "avoid": [], + "words": [ + "회귀", + "각성", + "스킬", + "던전", + "길드", + "마왕", + "헌터", + "복수", + "배신", + "계약", + "재벌", + "선배", + "일진", + "증거" + ], + "revisits": [] + }, + { + "id": "6.7", + "ko": "패널 읽기", + "name": "Reading whole panels", + "goal": "Several bubbles in a row, with sound words and dropped subjects, read for what happened and who did it.", + "vocabUnit": false, + "teaches": [ + "reading a multi-bubble exchange as one scene" + ], + "avoid": [], + "words": [], + "revisits": [] + }, + { + "id": "6.8", + "ko": "속도 읽기", + "name": "Speed reading", + "goal": "Real excerpt length, timed, no lookups. The finish line: you read it once and say what happened.", + "vocabUnit": false, + "teaches": [ + "reading at speed without lookups" + ], + "avoid": [], + "words": [], + "revisits": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/data/deck.json b/data/deck.json new file mode 100644 index 0000000..bf07c67 --- /dev/null +++ b/data/deck.json @@ -0,0 +1,2363 @@ +{ + "note": "Curated seed vocabulary. Fields: [한글, revised romanization, English, part of speech]. Romanization is retained for the build pipeline only; the app never displays it.", + "topics": { + "인사 Greetings": [ + [ + "안녕하세요", + "annyeonghaseyo", + "hello", + "phrase" + ], + [ + "안녕히 가세요", + "annyeonghi gaseyo", + "goodbye (to the one leaving)", + "phrase" + ], + [ + "안녕히 계세요", + "annyeonghi gyeseyo", + "goodbye (said by the one leaving)", + "phrase" + ], + [ + "감사합니다", + "gamsahamnida", + "thank you (formal)", + "phrase" + ], + [ + "고마워요", + "gomawoyo", + "thanks", + "phrase" + ], + [ + "죄송합니다", + "joesonghamnida", + "I'm sorry (formal)", + "phrase" + ], + [ + "미안해요", + "mianhaeyo", + "sorry", + "phrase" + ], + [ + "네", + "ne", + "yes", + "adv" + ], + [ + "아니요", + "aniyo", + "no", + "adv" + ], + [ + "실례합니다", + "sillyehamnida", + "excuse me", + "phrase" + ], + [ + "반갑습니다", + "bangapseumnida", + "nice to meet you", + "phrase" + ], + [ + "잘 부탁드립니다", + "jal butakdeurimnida", + "I look forward to working with you", + "phrase" + ], + [ + "괜찮아요", + "gwaenchanayo", + "it's fine / no problem", + "phrase" + ], + [ + "잠깐만요", + "jamkkanmanyo", + "just a moment", + "phrase" + ], + [ + "어서 오세요", + "eoseo oseyo", + "welcome (entering a shop)", + "phrase" + ], + [ + "맞아요", + "majayo", + "that's right", + "phrase" + ] + ], + "사람 People": [ + [ + "사람", + "saram", + "person", + "noun" + ], + [ + "남자", + "namja", + "man", + "noun" + ], + [ + "여자", + "yeoja", + "woman", + "noun" + ], + [ + "친구", + "chingu", + "friend", + "noun" + ], + [ + "가족", + "gajok", + "family", + "noun" + ], + [ + "아버지", + "abeoji", + "father", + "noun" + ], + [ + "어머니", + "eomeoni", + "mother", + "noun" + ], + [ + "아빠", + "appa", + "dad", + "noun" + ], + [ + "엄마", + "eomma", + "mom", + "noun" + ], + [ + "형", + "hyeong", + "older brother (male speaker)", + "noun" + ], + [ + "오빠", + "oppa", + "older brother (female speaker)", + "noun" + ], + [ + "누나", + "nuna", + "older sister (male speaker)", + "noun" + ], + [ + "언니", + "eonni", + "older sister (female speaker)", + "noun" + ], + [ + "동생", + "dongsaeng", + "younger sibling", + "noun" + ], + [ + "선생님", + "seonsaengnim", + "teacher", + "noun" + ], + [ + "학생", + "haksaeng", + "student", + "noun" + ], + [ + "아이", + "ai", + "child", + "noun" + ], + [ + "이름", + "ireum", + "name", + "noun" + ] + ], + "숫자 Numbers": [ + [ + "일", + "il", + "one (Sino)", + "num" + ], + [ + "이", + "i", + "two (Sino)", + "num" + ], + [ + "삼", + "sam", + "three (Sino)", + "num" + ], + [ + "사", + "sa", + "four (Sino)", + "num" + ], + [ + "오", + "o", + "five (Sino)", + "num" + ], + [ + "육", + "yuk", + "six (Sino)", + "num" + ], + [ + "칠", + "chil", + "seven (Sino)", + "num" + ], + [ + "팔", + "pal", + "eight (Sino)", + "num" + ], + [ + "구", + "gu", + "nine (Sino)", + "num" + ], + [ + "십", + "sip", + "ten (Sino)", + "num" + ], + [ + "백", + "baek", + "hundred (Sino)", + "num" + ], + [ + "천", + "cheon", + "thousand (Sino)", + "num" + ], + [ + "만", + "man", + "ten thousand (Sino)", + "num" + ], + [ + "하나", + "hana", + "one (Native)", + "num" + ], + [ + "둘", + "dul", + "two (Native)", + "num" + ], + [ + "셋", + "set", + "three (Native)", + "num" + ], + [ + "넷", + "net", + "four (Native)", + "num" + ], + [ + "다섯", + "daseot", + "five (Native)", + "num" + ], + [ + "여섯", + "yeoseot", + "six (Native)", + "num" + ], + [ + "일곱", + "ilgop", + "seven (Native)", + "num" + ], + [ + "여덟", + "yeodeol", + "eight (Native)", + "num" + ], + [ + "아홉", + "ahop", + "nine (Native)", + "num" + ], + [ + "열", + "yeol", + "ten (Native)", + "num" + ], + [ + "스물", + "seumul", + "twenty (Native)", + "num" + ] + ], + "시간 Time": [ + [ + "오늘", + "oneul", + "today", + "noun" + ], + [ + "어제", + "eoje", + "yesterday", + "noun" + ], + [ + "내일", + "naeil", + "tomorrow", + "noun" + ], + [ + "지금", + "jigeum", + "now", + "noun" + ], + [ + "아침", + "achim", + "morning / breakfast", + "noun" + ], + [ + "점심", + "jeomsim", + "midday / lunch", + "noun" + ], + [ + "저녁", + "jeonyeok", + "evening / dinner", + "noun" + ], + [ + "밤", + "bam", + "night", + "noun" + ], + [ + "주말", + "jumal", + "weekend", + "noun" + ], + [ + "시간", + "sigan", + "time / hour", + "noun" + ], + [ + "분", + "bun", + "minute", + "noun" + ], + [ + "요일", + "yoil", + "day of the week", + "noun" + ], + [ + "월요일", + "woryoil", + "Monday", + "noun" + ], + [ + "금요일", + "geumyoil", + "Friday", + "noun" + ], + [ + "토요일", + "toyoil", + "Saturday", + "noun" + ], + [ + "일요일", + "iryoil", + "Sunday", + "noun" + ], + [ + "년", + "nyeon", + "year", + "noun" + ], + [ + "매일", + "maeil", + "every day", + "adv" + ] + ], + "장소 Places": [ + [ + "집", + "jip", + "house / home", + "noun" + ], + [ + "학교", + "hakgyo", + "school", + "noun" + ], + [ + "회사", + "hoesa", + "company / office", + "noun" + ], + [ + "식당", + "sikdang", + "restaurant", + "noun" + ], + [ + "카페", + "kape", + "cafe", + "noun" + ], + [ + "가게", + "gage", + "shop", + "noun" + ], + [ + "시장", + "sijang", + "market", + "noun" + ], + [ + "백화점", + "baekhwajeom", + "department store", + "noun" + ], + [ + "병원", + "byeongwon", + "hospital", + "noun" + ], + [ + "약국", + "yakguk", + "pharmacy", + "noun" + ], + [ + "은행", + "eunhaeng", + "bank", + "noun" + ], + [ + "공항", + "gonghang", + "airport", + "noun" + ], + [ + "역", + "yeok", + "station", + "noun" + ], + [ + "화장실", + "hwajangsil", + "restroom", + "noun" + ], + [ + "공원", + "gongwon", + "park", + "noun" + ], + [ + "도서관", + "doseogwan", + "library", + "noun" + ] + ], + "음식 Food": [ + [ + "밥", + "bap", + "rice / a meal", + "noun" + ], + [ + "물", + "mul", + "water", + "noun" + ], + [ + "김치", + "gimchi", + "kimchi", + "noun" + ], + [ + "고기", + "gogi", + "meat", + "noun" + ], + [ + "생선", + "saengseon", + "fish (as food)", + "noun" + ], + [ + "과일", + "gwail", + "fruit", + "noun" + ], + [ + "사과", + "sagwa", + "apple", + "noun" + ], + [ + "야채", + "yachae", + "vegetables", + "noun" + ], + [ + "빵", + "ppang", + "bread", + "noun" + ], + [ + "계란", + "gyeran", + "egg", + "noun" + ], + [ + "우유", + "uyu", + "milk", + "noun" + ], + [ + "커피", + "keopi", + "coffee", + "noun" + ], + [ + "차", + "cha", + "tea", + "noun" + ], + [ + "맥주", + "maekju", + "beer", + "noun" + ], + [ + "비빔밥", + "bibimbap", + "bibimbap", + "noun" + ], + [ + "불고기", + "bulgogi", + "bulgogi", + "noun" + ], + [ + "라면", + "ramyeon", + "ramyeon", + "noun" + ], + [ + "음식", + "eumsik", + "food", + "noun" + ] + ], + "동사 Verbs": [ + [ + "하다", + "hada", + "to do", + "verb" + ], + [ + "가다", + "gada", + "to go", + "verb" + ], + [ + "오다", + "oda", + "to come", + "verb" + ], + [ + "먹다", + "meokda", + "to eat", + "verb" + ], + [ + "마시다", + "masida", + "to drink", + "verb" + ], + [ + "보다", + "boda", + "to see / to watch", + "verb" + ], + [ + "듣다", + "deutda", + "to listen", + "verb" + ], + [ + "읽다", + "ikda", + "to read", + "verb" + ], + [ + "쓰다", + "sseuda", + "to write / to use", + "verb" + ], + [ + "말하다", + "malhada", + "to speak", + "verb" + ], + [ + "사다", + "sada", + "to buy", + "verb" + ], + [ + "팔다", + "palda", + "to sell", + "verb" + ], + [ + "자다", + "jada", + "to sleep", + "verb" + ], + [ + "일어나다", + "ireonada", + "to get up", + "verb" + ], + [ + "앉다", + "anda", + "to sit", + "verb" + ], + [ + "주다", + "juda", + "to give", + "verb" + ], + [ + "받다", + "batda", + "to receive", + "verb" + ], + [ + "알다", + "alda", + "to know", + "verb" + ], + [ + "모르다", + "moreuda", + "to not know", + "verb" + ], + [ + "좋아하다", + "joahada", + "to like", + "verb" + ], + [ + "공부하다", + "gongbuhada", + "to study", + "verb" + ], + [ + "일하다", + "ilhada", + "to work", + "verb" + ], + [ + "살다", + "salda", + "to live", + "verb" + ], + [ + "만나다", + "mannada", + "to meet", + "verb" + ], + [ + "기다리다", + "gidarida", + "to wait", + "verb" + ], + [ + "배우다", + "baeuda", + "to learn", + "verb" + ], + [ + "가르치다", + "gareuchida", + "to teach", + "verb" + ], + [ + "있다", + "itda", + "to exist / to have", + "verb" + ], + [ + "없다", + "eopda", + "to not exist / to not have", + "verb" + ], + [ + "타다", + "tada", + "to ride / to board", + "verb" + ] + ], + "형용사 Adjectives": [ + [ + "좋다", + "jota", + "to be good", + "adj" + ], + [ + "나쁘다", + "nappeuda", + "to be bad", + "adj" + ], + [ + "크다", + "keuda", + "to be big", + "adj" + ], + [ + "작다", + "jakda", + "to be small", + "adj" + ], + [ + "많다", + "manta", + "to be many / much", + "adj" + ], + [ + "비싸다", + "bissada", + "to be expensive", + "adj" + ], + [ + "싸다", + "ssada", + "to be cheap", + "adj" + ], + [ + "맛있다", + "masitda", + "to be delicious", + "adj" + ], + [ + "맛없다", + "madeopda", + "to taste bad", + "adj" + ], + [ + "예쁘다", + "yeppeuda", + "to be pretty", + "adj" + ], + [ + "멋있다", + "meositda", + "to be stylish / cool", + "adj" + ], + [ + "재미있다", + "jaemiitda", + "to be fun / interesting", + "adj" + ], + [ + "어렵다", + "eoryeopda", + "to be difficult", + "adj" + ], + [ + "쉽다", + "swipda", + "to be easy", + "adj" + ], + [ + "바쁘다", + "bappeuda", + "to be busy", + "adj" + ], + [ + "피곤하다", + "pigonhada", + "to be tired", + "adj" + ], + [ + "덥다", + "deopda", + "to be hot (weather)", + "adj" + ], + [ + "춥다", + "chupda", + "to be cold (weather)", + "adj" + ], + [ + "아프다", + "apeuda", + "to hurt / to be ill", + "adj" + ], + [ + "매콤하다", + "maekomhada", + "to be pleasantly spicy", + "adj" + ], + [ + "싫다", + "sireoda", + "to dislike, to hate", + "adj" + ], + [ + "슬프다", + "seulpeuda", + "to be sad", + "adj" + ], + [ + "무섭다", + "museopda", + "to be frightening", + "adj" + ], + [ + "화나다", + "hwanada", + "to get angry", + "verb" + ], + [ + "놀라다", + "nollada", + "to be surprised", + "verb" + ], + [ + "이상하다", + "isanghada", + "to be strange", + "adj" + ], + [ + "조심하다", + "josimhada", + "to be careful", + "verb" + ] + ], + "부사 Adverbs": [ + [ + "아주", + "aju", + "very", + "adv" + ], + [ + "너무", + "neomu", + "too / excessively", + "adv" + ], + [ + "조금", + "jogeum", + "a little", + "adv" + ], + [ + "많이", + "mani", + "a lot", + "adv" + ], + [ + "잘", + "jal", + "well", + "adv" + ], + [ + "빨리", + "ppalli", + "quickly", + "adv" + ], + [ + "천천히", + "cheoncheonhi", + "slowly", + "adv" + ], + [ + "다시", + "dasi", + "again", + "adv" + ], + [ + "같이", + "gachi", + "together", + "adv" + ], + [ + "먼저", + "meonjeo", + "first", + "adv" + ], + [ + "항상", + "hangsang", + "always", + "adv" + ], + [ + "가끔", + "gakkeum", + "sometimes", + "adv" + ], + [ + "그리고", + "geurigo", + "and (then)", + "conj" + ], + [ + "하지만", + "hajiman", + "but", + "conj" + ], + [ + "그래서", + "geuraeseo", + "so / therefore", + "conj" + ], + [ + "그런데", + "geureonde", + "by the way / but", + "conj" + ] + ], + "질문 Question words": [ + [ + "뭐", + "mwo", + "what", + "pron" + ], + [ + "누구", + "nugu", + "who", + "pron" + ], + [ + "어디", + "eodi", + "where", + "pron" + ], + [ + "언제", + "eonje", + "when", + "adv" + ], + [ + "왜", + "wae", + "why", + "adv" + ], + [ + "어떻게", + "eotteoke", + "how", + "adv" + ], + [ + "얼마", + "eolma", + "how much", + "pron" + ], + [ + "몇", + "myeot", + "how many", + "det" + ] + ], + "교통 Transport": [ + [ + "버스", + "beoseu", + "bus", + "noun" + ], + [ + "지하철", + "jihacheol", + "subway", + "noun" + ], + [ + "택시", + "taeksi", + "taxi", + "noun" + ], + [ + "기차", + "gicha", + "train", + "noun" + ], + [ + "자전거", + "jajeongeo", + "bicycle", + "noun" + ], + [ + "비행기", + "bihaenggi", + "airplane", + "noun" + ], + [ + "표", + "pyo", + "ticket", + "noun" + ], + [ + "길", + "gil", + "road / way", + "noun" + ] + ], + "물건 Things": [ + [ + "책", + "chaek", + "book", + "noun" + ], + [ + "가방", + "gabang", + "bag", + "noun" + ], + [ + "옷", + "ot", + "clothes", + "noun" + ], + [ + "신발", + "sinbal", + "shoes", + "noun" + ], + [ + "돈", + "don", + "money", + "noun" + ], + [ + "휴대폰", + "hyudaepon", + "mobile phone", + "noun" + ], + [ + "컴퓨터", + "keompyuteo", + "computer", + "noun" + ], + [ + "시계", + "sigye", + "clock / watch", + "noun" + ], + [ + "열쇠", + "yeolsoe", + "key", + "noun" + ], + [ + "우산", + "usan", + "umbrella", + "noun" + ], + [ + "의자", + "uija", + "chair", + "noun" + ], + [ + "책상", + "chaeksang", + "desk", + "noun" + ] + ], + "날씨 Weather": [ + [ + "날씨", + "nalssi", + "weather", + "noun" + ], + [ + "비", + "bi", + "rain", + "noun" + ], + [ + "눈", + "nun", + "snow", + "noun" + ], + [ + "바람", + "baram", + "wind", + "noun" + ], + [ + "구름", + "gureum", + "cloud", + "noun" + ], + [ + "하늘", + "haneul", + "sky", + "noun" + ], + [ + "해", + "hae", + "sun", + "noun" + ] + ], + "몸 Body & health": [ + [ + "머리", + "meori", + "head / hair", + "noun" + ], + [ + "눈", + "nun", + "eye", + "noun" + ], + [ + "코", + "ko", + "nose", + "noun" + ], + [ + "입", + "ip", + "mouth", + "noun" + ], + [ + "귀", + "gwi", + "ear", + "noun" + ], + [ + "손", + "son", + "hand", + "noun" + ], + [ + "발", + "bal", + "foot", + "noun" + ], + [ + "배", + "bae", + "stomach", + "noun" + ], + [ + "목", + "mok", + "neck / throat", + "noun" + ], + [ + "약", + "yak", + "medicine", + "noun" + ], + [ + "심장", + "simjang", + "heart (the organ)", + "noun" + ] + ], + "쇼핑 Shopping": [ + [ + "가격", + "gagyeok", + "price", + "noun" + ], + [ + "할인", + "harin", + "discount", + "noun" + ], + [ + "계산", + "gyesan", + "payment / the bill", + "noun" + ], + [ + "영수증", + "yeongsujeung", + "receipt", + "noun" + ], + [ + "카드", + "kadeu", + "card", + "noun" + ], + [ + "현금", + "hyeongeum", + "cash", + "noun" + ], + [ + "봉투", + "bongtu", + "bag / envelope", + "noun" + ] + ], + "대명사 Pronouns & pointers": [ + [ + "나", + "na", + "I, me (casual)", + "pron" + ], + [ + "저", + "jeo", + "I, me (humble)", + "pron" + ], + [ + "너", + "neo", + "you (casual)", + "pron" + ], + [ + "우리", + "uri", + "we, our", + "pron" + ], + [ + "쟤", + "jyae", + "that kid, him/her (casual)", + "pron" + ], + [ + "이", + "i", + "this", + "det" + ], + [ + "그", + "geu", + "that (near you, or already mentioned)", + "det" + ], + [ + "저", + "jeo", + "that over there", + "det" + ], + [ + "여기", + "yeogi", + "here", + "pron" + ], + [ + "거기", + "geogi", + "there", + "pron" + ], + [ + "저기", + "jeogi", + "over there", + "pron" + ], + [ + "이거", + "igeo", + "this thing", + "pron" + ], + [ + "그거", + "geugeo", + "that thing", + "pron" + ], + [ + "저거", + "jeogeo", + "that thing over there", + "pron" + ], + [ + "자기", + "jagi", + "oneself; darling", + "pron" + ] + ], + "판타지 Fantasy & tower": [ + [ + "회귀", + "hoegwi", + "regression, returning to the past", + "noun" + ], + [ + "각성", + "gakseong", + "awakening", + "noun" + ], + [ + "능력", + "neungnyeok", + "ability", + "noun" + ], + [ + "스킬", + "seukil", + "skill", + "noun" + ], + [ + "레벨", + "rebel", + "level", + "noun" + ], + [ + "던전", + "deonjeon", + "dungeon", + "noun" + ], + [ + "탑", + "tap", + "tower", + "noun" + ], + [ + "층", + "cheung", + "floor, storey", + "noun" + ], + [ + "길드", + "gildeu", + "guild", + "noun" + ], + [ + "헌터", + "heonteo", + "hunter", + "noun" + ], + [ + "마법", + "mabeop", + "magic", + "noun" + ], + [ + "마력", + "maryeok", + "mana, magic power", + "noun" + ], + [ + "검", + "geom", + "sword", + "noun" + ], + [ + "방패", + "bangpae", + "shield", + "noun" + ], + [ + "갑옷", + "gabot", + "armour", + "noun" + ], + [ + "몬스터", + "monseuteo", + "monster", + "noun" + ], + [ + "보스", + "boseu", + "boss", + "noun" + ], + [ + "공격", + "gonggyeok", + "attack", + "noun" + ], + [ + "방어", + "bangeo", + "defence", + "noun" + ], + [ + "회복", + "hoebok", + "recovery, healing", + "noun" + ], + [ + "전투", + "jeontu", + "battle", + "noun" + ], + [ + "계약", + "gyeyak", + "contract, pact", + "noun" + ], + [ + "소환", + "sohwan", + "summoning", + "noun" + ], + [ + "용", + "yong", + "dragon", + "noun" + ], + [ + "왕", + "wang", + "king", + "noun" + ], + [ + "기사", + "gisa", + "knight", + "noun" + ], + [ + "마왕", + "mawang", + "demon lord", + "noun" + ], + [ + "힘", + "him", + "strength, power", + "noun" + ], + [ + "죽다", + "jukda", + "to die", + "verb" + ], + [ + "살아나다", + "saranada", + "to come back to life", + "verb" + ], + [ + "강해지다", + "ganghaejida", + "to grow strong", + "verb" + ], + [ + "포기하다", + "pogihada", + "to give up", + "verb" + ] + ], + "로맨스 Romance": [ + [ + "사랑", + "sarang", + "love", + "noun" + ], + [ + "짝사랑", + "jjaksarang", + "unrequited love", + "noun" + ], + [ + "고백", + "gobaek", + "a confession of feelings", + "noun" + ], + [ + "연애", + "yeonae", + "dating, a relationship", + "noun" + ], + [ + "남자친구", + "namjachingu", + "boyfriend", + "noun" + ], + [ + "여자친구", + "yeojachingu", + "girlfriend", + "noun" + ], + [ + "결혼", + "gyeolhon", + "marriage", + "noun" + ], + [ + "계약결혼", + "gyeyakgyeolhon", + "contract marriage", + "noun" + ], + [ + "약혼", + "yakhon", + "engagement", + "noun" + ], + [ + "이혼", + "ihon", + "divorce", + "noun" + ], + [ + "질투", + "jiltu", + "jealousy", + "noun" + ], + [ + "오해", + "ohae", + "a misunderstanding", + "noun" + ], + [ + "비밀", + "bimil", + "a secret", + "noun" + ], + [ + "거짓말", + "geojitmal", + "a lie", + "noun" + ], + [ + "약속", + "yaksok", + "a promise; an appointment", + "noun" + ], + [ + "마음", + "maeum", + "heart, mind, feelings", + "noun" + ], + [ + "운명", + "unmyeong", + "fate", + "noun" + ], + [ + "재벌", + "jaebeol", + "conglomerate heir", + "noun" + ], + [ + "비서", + "biseo", + "secretary", + "noun" + ], + [ + "설레다", + "seolleda", + "to flutter with excitement", + "verb" + ], + [ + "반하다", + "banhada", + "to fall for someone", + "verb" + ], + [ + "사귀다", + "sagwida", + "to go out with", + "verb" + ], + [ + "헤어지다", + "heeojida", + "to break up, to part", + "verb" + ], + [ + "안다", + "anda", + "to hug, to hold", + "verb" + ], + [ + "울다", + "ulda", + "to cry", + "verb" + ], + [ + "웃다", + "utda", + "to laugh, to smile", + "verb" + ] + ], + "학원 School life": [ + [ + "반", + "ban", + "class (group)", + "noun" + ], + [ + "학년", + "hangnyeon", + "school year, grade", + "noun" + ], + [ + "선배", + "seonbae", + "senior, upperclassman", + "noun" + ], + [ + "후배", + "hubae", + "junior, underclassman", + "noun" + ], + [ + "동아리", + "dongari", + "club, society", + "noun" + ], + [ + "시험", + "siheom", + "exam", + "noun" + ], + [ + "성적", + "seongjeok", + "grades, results", + "noun" + ], + [ + "수업", + "sueop", + "lesson, class", + "noun" + ], + [ + "교실", + "gyosil", + "classroom", + "noun" + ], + [ + "복도", + "bokdo", + "corridor", + "noun" + ], + [ + "급식", + "geupsik", + "school lunch", + "noun" + ], + [ + "숙제", + "sukje", + "homework", + "noun" + ], + [ + "방학", + "banghak", + "school holiday", + "noun" + ], + [ + "졸업", + "joreop", + "graduation", + "noun" + ], + [ + "전학", + "jeonhak", + "transferring school", + "noun" + ], + [ + "일진", + "iljin", + "school delinquent, bully", + "noun" + ], + [ + "왕따", + "wangtta", + "an outcast, a bullied kid", + "noun" + ], + [ + "소문", + "somun", + "rumour", + "noun" + ], + [ + "싸우다", + "ssauda", + "to fight, to argue", + "verb" + ], + [ + "놀리다", + "nollida", + "to tease", + "verb" + ], + [ + "챙기다", + "chaenggida", + "to look out for, to take care of", + "verb" + ], + [ + "부르다", + "bureuda", + "to call (someone)", + "verb" + ] + ], + "액션 Action & crime": [ + [ + "조직", + "jojik", + "organisation, gang", + "noun" + ], + [ + "복수", + "boksu", + "revenge", + "noun" + ], + [ + "배신", + "baesin", + "betrayal", + "noun" + ], + [ + "배신자", + "baesinja", + "traitor", + "noun" + ], + [ + "형사", + "hyeongsa", + "detective", + "noun" + ], + [ + "경찰", + "gyeongchal", + "police", + "noun" + ], + [ + "범인", + "beomin", + "culprit", + "noun" + ], + [ + "증거", + "jeunggeo", + "evidence", + "noun" + ], + [ + "사건", + "sageon", + "incident, case", + "noun" + ], + [ + "살인", + "sarin", + "murder", + "noun" + ], + [ + "총", + "chong", + "gun", + "noun" + ], + [ + "칼", + "kal", + "knife, blade", + "noun" + ], + [ + "피", + "pi", + "blood", + "noun" + ], + [ + "목숨", + "moksum", + "one's life", + "noun" + ], + [ + "위험", + "wiheom", + "danger", + "noun" + ], + [ + "함정", + "hamjeong", + "a trap", + "noun" + ], + [ + "인질", + "injil", + "hostage", + "noun" + ], + [ + "죽이다", + "jugida", + "to kill", + "verb" + ], + [ + "때리다", + "ttaerida", + "to hit", + "verb" + ], + [ + "도망치다", + "domangchida", + "to run away", + "verb" + ], + [ + "잡다", + "japda", + "to catch, to grab", + "verb" + ], + [ + "숨다", + "sumda", + "to hide (oneself)", + "verb" + ], + [ + "지키다", + "jikida", + "to protect, to keep", + "verb" + ], + [ + "속이다", + "sogida", + "to deceive", + "verb" + ], + [ + "막다", + "makda", + "to block, to stop", + "verb" + ] + ], + "대사 Manhwa lines": [ + [ + "진짜?", + "jinjja?", + "really?", + "phrase" + ], + [ + "정말?", + "jeongmal?", + "really? (a shade softer)", + "phrase" + ], + [ + "뭐야", + "mwoya", + "what is this / what the —", + "phrase" + ], + [ + "왜 이래", + "wae irae", + "what's with you", + "phrase" + ], + [ + "잠깐만", + "jamkkanman", + "hold on", + "phrase" + ], + [ + "안 돼", + "an dwae", + "no — you can't", + "phrase" + ], + [ + "하지 마", + "haji ma", + "don't", + "phrase" + ], + [ + "말도 안 돼", + "maldo an dwae", + "no way, that's absurd", + "phrase" + ], + [ + "설마", + "seolma", + "surely not, don't tell me", + "adv" + ], + [ + "그럴 리가", + "geureol riga", + "that can't be", + "phrase" + ], + [ + "어떡해", + "eotteokae", + "what do I do", + "phrase" + ], + [ + "미쳤어", + "michyeosseo", + "you're crazy / that's insane", + "phrase" + ], + [ + "됐어", + "dwaesseo", + "forget it, that's enough", + "phrase" + ], + [ + "꺼져", + "kkeojyeo", + "get lost", + "phrase" + ], + [ + "비켜", + "bikyeo", + "move, out of the way", + "phrase" + ], + [ + "가자", + "gaja", + "let's go", + "phrase" + ], + [ + "괜찮아", + "gwaenchana", + "it's fine / are you okay", + "phrase" + ], + [ + "고마워", + "gomawo", + "thanks", + "phrase" + ], + [ + "미안", + "mian", + "sorry", + "phrase" + ], + [ + "알았어", + "arasseo", + "got it, fine", + "phrase" + ], + [ + "몰라", + "molla", + "I don't know / whatever", + "phrase" + ], + [ + "싫어", + "sireo", + "I don't want to, I hate it", + "phrase" + ], + [ + "좋아", + "joa", + "good / I like it / okay", + "phrase" + ], + [ + "야", + "ya", + "hey (calling someone)", + "interj" + ], + [ + "헐", + "heol", + "whoa, no way", + "interj" + ], + [ + "어쩌라고", + "eojjeorago", + "so what", + "phrase" + ], + [ + "두고 봐", + "dugo bwa", + "just you wait", + "phrase" + ], + [ + "믿어", + "mideo", + "trust me / I trust you", + "phrase" + ], + [ + "녀석", + "nyeoseok", + "that guy, kid (rough, affectionate or not)", + "noun" + ], + [ + "이제", + "ije", + "now, from now on", + "adv" + ] + ] + } +} \ No newline at end of file diff --git a/data/gloss-extra.json b/data/gloss-extra.json new file mode 100644 index 0000000..26c5ce3 --- /dev/null +++ b/data/gloss-extra.json @@ -0,0 +1,841 @@ +{ + "note": "Roadmap words the curated deck does not cover. Gloss only — these are NOT SRS cards. In the port these seed the hand-written grammar lexicon (REVIEW.md item 6); the rest should come from KRDICT.", + "count": 167, + "entries": [ + { + "ko": "나무", + "en": "tree", + "note": "" + }, + { + "ko": "소리", + "en": "sound, noise", + "note": "" + }, + { + "ko": "바다", + "en": "sea", + "note": "" + }, + { + "ko": "다리", + "en": "leg; bridge", + "note": "" + }, + { + "ko": "개", + "en": "dog", + "note": "" + }, + { + "ko": "새", + "en": "bird", + "note": "" + }, + { + "ko": "세", + "en": "three", + "note": "native numeral, before a counter" + }, + { + "ko": "의사", + "en": "doctor", + "note": "" + }, + { + "ko": "위", + "en": "above, on top", + "note": "" + }, + { + "ko": "돼지", + "en": "pig", + "note": "" + }, + { + "ko": "가위", + "en": "scissors", + "note": "" + }, + { + "ko": "매미", + "en": "cicada", + "note": "" + }, + { + "ko": "제비", + "en": "swallow", + "note": "the bird" + }, + { + "ko": "포도", + "en": "grapes", + "note": "" + }, + { + "ko": "치마", + "en": "skirt", + "note": "" + }, + { + "ko": "까치", + "en": "magpie", + "note": "" + }, + { + "ko": "토끼", + "en": "rabbit", + "note": "" + }, + { + "ko": "꼬리", + "en": "tail", + "note": "" + }, + { + "ko": "찌개", + "en": "stew", + "note": "" + }, + { + "ko": "피자", + "en": "pizza", + "note": "" + }, + { + "ko": "쿠키", + "en": "cookie", + "note": "" + }, + { + "ko": "산", + "en": "mountain", + "note": "" + }, + { + "ko": "강", + "en": "river", + "note": "" + }, + { + "ko": "문", + "en": "door", + "note": "" + }, + { + "ko": "방", + "en": "room", + "note": "" + }, + { + "ko": "곰", + "en": "bear", + "note": "" + }, + { + "ko": "꽃", + "en": "flower", + "note": "" + }, + { + "ko": "앞", + "en": "front, in front of", + "note": "" + }, + { + "ko": "낮", + "en": "daytime", + "note": "" + }, + { + "ko": "밖", + "en": "outside", + "note": "" + }, + { + "ko": "한국어", + "en": "the Korean language", + "note": "" + }, + { + "ko": "음악", + "en": "music", + "note": "" + }, + { + "ko": "국어", + "en": "national language", + "note": "Korean as a school subject" + }, + { + "ko": "단어", + "en": "word", + "note": "" + }, + { + "ko": "언어", + "en": "language", + "note": "" + }, + { + "ko": "발음", + "en": "pronunciation", + "note": "" + }, + { + "ko": "얼음", + "en": "ice", + "note": "" + }, + { + "ko": "직업", + "en": "job, occupation", + "note": "" + }, + { + "ko": "목요일", + "en": "Thursday", + "note": "" + }, + { + "ko": "작은", + "en": "small", + "note": "modifying form of 작다" + }, + { + "ko": "입니다", + "en": "is", + "note": "the formal copula ending" + }, + { + "ko": "국물", + "en": "broth", + "note": "" + }, + { + "ko": "십만", + "en": "a hundred thousand", + "note": "" + }, + { + "ko": "작년", + "en": "last year", + "note": "" + }, + { + "ko": "몇 명", + "en": "how many people", + "note": "" + }, + { + "ko": "습니다", + "en": "the formal polite verb ending", + "note": "attaches to the stem: 갑니다, 먹습니다" + }, + { + "ko": "한국말", + "en": "Korean", + "note": "the spoken language" + }, + { + "ko": "꽃말", + "en": "the meaning of a flower", + "note": "" + }, + { + "ko": "값", + "en": "price, value", + "note": "" + }, + { + "ko": "닭", + "en": "chicken", + "note": "" + }, + { + "ko": "삶", + "en": "life", + "note": "" + }, + { + "ko": "넓다", + "en": "to be wide", + "note": "" + }, + { + "ko": "짧다", + "en": "to be short", + "note": "" + }, + { + "ko": "신라", + "en": "Silla", + "note": "an ancient Korean kingdom" + }, + { + "ko": "설날", + "en": "Lunar New Year", + "note": "" + }, + { + "ko": "못해", + "en": "can't do it", + "note": "" + }, + { + "ko": "축하", + "en": "congratulations", + "note": "" + }, + { + "ko": "놓다", + "en": "to put down, to let go", + "note": "" + }, + { + "ko": "앉히다", + "en": "to seat someone", + "note": "causative of 앉다" + }, + { + "ko": "나라", + "en": "country", + "note": "" + }, + { + "ko": "얼굴", + "en": "face", + "note": "" + }, + { + "ko": "목소리", + "en": "voice", + "note": "" + }, + { + "ko": "이야기", + "en": "story, talk", + "note": "" + }, + { + "ko": "아니", + "en": "no", + "note": "반말" + }, + { + "ko": "맞아", + "en": "that's right", + "note": "" + }, + { + "ko": "이번", + "en": "this time", + "note": "" + }, + { + "ko": "다음", + "en": "next", + "note": "" + }, + { + "ko": "월", + "en": "month", + "note": "in dates: 삼월 = March" + }, + { + "ko": "원", + "en": "won", + "note": "the currency" + }, + { + "ko": "명", + "en": "people", + "note": "counter" + }, + { + "ko": "마리", + "en": "animals", + "note": "counter" + }, + { + "ko": "살", + "en": "years of age", + "note": "counter" + }, + { + "ko": "잔", + "en": "cups, glasses", + "note": "counter" + }, + { + "ko": "번", + "en": "times, occasions", + "note": "counter" + }, + { + "ko": "시", + "en": "o'clock", + "note": "counter" + }, + { + "ko": "못", + "en": "cannot", + "note": "placed before the verb" + }, + { + "ko": "아직", + "en": "still, not yet", + "note": "" + }, + { + "ko": "벌써", + "en": "already", + "note": "" + }, + { + "ko": "전혀", + "en": "not at all", + "note": "with a negative" + }, + { + "ko": "별로", + "en": "not particularly", + "note": "with a negative" + }, + { + "ko": "다르다", + "en": "to be different", + "note": "르 irregular" + }, + { + "ko": "빠르다", + "en": "to be fast", + "note": "르 irregular" + }, + { + "ko": "맵다", + "en": "to be spicy", + "note": "ㅂ irregular" + }, + { + "ko": "걷다", + "en": "to walk", + "note": "ㄷ irregular" + }, + { + "ko": "묻다", + "en": "to ask", + "note": "ㄷ irregular" + }, + { + "ko": "낫다", + "en": "to be better; to heal", + "note": "ㅅ irregular" + }, + { + "ko": "짓다", + "en": "to build", + "note": "ㅅ irregular" + }, + { + "ko": "그렇다", + "en": "to be so", + "note": "ㅎ irregular" + }, + { + "ko": "어떻다", + "en": "to be how", + "note": "ㅎ irregular" + }, + { + "ko": "빨갛다", + "en": "to be red", + "note": "ㅎ irregular" + }, + { + "ko": "하얗다", + "en": "to be white", + "note": "ㅎ irregular" + }, + { + "ko": "까맣다", + "en": "to be black", + "note": "ㅎ irregular" + }, + { + "ko": "놀다", + "en": "to play", + "note": "ㄹ irregular" + }, + { + "ko": "멀다", + "en": "to be far", + "note": "ㄹ irregular" + }, + { + "ko": "길다", + "en": "to be long", + "note": "ㄹ irregular" + }, + { + "ko": "만들다", + "en": "to make", + "note": "ㄹ irregular" + }, + { + "ko": "아까", + "en": "a while ago", + "note": "" + }, + { + "ko": "방금", + "en": "just now", + "note": "" + }, + { + "ko": "나중에", + "en": "later", + "note": "" + }, + { + "ko": "곧", + "en": "soon", + "note": "" + }, + { + "ko": "이따가", + "en": "in a little while", + "note": "" + }, + { + "ko": "정말", + "en": "really", + "note": "" + }, + { + "ko": "찾다", + "en": "to look for, to find", + "note": "" + }, + { + "ko": "열다", + "en": "to open", + "note": "ㄹ irregular" + }, + { + "ko": "닫다", + "en": "to close", + "note": "" + }, + { + "ko": "시작하다", + "en": "to start", + "note": "" + }, + { + "ko": "끝나다", + "en": "to end", + "note": "" + }, + { + "ko": "요즘", + "en": "these days", + "note": "" + }, + { + "ko": "원래", + "en": "originally", + "note": "" + }, + { + "ko": "보통", + "en": "usually", + "note": "" + }, + { + "ko": "누가", + "en": "who", + "note": "subject form of 누구" + }, + { + "ko": "제가", + "en": "I", + "note": "humble, subject form" + }, + { + "ko": "뭘", + "en": "what", + "note": "contraction of 무엇을" + }, + { + "ko": "절", + "en": "me", + "note": "humble, contraction of 저를" + }, + { + "ko": "아래", + "en": "below", + "note": "" + }, + { + "ko": "옆", + "en": "beside, next to", + "note": "" + }, + { + "ko": "뒤", + "en": "behind", + "note": "" + }, + { + "ko": "사이", + "en": "between", + "note": "" + }, + { + "ko": "혼자", + "en": "alone", + "note": "" + }, + { + "ko": "하고", + "en": "and; with", + "note": "particle" + }, + { + "ko": "랑", + "en": "and; with", + "note": "casual particle" + }, + { + "ko": "로", + "en": "by, with, toward", + "note": "particle" + }, + { + "ko": "부터", + "en": "from", + "note": "particle" + }, + { + "ko": "까지", + "en": "until, up to", + "note": "particle" + }, + { + "ko": "처럼", + "en": "like, as", + "note": "particle" + }, + { + "ko": "그때", + "en": "at that time", + "note": "" + }, + { + "ko": "걔", + "en": "that kid", + "note": "contraction of 그 애" + }, + { + "ko": "얘", + "en": "this kid", + "note": "contraction of 이 애" + }, + { + "ko": "그렇습니다", + "en": "that is so", + "note": "formal" + }, + { + "ko": "말씀", + "en": "words", + "note": "honorific" + }, + { + "ko": "님", + "en": "honorific suffix", + "note": "선생님, 형님" + }, + { + "ko": "드리다", + "en": "to give", + "note": "humble, to someone above you" + }, + { + "ko": "계시다", + "en": "to be, to stay", + "note": "honorific of 있다" + }, + { + "ko": "주무시다", + "en": "to sleep", + "note": "honorific of 자다" + }, + { + "ko": "걱정하다", + "en": "to worry", + "note": "" + }, + { + "ko": "신경 쓰다", + "en": "to be bothered, to care about", + "note": "" + }, + { + "ko": "싶다", + "en": "to want", + "note": "used after -고" + }, + { + "ko": "수", + "en": "possibility", + "note": "used in -ㄹ 수 있다" + }, + { + "ko": "필요하다", + "en": "to be necessary", + "note": "" + }, + { + "ko": "그렇지만", + "en": "but, however", + "note": "" + }, + { + "ko": "왜냐하면", + "en": "because", + "note": "opens the reason" + }, + { + "ko": "때문에", + "en": "because of", + "note": "after a noun" + }, + { + "ko": "만약", + "en": "if", + "note": "paired with -면" + }, + { + "ko": "혹시", + "en": "by any chance", + "note": "" + }, + { + "ko": "아마", + "en": "probably", + "note": "" + }, + { + "ko": "기분", + "en": "mood, feeling", + "note": "" + }, + { + "ko": "걱정", + "en": "worry", + "note": "" + }, + { + "ko": "미안하다", + "en": "to be sorry", + "note": "" + }, + { + "ko": "고맙다", + "en": "to be thankful", + "note": "ㅂ irregular" + }, + { + "ko": "것", + "en": "thing", + "note": "bound noun" + }, + { + "ko": "거", + "en": "thing", + "note": "casual 것" + }, + { + "ko": "때", + "en": "time, when", + "note": "" + }, + { + "ko": "곳", + "en": "place", + "note": "" + }, + { + "ko": "적", + "en": "an occasion", + "note": "used in -ㄴ 적 있다" + }, + { + "ko": "라고", + "en": "quoting particle", + "note": "marks what someone said" + }, + { + "ko": "대", + "en": "he says that…", + "note": "reported statement, contracted from -다고 해" + }, + { + "ko": "래", + "en": "he says to…", + "note": "reported suggestion or request, from -라고 해" + }, + { + "ko": "냬", + "en": "he asks whether…", + "note": "reported question, from -냐고 해" + }, + { + "ko": "재", + "en": "he suggests that we…", + "note": "reported proposal, from -자고 해" + }, + { + "ko": "이다", + "en": "to be", + "note": "the copula, dictionary form" + }, + { + "ko": "했다", + "en": "did", + "note": "plain written past" + }, + { + "ko": "한다", + "en": "does", + "note": "plain written present" + }, + { + "ko": "였다", + "en": "was", + "note": "plain written past of 이다" + }, + { + "ko": "난", + "en": "I", + "note": "contraction of 나는" + }, + { + "ko": "넌", + "en": "you", + "note": "contraction of 너는" + }, + { + "ko": "건", + "en": "the thing", + "note": "contraction of 것은" + }, + { + "ko": "이건", + "en": "this thing", + "note": "contraction of 이것은" + } + ] +} \ No newline at end of file diff --git a/data/grammar.json b/data/grammar.json new file mode 100644 index 0000000..3e96543 --- /dev/null +++ b/data/grammar.json @@ -0,0 +1,961 @@ +{ + "note": "Reference grammar points. read:1 marks reading-priority items.", + "points": [ + { + "id": "g-eun", + "cat": "조사 Particles", + "form": "은 / 는", + "name": "topic marker", + "why": "Names what the sentence is about, and quietly sets it against something else. 은 after a consonant, 는 after a vowel. A Korean sentence can drop it once the topic is obvious.", + "ex": [ + [ + "저는 학생이에요.", + "jeoneun haksaeng-ieyo.", + "I'm a student." + ], + [ + "김치는 매워요.", + "gimchineun maewoyo.", + "Kimchi — that one's spicy." + ] + ] + }, + { + "id": "g-iga", + "cat": "조사 Particles", + "form": "이 / 가", + "name": "subject marker", + "why": "Marks who or what performs the verb, and introduces information that is new. 이 after a consonant, 가 after a vowel. 저 + 가 becomes 제가.", + "ex": [ + [ + "친구가 왔어요.", + "chinguga wasseoyo.", + "A friend came." + ], + [ + "시간이 없어요.", + "sigani eopseoyo.", + "There's no time." + ] + ] + }, + { + "id": "g-eul", + "cat": "조사 Particles", + "form": "을 / 를", + "name": "object marker", + "why": "Marks the thing the verb acts on. 을 after a consonant, 를 after a vowel. Dropped constantly in speech.", + "ex": [ + [ + "밥을 먹어요.", + "babeul meogeoyo.", + "I'm eating." + ], + [ + "커피를 마셔요.", + "keopireul masyeoyo.", + "I'm drinking coffee." + ] + ] + }, + { + "id": "g-e", + "cat": "조사 Particles", + "form": "에", + "name": "at, to, in (destination & time)", + "why": "Points at a destination, a point in time, or a static location with 있다 / 없다. Never used for where an action takes place — that is 에서.", + "ex": [ + [ + "학교에 가요.", + "hakgyoe gayo.", + "I go to school." + ], + [ + "세 시에 만나요.", + "se sie mannayo.", + "Let's meet at three." + ] + ] + }, + { + "id": "g-eseo", + "cat": "조사 Particles", + "form": "에서", + "name": "at, in (action), from", + "why": "Where something happens, or where it started. Pair it with 에 to feel the split: 집에 있어요 (I'm at home) vs 집에서 일해요 (I work at home).", + "ex": [ + [ + "카페에서 공부해요.", + "kapeeseo gongbuhaeyo.", + "I study at the cafe." + ], + [ + "독일에서 왔어요.", + "dogireseo wasseoyo.", + "I'm from Germany." + ] + ] + }, + { + "id": "g-do", + "cat": "조사 Particles", + "form": "도", + "name": "also, too", + "why": "Replaces 은/는, 이/가 and 을/를 rather than stacking onto them. 저는도 is wrong; 저도 is right.", + "ex": [ + [ + "저도 가요.", + "jeodo gayo.", + "I'm going too." + ], + [ + "물도 주세요.", + "muldo juseyo.", + "Water as well, please." + ] + ] + }, + { + "id": "g-man", + "cat": "조사 Particles", + "form": "만", + "name": "only, just", + "why": "Also replaces the subject and object markers. Attaches after time and counters happily.", + "ex": [ + [ + "물만 마셔요.", + "mulman masyeoyo.", + "I only drink water." + ], + [ + "십 분만 기다려 주세요.", + "sip bunman gidaryeo juseyo.", + "Please wait just ten minutes." + ] + ] + }, + { + "id": "g-hago", + "cat": "조사 Particles", + "form": "하고 · 와/과 · (이)랑", + "name": "and, with", + "why": "Three ways to join two nouns. 와 after a vowel, 과 after a consonant — the written pair. 하고 is neutral, (이)랑 is casual speech.", + "ex": [ + [ + "빵하고 우유를 샀어요.", + "ppanghago uyureul sasseoyo.", + "I bought bread and milk." + ], + [ + "친구와 같이 갔어요.", + "chinguwa gachi gasseoyo.", + "I went together with a friend." + ] + ] + }, + { + "id": "g-ui", + "cat": "조사 Particles", + "form": "의", + "name": "possessive 's", + "why": "Written 의 but pronounced [에] here. Often dropped entirely: 친구 가방 is perfectly natural. 저의 contracts to 제, 나의 to 내.", + "ex": [ + [ + "친구의 가방이에요.", + "chinguui gabang-ieyo.", + "It's my friend's bag." + ], + [ + "한국의 날씨는 어때요?", + "hangugui nalssineun eottaeyo?", + "How is the weather in Korea?" + ] + ] + }, + { + "id": "g-buteo", + "cat": "조사 Particles", + "form": "부터 ~ 까지", + "name": "from ~ until", + "why": "부터 ~ 까지 spans time; 에서 ~ 까지 spans distance. Mixing them up is the classic beginner tell.", + "ex": [ + [ + "아홉 시부터 여섯 시까지 일해요.", + "ahop sibuteo yeoseot sikkaji ilhaeyo.", + "I work from nine until six." + ], + [ + "집에서 회사까지 삼십 분 걸려요.", + "jibeseo hoesakkaji samsip bun geollyeoyo.", + "It takes thirty minutes from home to the office." + ] + ] + }, + { + "id": "g-boda", + "cat": "조사 Particles", + "form": "보다 (더)", + "name": "more than", + "why": "Attaches to the thing being beaten, not the winner: A는 B보다 — A beats B. 더 is optional but very common.", + "ex": [ + [ + "오늘이 어제보다 더워요.", + "oneuri eojeboda deowoyo.", + "Today is hotter than yesterday." + ], + [ + "저는 커피보다 차를 더 좋아해요.", + "jeoneun keopiboda chareul deo joahaeyo.", + "I like tea more than coffee." + ] + ] + }, + { + "id": "g-cheoreom", + "cat": "조사 Particles", + "form": "처럼 · 같이", + "name": "like, as", + "why": "Both attach directly to a noun. 같이 here is the adverb 'like', the same word as 'together' — context separates them.", + "ex": [ + [ + "한국 사람처럼 말해요.", + "hanguk saramcheoreom malhaeyo.", + "You speak like a Korean." + ], + [ + "얼음같이 차가워요.", + "eoreumgachi chagawoyo.", + "It's cold as ice." + ] + ] + }, + { + "id": "g-ieyo", + "cat": "서술 Predicates", + "form": "N + 이에요 / 예요", + "name": "to be", + "why": "The copula. 이에요 after a consonant, 예요 after a vowel. Formal register: 입니다. Negative: N이/가 아니에요.", + "ex": [ + [ + "저는 파비안이에요.", + "jeoneun pabian-ieyo.", + "I'm Fabian." + ], + [ + "여기가 우리 집이에요.", + "yeogiga uri jibieyo.", + "This is our house." + ] + ] + }, + { + "id": "g-ayo", + "cat": "서술 Predicates", + "form": "V/A + 아요 / 어요", + "name": "polite present (해요체)", + "why": "The everyday polite ending, and the one to master first. Stem vowel ㅏ or ㅗ takes 아요; everything else takes 어요; 하다 becomes 해요. Same form covers present tense, near future and a gentle suggestion.", + "ex": [ + [ + "지금 밥을 먹어요.", + "jigeum babeul meogeoyo.", + "I'm eating now." + ], + [ + "매일 운동해요.", + "maeil undonghaeyo.", + "I exercise every day." + ] + ] + }, + { + "id": "g-past", + "cat": "서술 Predicates", + "form": "V/A + 았 / 었어요", + "name": "past tense", + "why": "Take the 아/어 form, insert ㅆ, add 어요. 먹어요 → 먹었어요, 가요 → 갔어요, 해요 → 했어요.", + "ex": [ + [ + "어제 영화를 봤어요.", + "eoje yeonghwareul bwasseoyo.", + "I watched a film yesterday." + ], + [ + "밥을 안 먹었어요.", + "babeul an meogeosseoyo.", + "I didn't eat." + ] + ] + }, + { + "id": "g-future", + "cat": "서술 Predicates", + "form": "V + (으)ㄹ 거예요", + "name": "future, intention", + "why": "Plans and predictions. Add ㄹ 거예요 to a vowel-final stem, 을 거예요 after a consonant. Softer and more common than the bookish -겠-.", + "ex": [ + [ + "내일 한국에 갈 거예요.", + "naeil hanguge gal geoyeyo.", + "I'll go to Korea tomorrow." + ], + [ + "주말에 쉴 거예요.", + "jumare swil geoyeyo.", + "I'm going to rest at the weekend." + ] + ] + }, + { + "id": "g-nida", + "cat": "서술 Predicates", + "form": "V/A + ㅂ니다 / 습니다", + "name": "formal polite (합쇼체)", + "why": "The register of announcements, presentations, the news and the army. Vowel stem takes ㅂ니다, consonant stem 습니다. Questions end -ㅂ니까?", + "ex": [ + [ + "감사합니다.", + "gamsahamnida.", + "Thank you." + ], + [ + "저는 독일에서 왔습니다.", + "jeoneun dogireseo watseumnida.", + "I came from Germany." + ] + ] + }, + { + "id": "g-an", + "cat": "부정 Negation", + "form": "안 + V/A · V/A + 지 않다", + "name": "simple negation", + "why": "안 goes in front and is the spoken default; -지 않다 goes behind and reads a touch more formal. For 하다 verbs, 안 slips inside: 공부 안 해요, not 안 공부해요.", + "ex": [ + [ + "오늘은 안 바빠요.", + "oneureun an bappayo.", + "I'm not busy today." + ], + [ + "그 영화는 재미있지 않았어요.", + "geu yeonghwaneun jaemiitji anasseoyo.", + "That film wasn't interesting." + ] + ] + }, + { + "id": "g-mot", + "cat": "부정 Negation", + "form": "못 + V · V + 지 못하다", + "name": "cannot (ability, circumstance)", + "why": "안 is 'I don't'; 못 is 'I can't, even though I might want to'. Pronounced [몯] before a consonant, [모ㅅ] links forward before a vowel.", + "ex": [ + [ + "매운 음식을 못 먹어요.", + "maeun eumsigeul mot meogeoyo.", + "I can't eat spicy food." + ], + [ + "어제 못 갔어요.", + "eoje mot gasseoyo.", + "I couldn't go yesterday." + ] + ] + }, + { + "id": "g-jima", + "cat": "부정 Negation", + "form": "V + 지 마세요", + "name": "please don't", + "why": "The negative command. Attaches to the plain verb stem. Blunter: -지 마.", + "ex": [ + [ + "여기에서 사진을 찍지 마세요.", + "yeogieseo sajineul jjikji maseyo.", + "Please don't take photos here." + ], + [ + "걱정하지 마세요.", + "geokjeonghaji maseyo.", + "Don't worry." + ] + ] + }, + { + "id": "g-gosipda", + "cat": "표현 Expressions", + "form": "V + 고 싶다", + "name": "want to", + "why": "Only for I and you. Talking about a third person's wants needs -고 싶어하다, because Korean does not claim to know another person's inner state.", + "ex": [ + [ + "한국에 가고 싶어요.", + "hanguge gago sipeoyo.", + "I want to go to Korea." + ], + [ + "뭐 먹고 싶어요?", + "mwo meokgo sipeoyo?", + "What do you want to eat?" + ] + ] + }, + { + "id": "g-lsu", + "cat": "표현 Expressions", + "form": "V + (으)ㄹ 수 있다 / 없다", + "name": "can / cannot", + "why": "Literally 'there is a way to'. Vowel stem takes ㄹ 수, consonant stem 을 수.", + "ex": [ + [ + "한국어를 조금 할 수 있어요.", + "hangugeoreul jogeum hal su isseoyo.", + "I can speak a little Korean." + ], + [ + "오늘은 갈 수 없어요.", + "oneureun gal su eopseoyo.", + "I can't go today." + ] + ] + }, + { + "id": "g-ayadoeda", + "cat": "표현 Expressions", + "form": "V/A + 아 / 어야 되다", + "name": "must, have to", + "why": "Built on the 아/어 form. 되다 and 하다 are interchangeable here; 되다 is more spoken.", + "ex": [ + [ + "지금 가야 돼요.", + "jigeum gaya dwaeyo.", + "I have to go now." + ], + [ + "약을 먹어야 해요.", + "yageul meogeoya haeyo.", + "You need to take the medicine." + ] + ] + }, + { + "id": "g-euseyo", + "cat": "표현 Expressions", + "form": "V + (으)세요", + "name": "polite request & honorific", + "why": "Two jobs at once: a soft command to the listener, and the honorific present when the subject deserves respect. A few verbs go irregular — 먹다 → 드세요, 자다 → 주무세요, 있다 → 계세요.", + "ex": [ + [ + "여기 앉으세요.", + "yeogi anjeuseyo.", + "Please sit here." + ], + [ + "할머니는 지금 주무세요.", + "halmeonineun jigeum jumuseyo.", + "Grandmother is sleeping." + ] + ] + }, + { + "id": "g-juseyo", + "cat": "표현 Expressions", + "form": "V + 아 / 어 주세요", + "name": "please do it for me", + "why": "The single most useful thing to say in a shop, a taxi or a classroom. Adds a sense of favour that plain -(으)세요 lacks.", + "ex": [ + [ + "천천히 말해 주세요.", + "cheoncheonhi malhae juseyo.", + "Please speak slowly." + ], + [ + "이것 좀 보여 주세요.", + "igeot jom boyeo juseyo.", + "Could you show me this one." + ] + ] + }, + { + "id": "g-ryeogo", + "cat": "표현 Expressions", + "form": "V + (으)려고 하다", + "name": "intend to, be about to", + "why": "A plan already formed, not a fresh decision. Both clauses must share a subject.", + "ex": [ + [ + "한국어를 배우려고 해요.", + "hangugeoreul baeuryeogo haeyo.", + "I intend to learn Korean." + ], + [ + "나가려고 했어요.", + "nagaryeogo haesseoyo.", + "I was about to go out." + ] + ] + }, + { + "id": "g-reo", + "cat": "표현 Expressions", + "form": "V + (으)러 가다 / 오다", + "name": "go in order to", + "why": "Only pairs with verbs of movement — 가다, 오다, 다니다. The purpose comes first, the movement second.", + "ex": [ + [ + "밥 먹으러 가요.", + "bap meogeureo gayo.", + "Let's go eat." + ], + [ + "친구를 만나러 왔어요.", + "chingureul mannareo wasseoyo.", + "I came to meet a friend." + ] + ] + }, + { + "id": "g-geotgatda", + "cat": "표현 Expressions", + "form": "A/V + 것 같다", + "name": "it seems, I think", + "why": "Korean prefers a hedge to a flat assertion, so this shows up far more than 'I think' does in English. 올 것 같다 for a guess ahead, 온 것 같다 for a guess about what already happened.", + "ex": [ + [ + "비가 올 것 같아요.", + "biga ol geot gatayo.", + "It looks like rain." + ], + [ + "이 책이 좋은 것 같아요.", + "i chaegi joeun geot gatayo.", + "I think this book is good." + ] + ] + }, + { + "id": "g-lkkayo", + "cat": "표현 Expressions", + "form": "V + (으)ㄹ까요?", + "name": "shall we? I wonder?", + "why": "With a first-person plural sense it proposes; with a third-person subject it wonders aloud.", + "ex": [ + [ + "같이 갈까요?", + "gachi galkkayo?", + "Shall we go together?" + ], + [ + "비가 올까요?", + "biga olkkayo?", + "Do you think it'll rain?" + ] + ] + }, + { + "id": "g-neyo", + "cat": "표현 Expressions", + "form": "A/V + 네요", + "name": "realisation, mild surprise", + "why": "Marks something you have just noticed. Warm and very common — the difference between 좋아요 (it's good) and 좋네요 (oh, that's nice).", + "ex": [ + [ + "날씨가 좋네요.", + "nalssiga jonneyo.", + "The weather's lovely, isn't it." + ], + [ + "한국어를 잘하네요.", + "hangugeoreul jalhaneyo.", + "Oh — your Korean is good." + ] + ] + }, + { + "id": "g-go", + "cat": "연결 Connectives", + "form": "V/A + 고", + "name": "and (listing, sequence)", + "why": "Joins two clauses with no cause-and-effect claim. Tense is carried only by the final verb.", + "ex": [ + [ + "밥을 먹고 커피를 마셨어요.", + "babeul meokgo keopireul masyeosseoyo.", + "I ate and then drank coffee." + ], + [ + "이 카페는 조용하고 싸요.", + "i kapeneun joyonghago ssayo.", + "This cafe is quiet and cheap." + ] + ] + }, + { + "id": "g-jiman", + "cat": "연결 Connectives", + "form": "V/A + 지만", + "name": "but", + "why": "Attaches straight to the stem, and unlike -아/어서 it does take tense: 했지만.", + "ex": [ + [ + "한국어는 어렵지만 재미있어요.", + "hangugeoneun eoryeopjiman jaemiisseoyo.", + "Korean is hard but it's fun." + ], + [ + "갔지만 못 만났어요.", + "gatjiman mot mannasseoyo.", + "I went, but I couldn't meet them." + ] + ] + }, + { + "id": "g-aseo", + "cat": "연결 Connectives", + "form": "V/A + 아 / 어서", + "name": "because; and then", + "why": "Two readings from one form: a reason, or a sequence where the first action sets up the second. Never carries past tense before 서, and never precedes a command or a suggestion — that is what -(으)니까 is for.", + "ex": [ + [ + "배가 아파서 병원에 갔어요.", + "baega apaseo byeongwone gasseoyo.", + "I went to the hospital because my stomach hurt." + ], + [ + "친구를 만나서 밥을 먹었어요.", + "chingureul mannaseo babeul meogeosseoyo.", + "I met a friend and we ate." + ] + ] + }, + { + "id": "g-nikka", + "cat": "연결 Connectives", + "form": "V/A + (으)니까", + "name": "because (before commands)", + "why": "The reason ending that is allowed to precede 하세요, 합시다 and -(으)ㄹ까요. It also accepts past tense, which -아/어서 refuses.", + "ex": [ + [ + "비가 오니까 택시를 탑시다.", + "biga onikka taeksireul tapsida.", + "It's raining, so let's take a taxi." + ], + [ + "시간이 없으니까 빨리 가세요.", + "sigani eopseunikka ppalli gaseyo.", + "There's no time, so please hurry." + ] + ] + }, + { + "id": "g-neunde", + "cat": "연결 Connectives", + "form": "V + 는데 · A + (으)ㄴ데", + "name": "background, contrast, soft lead-in", + "why": "The hardest-working ending in conversational Korean. It sets the scene, softens a contrast, or simply opens a topic and trails off. Verbs take 는데, adjectives (으)ㄴ데, past tense 았/었는데.", + "ex": [ + [ + "지금 공부하는데 좀 어려워요.", + "jigeum gongbuhaneunde jom eoryeowoyo.", + "I'm studying now, and it's a bit hard." + ], + [ + "이 옷은 예쁜데 비싸요.", + "i oseun yeppeunde bissayo.", + "These clothes are pretty, but expensive." + ] + ] + }, + { + "id": "g-gijeone", + "cat": "연결 Connectives", + "form": "V + 기 전에", + "name": "before doing", + "why": "Always the plain stem + 기 전에, with no tense marking. With a noun, drop the 기: 식사 전에.", + "ex": [ + [ + "자기 전에 책을 읽어요.", + "jagi jeone chaegeul ilgeoyo.", + "I read before going to sleep." + ], + [ + "가기 전에 전화하세요.", + "gagi jeone jeonhwahaseyo.", + "Call before you go." + ] + ] + }, + { + "id": "g-nhue", + "cat": "연결 Connectives", + "form": "V + (으)ㄴ 후에", + "name": "after doing", + "why": "Mirror of 기 전에, but built on the past-modifier form: 먹은 후에, 간 후에. -(으)ㄴ 다음에 means the same thing.", + "ex": [ + [ + "밥을 먹은 후에 산책해요.", + "babeul meogeun hue sanchaekaeyo.", + "After eating I go for a walk." + ], + [ + "일이 끝난 후에 만나요.", + "iri kkeunnan hue mannayo.", + "Let's meet after work finishes." + ] + ] + }, + { + "id": "g-numsys", + "cat": "수 Numbers & counters", + "form": "일이삼 vs 하나둘셋", + "name": "the two number systems", + "why": "Korean runs two full sets of numbers side by side. Sino-Korean handles dates, money, minutes, phone numbers, floors and anything above 99 in counting. Native Korean handles counted objects, people, hours and age. A clock reading uses both at once.", + "ex": [ + [ + "세 시 삼십 분이에요.", + "se si samsip bunieyo.", + "It's 3:30. (native hour, Sino minute)" + ], + [ + "스물다섯 살이에요.", + "seumuldaseot sarieyo.", + "I'm twenty-five." + ] + ] + }, + { + "id": "g-counters", + "cat": "수 Numbers & counters", + "form": "개 · 명 · 마리 · 잔 · 권 · 살", + "name": "counters", + "why": "Nothing is counted bare — a counter word sits between the number and the verb. Before a counter, 하나·둘·셋·넷·스물 shorten to 한·두·세·네·스무. Order: noun, number, counter.", + "ex": [ + [ + "커피 두 잔 주세요.", + "keopi du jan juseyo.", + "Two coffees, please." + ], + [ + "고양이 세 마리가 있어요.", + "goyangi se mariga isseoyo.", + "There are three cats." + ] + ] + }, + { + "id": "g-banmal", + "cat": "반말 Casual speech", + "form": "V/A + 아 / 어", + "name": "반말 — plain casual speech", + "read": 1, + "why": "Take the polite 아/어요 form and drop the 요. That is 반말, and it is what almost every line of manhwa dialogue is written in. Between friends, from an older speaker to a younger one, and in a character's own thoughts.", + "ex": [ + [ + "밥 먹어.", + "", + "Eat. / I'm eating." + ], + [ + "어디 가?", + "", + "Where are you going?" + ] + ] + }, + { + "id": "g-ya", + "cat": "반말 Casual speech", + "form": "N + 야 / 이야", + "name": "casual 'to be'", + "read": 1, + "why": "The 반말 copula: 이에요/예요 with the 요 gone and the vowel shortened. 야 after a vowel, 이야 after a consonant. Questions are identical — only the rising tone and the question mark differ.", + "ex": [ + [ + "우리 친구야.", + "", + "We're friends." + ], + [ + "범인은 저 사람이야.", + "", + "The culprit is that person." + ] + ] + }, + { + "id": "g-eosseo", + "cat": "반말 Casual speech", + "form": "V/A + 았 / 었어", + "name": "casual past", + "read": 1, + "why": "The past tense minus the 요. This is the workhorse of manhwa narration and flashbacks.", + "ex": [ + [ + "소문 들었어?", + "", + "Did you hear the rumour?" + ], + [ + "나 회귀했어.", + "", + "I regressed." + ] + ] + }, + { + "id": "g-lge", + "cat": "반말 Casual speech", + "form": "V + (으)ㄹ게", + "name": "I'll do it (a promise to you)", + "read": 1, + "why": "A first-person commitment made for the listener's benefit — not a neutral prediction. Very common in the panel right before someone does something heroic.", + "ex": [ + [ + "내가 지킬게.", + "", + "I'll protect you." + ], + [ + "나 갈게.", + "", + "I'm off." + ] + ] + }, + { + "id": "g-lgeoya", + "cat": "반말 Casual speech", + "form": "V + (으)ㄹ 거야", + "name": "casual future / intention", + "read": 1, + "why": "-(으)ㄹ 거예요 in 반말. Declaring what you are going to do, often as a threat or a vow.", + "ex": [ + [ + "복수할 거야.", + "", + "I'm going to take revenge." + ], + [ + "안 갈 거야.", + "", + "I'm not going." + ] + ] + }, + { + "id": "g-ja", + "cat": "반말 Casual speech", + "form": "V + 자", + "name": "let's", + "read": 1, + "why": "The casual suggestion. 가자, 하자, 먹자 — three syllables that move a whole scene along.", + "ex": [ + [ + "우리 헤어지자.", + "", + "Let's break up." + ], + [ + "가자.", + "", + "Let's go." + ] + ] + }, + { + "id": "g-janha", + "cat": "반말 Casual speech", + "form": "V/A + 잖아", + "name": "you know, as you're aware", + "read": 1, + "why": "Appeals to something the listener already knows, often impatiently. Reading it as plain 'because' loses the exasperation the panel is drawing.", + "ex": [ + [ + "내가 말했잖아.", + "", + "I told you, didn't I." + ], + [ + "위험하잖아.", + "", + "It's dangerous, you know." + ] + ] + }, + { + "id": "g-geodeun", + "cat": "반말 Casual speech", + "form": "V/A + 거든", + "name": "the thing is…", + "read": 1, + "why": "Supplies a reason the listener did not have, sometimes with a note of defiance. Often ends a speech bubble on its own.", + "ex": [ + [ + "나 바쁘거든.", + "", + "I'm busy, that's why." + ], + [ + "걔가 먼저 그랬거든.", + "", + "He started it, actually." + ] + ] + }, + { + "id": "g-deora", + "cat": "반말 Casual speech", + "form": "V/A + 더라", + "name": "I saw it myself", + "read": 1, + "why": "Reports something the speaker personally witnessed, recalled with a little surprise. A flag that the line is a memory or an eyewitness account.", + "ex": [ + [ + "그 사람 진짜 강하더라.", + "", + "That person was seriously strong, I saw it." + ] + ] + }, + { + "id": "g-quote", + "cat": "반말 Casual speech", + "form": "V + 대 / 래 / 냬 / 재", + "name": "hearsay — 'they say'", + "read": 1, + "why": "Contracted quotation, everywhere in gossip panels. -대 reports a statement, -래 a command or an 'is', -냬 a question, -재 a suggestion. If a line ends in one of these, someone else said it first.", + "ex": [ + [ + "시험 어렵대.", + "", + "They say the exam is hard." + ], + [ + "같이 가재.", + "", + "He says let's go together." + ] + ] + }, + { + "id": "g-neun", + "cat": "반말 Casual speech", + "form": "V + 는 / A + (으)ㄴ + N", + "name": "modifying a noun", + "read": 1, + "why": "Korean puts the whole description in front of the noun, where English puts it after: 내가 죽인 몬스터 is 'the monster I killed'. Once you can see where the modifier ends and the noun begins, long manhwa narration stops being a wall.", + "ex": [ + [ + "내가 죽인 몬스터.", + "", + "The monster I killed." + ], + [ + "강한 사람.", + "", + "A strong person." + ] + ] + } + ] +} \ No newline at end of file diff --git a/data/hangul.json b/data/hangul.json new file mode 100644 index 0000000..8605346 --- /dev/null +++ b/data/hangul.json @@ -0,0 +1,492 @@ +{ + "consonants": [ + { + "jamo": "ㄱ", + "roman": "g / k", + "name": "기역", + "tense": false + }, + { + "jamo": "ㄲ", + "roman": "kk", + "name": "쌍기역", + "tense": true + }, + { + "jamo": "ㄴ", + "roman": "n", + "name": "니은", + "tense": false + }, + { + "jamo": "ㄷ", + "roman": "d / t", + "name": "디귿", + "tense": false + }, + { + "jamo": "ㄸ", + "roman": "tt", + "name": "쌍디귿", + "tense": true + }, + { + "jamo": "ㄹ", + "roman": "r / l", + "name": "리을", + "tense": false + }, + { + "jamo": "ㅁ", + "roman": "m", + "name": "미음", + "tense": false + }, + { + "jamo": "ㅂ", + "roman": "b / p", + "name": "비읍", + "tense": false + }, + { + "jamo": "ㅃ", + "roman": "pp", + "name": "쌍비읍", + "tense": true + }, + { + "jamo": "ㅅ", + "roman": "s", + "name": "시옷", + "tense": false + }, + { + "jamo": "ㅆ", + "roman": "ss", + "name": "쌍시옷", + "tense": true + }, + { + "jamo": "ㅇ", + "roman": "— / ng", + "name": "이응", + "tense": false + }, + { + "jamo": "ㅈ", + "roman": "j", + "name": "지읒", + "tense": false + }, + { + "jamo": "ㅉ", + "roman": "jj", + "name": "쌍지읒", + "tense": true + }, + { + "jamo": "ㅊ", + "roman": "ch", + "name": "치읓", + "tense": false + }, + { + "jamo": "ㅋ", + "roman": "k", + "name": "키읔", + "tense": false + }, + { + "jamo": "ㅌ", + "roman": "t", + "name": "티읕", + "tense": false + }, + { + "jamo": "ㅍ", + "roman": "p", + "name": "피읖", + "tense": false + }, + { + "jamo": "ㅎ", + "roman": "h", + "name": "히읗", + "tense": false + } + ], + "vowels": [ + { + "jamo": "ㅏ", + "roman": "a", + "kind": "basic" + }, + { + "jamo": "ㅑ", + "roman": "ya", + "kind": "basic" + }, + { + "jamo": "ㅓ", + "roman": "eo", + "kind": "basic" + }, + { + "jamo": "ㅕ", + "roman": "yeo", + "kind": "basic" + }, + { + "jamo": "ㅗ", + "roman": "o", + "kind": "basic" + }, + { + "jamo": "ㅛ", + "roman": "yo", + "kind": "basic" + }, + { + "jamo": "ㅜ", + "roman": "u", + "kind": "basic" + }, + { + "jamo": "ㅠ", + "roman": "yu", + "kind": "basic" + }, + { + "jamo": "ㅡ", + "roman": "eu", + "kind": "basic" + }, + { + "jamo": "ㅣ", + "roman": "i", + "kind": "basic" + }, + { + "jamo": "ㅐ", + "roman": "ae", + "kind": "compound" + }, + { + "jamo": "ㅒ", + "roman": "yae", + "kind": "compound" + }, + { + "jamo": "ㅔ", + "roman": "e", + "kind": "compound" + }, + { + "jamo": "ㅖ", + "roman": "ye", + "kind": "compound" + }, + { + "jamo": "ㅘ", + "roman": "wa", + "kind": "compound" + }, + { + "jamo": "ㅙ", + "roman": "wae", + "kind": "compound" + }, + { + "jamo": "ㅚ", + "roman": "oe", + "kind": "compound" + }, + { + "jamo": "ㅝ", + "roman": "wo", + "kind": "compound" + }, + { + "jamo": "ㅞ", + "roman": "we", + "kind": "compound" + }, + { + "jamo": "ㅟ", + "roman": "wi", + "kind": "compound" + }, + { + "jamo": "ㅢ", + "roman": "ui", + "kind": "compound" + } + ], + "batchim": [ + { + "sound": "ㄱ", + "roman": "k", + "writtenAs": "ㄱ ㅋ ㄲ ㄳ ㄺ" + }, + { + "sound": "ㄴ", + "roman": "n", + "writtenAs": "ㄴ ㄵ ㄶ" + }, + { + "sound": "ㄷ", + "roman": "t", + "writtenAs": "ㄷ ㅅ ㅆ ㅈ ㅊ ㅌ ㅎ" + }, + { + "sound": "ㄹ", + "roman": "l", + "writtenAs": "ㄹ ㄼ ㄽ ㄾ ㅀ" + }, + { + "sound": "ㅁ", + "roman": "m", + "writtenAs": "ㅁ ㄻ" + }, + { + "sound": "ㅂ", + "roman": "p", + "writtenAs": "ㅂ ㅍ ㅄ ㄿ" + }, + { + "sound": "ㅇ", + "roman": "ng", + "writtenAs": "ㅇ" + } + ], + "soundRules": [ + { + "n": "Liaison", + "k": "연음", + "p": "A batchim followed by a silent ㅇ slides across into the next block. This is why written Korean and spoken Korean look so different.", + "a": "한국어", + "b": "한구거", + "r": "han-gu-geo" + }, + { + "n": "Nasalisation", + "k": "비음화", + "p": "Before ㄴ or ㅁ, a stop batchim turns nasal: ㄱ→ㅇ, ㄷ→ㄴ, ㅂ→ㅁ.", + "a": "감사합니다", + "b": "감사함니다", + "r": "gam-sa-ham-ni-da" + }, + { + "n": "Lateralisation", + "k": "유음화", + "p": "ㄴ next to ㄹ, in either order, becomes ㄹㄹ.", + "a": "신라", + "b": "실라", + "r": "sil-la" + }, + { + "n": "Aspiration", + "k": "격음화", + "p": "ㅎ meeting ㄱ ㄷ ㅈ ㅂ fuses into the aspirated ㅋ ㅌ ㅊ ㅍ.", + "a": "좋다", + "b": "조타", + "r": "jo-ta" + }, + { + "n": "Tensification", + "k": "경음화", + "p": "After a ㄱ ㄷ ㅂ batchim, a following ㄱ ㄷ ㅂ ㅅ ㅈ tenses to ㄲ ㄸ ㅃ ㅆ ㅉ.", + "a": "학교", + "b": "학꾜", + "r": "hak-kkyo" + }, + { + "n": "Palatalisation", + "k": "구개음화", + "p": "A ㄷ or ㅌ batchim before 이 becomes 지 or 치.", + "a": "같이", + "b": "가치", + "r": "ga-chi" + }, + { + "n": "ㅎ dropping", + "k": "ㅎ 탈락", + "p": "ㅎ between vowels, or after ㄴ ㄹ ㅁ ㅇ, is simply not pronounced.", + "a": "좋아요", + "b": "조아요", + "r": "jo-a-yo" + } + ], + "soundPairs": [ + { + "written": "한국어", + "spoken": "한구거", + "rule": "연음" + }, + { + "written": "음악", + "spoken": "으막", + "rule": "연음" + }, + { + "written": "읽어", + "spoken": "일거", + "rule": "연음" + }, + { + "written": "앉아", + "spoken": "안자", + "rule": "연음" + }, + { + "written": "꽃이", + "spoken": "꼬치", + "rule": "연음" + }, + { + "written": "옷을", + "spoken": "오슬", + "rule": "연음" + }, + { + "written": "앞에", + "spoken": "아페", + "rule": "연음" + }, + { + "written": "낮에", + "spoken": "나제", + "rule": "연음" + }, + { + "written": "있어", + "spoken": "이써", + "rule": "연음" + }, + { + "written": "없어", + "spoken": "업써", + "rule": "연음" + }, + { + "written": "맛있어", + "spoken": "마시써", + "rule": "연음" + }, + { + "written": "끝났어", + "spoken": "끈나써", + "rule": "비음화" + }, + { + "written": "감사합니다", + "spoken": "감사함니다", + "rule": "비음화" + }, + { + "written": "입니다", + "spoken": "임니다", + "rule": "비음화" + }, + { + "written": "십만", + "spoken": "심만", + "rule": "비음화" + }, + { + "written": "국물", + "spoken": "궁물", + "rule": "비음화" + }, + { + "written": "작년", + "spoken": "장년", + "rule": "비음화" + }, + { + "written": "백만", + "spoken": "뱅만", + "rule": "비음화" + }, + { + "written": "몇 명", + "spoken": "면 명", + "rule": "비음화" + }, + { + "written": "독립", + "spoken": "동닙", + "rule": "비음화" + }, + { + "written": "설날", + "spoken": "설랄", + "rule": "유음화" + }, + { + "written": "신라", + "spoken": "실라", + "rule": "유음화" + }, + { + "written": "좋다", + "spoken": "조타", + "rule": "격음화" + }, + { + "written": "못해", + "spoken": "모태", + "rule": "격음화" + }, + { + "written": "같아요", + "spoken": "가타요", + "rule": "연음" + }, + { + "written": "학교", + "spoken": "학꾜", + "rule": "경음화" + }, + { + "written": "할 수 있어", + "spoken": "할 쑤 이써", + "rule": "경음화" + }, + { + "written": "밥 먹어", + "spoken": "밤머거", + "rule": "비음화" + }, + { + "written": "같이", + "spoken": "가치", + "rule": "구개음화" + }, + { + "written": "좋아요", + "spoken": "조아요", + "rule": "ㅎ 탈락" + }, + { + "written": "싫어", + "spoken": "시러", + "rule": "ㅎ 탈락" + }, + { + "written": "많이", + "spoken": "마니", + "rule": "ㅎ 탈락" + }, + { + "written": "좋은", + "spoken": "조은", + "rule": "ㅎ 탈락" + }, + { + "written": "넣어", + "spoken": "너어", + "rule": "ㅎ 탈락" + } + ] +} \ No newline at end of file diff --git a/data/irregulars.json b/data/irregulars.json new file mode 100644 index 0000000..e09c528 --- /dev/null +++ b/data/irregulars.json @@ -0,0 +1,149 @@ +{ + "classes": [ + { + "k": "ㅡ", + "n": "ㅡ drops", + "p": "A stem ending in ㅡ loses it. The 아/어 choice then follows the vowel BEFORE it.", + "ex": [ + [ + "크다", + "커" + ], + [ + "바쁘다", + "바빠" + ], + [ + "슬프다", + "슬퍼" + ], + [ + "쓰다", + "써" + ] + ] + }, + { + "k": "ㅂ", + "n": "ㅂ → 우", + "p": "A ㅂ batchim turns into 우, which fuses with the ending: 워. Almost all are adjectives.", + "ex": [ + [ + "덥다", + "더워" + ], + [ + "어렵다", + "어려워" + ], + [ + "무섭다", + "무서워" + ], + [ + "쉽다", + "쉬워" + ] + ] + }, + { + "k": "ㄷ", + "n": "ㄷ → ㄹ", + "p": "A ㄷ batchim becomes ㄹ before a vowel. 받다 and 닫다 are regular — they do not change.", + "ex": [ + [ + "듣다", + "들어" + ], + [ + "걷다", + "걸어" + ], + [ + "묻다", + "물어" + ] + ] + }, + { + "k": "르", + "n": "르 doubles ㄹ", + "p": "The 르 drops its ㅡ and an extra ㄹ lands on the previous syllable.", + "ex": [ + [ + "모르다", + "몰라" + ], + [ + "부르다", + "불러" + ], + [ + "다르다", + "달라" + ], + [ + "빠르다", + "빨라" + ] + ] + }, + { + "k": "ㅅ", + "n": "ㅅ drops", + "p": "A ㅅ batchim disappears before a vowel, and no contraction follows. 웃다 and 씻다 are regular.", + "ex": [ + [ + "낫다", + "나아" + ], + [ + "짓다", + "지어" + ], + [ + "붓다", + "부어" + ] + ] + }, + { + "k": "ㅎ", + "n": "ㅎ drops, vowel shifts", + "p": "Colour adjectives and 그렇다-type words: the ㅎ goes and the vowel becomes ㅐ.", + "ex": [ + [ + "그렇다", + "그래" + ], + [ + "빨갛다", + "빨개" + ], + [ + "어떻다", + "어때" + ] + ] + }, + { + "k": "ㄹ", + "n": "ㄹ drops before ㄴ ㅂ ㅅ", + "p": "The 아/어 form is regular; the ㄹ only vanishes before certain endings.", + "ex": [ + [ + "살다", + "살아 · 사는" + ], + [ + "알다", + "알아 · 아는" + ], + [ + "멀다", + "멀어 · 먼" + ] + ] + } + ] +} \ No newline at end of file diff --git a/data/sentences.json b/data/sentences.json new file mode 100644 index 0000000..18a2131 --- /dev/null +++ b/data/sentences.json @@ -0,0 +1,765 @@ +{ + "note": "Glossed sentences. parts[] are [chunk, gloss]; the LAST chunk is the 서술어 (predicate).", + "levels": { + "A": "subject + ending word", + "B": "manhwa dialogue" + }, + "sentences": [ + { + "lvl": "A", + "ko": "나 배고파.", + "en": "I'm hungry.", + "parts": [ + [ + "나", + "I" + ], + [ + "배고파", + "am hungry" + ] + ] + }, + { + "lvl": "A", + "ko": "너 어디 가?", + "en": "Where are you going?", + "parts": [ + [ + "너", + "you" + ], + [ + "어디", + "where" + ], + [ + "가", + "go" + ] + ] + }, + { + "lvl": "A", + "ko": "이거 뭐야?", + "en": "What is this?", + "parts": [ + [ + "이거", + "this thing" + ], + [ + "뭐야", + "is what" + ] + ] + }, + { + "lvl": "A", + "ko": "그 사람 누구야?", + "en": "Who is that person?", + "parts": [ + [ + "그", + "that" + ], + [ + "사람", + "person" + ], + [ + "누구야", + "is who" + ] + ] + }, + { + "lvl": "A", + "ko": "우리 친구야.", + "en": "We're friends.", + "parts": [ + [ + "우리", + "we" + ], + [ + "친구야", + "are friends" + ] + ] + }, + { + "lvl": "A", + "ko": "나 진짜 슬퍼.", + "en": "I'm really sad.", + "parts": [ + [ + "나", + "I" + ], + [ + "진짜", + "really" + ], + [ + "슬퍼", + "am sad" + ] + ] + }, + { + "lvl": "A", + "ko": "이 책 좋아.", + "en": "This book is good. / I like this book.", + "parts": [ + [ + "이", + "this" + ], + [ + "책", + "book" + ], + [ + "좋아", + "is good" + ] + ] + }, + { + "lvl": "A", + "ko": "저 사람 커.", + "en": "That person is big.", + "parts": [ + [ + "저", + "that over there" + ], + [ + "사람", + "person" + ], + [ + "커", + "is big" + ] + ] + }, + { + "lvl": "A", + "ko": "여기 위험해.", + "en": "It's dangerous here.", + "parts": [ + [ + "여기", + "here" + ], + [ + "위험해", + "is dangerous" + ] + ] + }, + { + "lvl": "A", + "ko": "너 왜 그래?", + "en": "What's wrong with you?", + "parts": [ + [ + "너", + "you" + ], + [ + "왜", + "why" + ], + [ + "그래", + "are like that" + ] + ] + }, + { + "lvl": "A", + "ko": "밥 먹어.", + "en": "Eat. / I'm eating.", + "parts": [ + [ + "밥", + "rice, a meal" + ], + [ + "먹어", + "eat" + ] + ] + }, + { + "lvl": "A", + "ko": "물 마셔.", + "en": "Drink some water.", + "parts": [ + [ + "물", + "water" + ], + [ + "마셔", + "drink" + ] + ] + }, + { + "lvl": "A", + "ko": "나 갈게.", + "en": "I'm off.", + "parts": [ + [ + "나", + "I" + ], + [ + "갈게", + "will go" + ] + ] + }, + { + "lvl": "A", + "ko": "그거 안 돼.", + "en": "That won't work.", + "parts": [ + [ + "그거", + "that thing" + ], + [ + "안", + "not" + ], + [ + "돼", + "becomes, works" + ] + ] + }, + { + "lvl": "A", + "ko": "시험 언제야?", + "en": "When's the exam?", + "parts": [ + [ + "시험", + "exam" + ], + [ + "언제야", + "is when" + ] + ] + }, + { + "lvl": "A", + "ko": "소문 들었어?", + "en": "Did you hear the rumour?", + "parts": [ + [ + "소문", + "rumour" + ], + [ + "들었어", + "heard" + ] + ] + }, + { + "lvl": "A", + "ko": "쟤 우리 반이야.", + "en": "That kid is in our class.", + "parts": [ + [ + "쟤", + "that kid" + ], + [ + "우리", + "our" + ], + [ + "반이야", + "is class" + ] + ] + }, + { + "lvl": "A", + "ko": "증거 없어.", + "en": "There's no evidence.", + "parts": [ + [ + "증거", + "evidence" + ], + [ + "없어", + "does not exist" + ] + ] + }, + { + "lvl": "A", + "ko": "이제 끝났어.", + "en": "It's over now.", + "parts": [ + [ + "이제", + "now" + ], + [ + "끝났어", + "has ended" + ] + ] + }, + { + "lvl": "A", + "ko": "검 들어.", + "en": "Pick up the sword.", + "parts": [ + [ + "검", + "sword" + ], + [ + "들어", + "lift, pick up" + ] + ] + }, + { + "lvl": "B", + "ko": "나 회귀했어.", + "en": "I've regressed.", + "parts": [ + [ + "나", + "I" + ], + [ + "회귀했어", + "did regression" + ] + ] + }, + { + "lvl": "B", + "ko": "이번엔 죽지 않아.", + "en": "This time I won't die.", + "parts": [ + [ + "이번엔", + "this time" + ], + [ + "죽지 않아", + "do not die" + ] + ] + }, + { + "lvl": "B", + "ko": "던전이 열렸어.", + "en": "The dungeon has opened.", + "parts": [ + [ + "던전이", + "the dungeon" + ], + [ + "열렸어", + "has opened" + ] + ] + }, + { + "lvl": "B", + "ko": "저 몬스터 보스야.", + "en": "That monster is the boss.", + "parts": [ + [ + "저", + "that over there" + ], + [ + "몬스터", + "monster" + ], + [ + "보스야", + "is the boss" + ] + ] + }, + { + "lvl": "B", + "ko": "스킬 각성했어?", + "en": "Did you awaken a skill?", + "parts": [ + [ + "스킬", + "skill" + ], + [ + "각성했어", + "did awaken" + ] + ] + }, + { + "lvl": "B", + "ko": "길드에서 연락 왔어.", + "en": "The guild got in touch.", + "parts": [ + [ + "길드에서", + "from the guild" + ], + [ + "연락", + "contact" + ], + [ + "왔어", + "came" + ] + ] + }, + { + "lvl": "B", + "ko": "그 녀석 진짜 강해.", + "en": "That guy is seriously strong.", + "parts": [ + [ + "그", + "that" + ], + [ + "녀석", + "guy" + ], + [ + "진짜", + "really" + ], + [ + "강해", + "is strong" + ] + ] + }, + { + "lvl": "B", + "ko": "도망쳐! 빨리!", + "en": "Run! Quickly!", + "parts": [ + [ + "도망쳐", + "run away" + ], + [ + "빨리", + "quickly" + ] + ] + }, + { + "lvl": "B", + "ko": "내가 지킬게.", + "en": "I'll protect you.", + "parts": [ + [ + "내가", + "I (as the one doing it)" + ], + [ + "지킬게", + "will protect" + ] + ] + }, + { + "lvl": "B", + "ko": "널 믿어.", + "en": "I trust you.", + "parts": [ + [ + "널", + "you (object)" + ], + [ + "믿어", + "trust" + ] + ] + }, + { + "lvl": "B", + "ko": "잠깐만, 너 지금 뭐 했어?", + "en": "Hold on — what did you just do?", + "parts": [ + [ + "잠깐만", + "hold on" + ], + [ + "너", + "you" + ], + [ + "지금", + "now" + ], + [ + "뭐", + "what" + ], + [ + "했어", + "did" + ] + ] + }, + { + "lvl": "B", + "ko": "말도 안 돼. 네가 어떻게 여기 있어?", + "en": "No way. How are you here?", + "parts": [ + [ + "말도 안 돼", + "that's absurd" + ], + [ + "네가", + "you" + ], + [ + "어떻게", + "how" + ], + [ + "여기", + "here" + ], + [ + "있어", + "are, exist" + ] + ] + }, + { + "lvl": "B", + "ko": "나 너 좋아해.", + "en": "I like you.", + "parts": [ + [ + "나", + "I" + ], + [ + "너", + "you" + ], + [ + "좋아해", + "like" + ] + ] + }, + { + "lvl": "B", + "ko": "우리 헤어지자.", + "en": "Let's break up.", + "parts": [ + [ + "우리", + "we" + ], + [ + "헤어지자", + "let's part" + ] + ] + }, + { + "lvl": "B", + "ko": "오해야. 진짜야.", + "en": "It's a misunderstanding. Really.", + "parts": [ + [ + "오해야", + "is a misunderstanding" + ], + [ + "진짜야", + "is true" + ] + ] + }, + { + "lvl": "B", + "ko": "왜 말 안 했어?", + "en": "Why didn't you say anything?", + "parts": [ + [ + "왜", + "why" + ], + [ + "말", + "words" + ], + [ + "안", + "not" + ], + [ + "했어", + "did" + ] + ] + }, + { + "lvl": "B", + "ko": "심장이 두근두근해.", + "en": "My heart is pounding.", + "parts": [ + [ + "심장이", + "the heart" + ], + [ + "두근두근해", + "goes thump-thump" + ] + ] + }, + { + "lvl": "B", + "ko": "그 사람이랑 계약결혼 했어.", + "en": "I did a contract marriage with him.", + "parts": [ + [ + "그 사람이랑", + "with that person" + ], + [ + "계약결혼", + "contract marriage" + ], + [ + "했어", + "did" + ] + ] + }, + { + "lvl": "B", + "ko": "선배가 불렀어.", + "en": "The senior was calling for you.", + "parts": [ + [ + "선배가", + "the senior" + ], + [ + "불렀어", + "called" + ] + ] + }, + { + "lvl": "B", + "ko": "범인은 저 사람이야.", + "en": "The culprit is that person.", + "parts": [ + [ + "범인은", + "as for the culprit" + ], + [ + "저 사람이야", + "is that person" + ] + ] + }, + { + "lvl": "B", + "ko": "복수할 거야.", + "en": "I'm going to take revenge.", + "parts": [ + [ + "복수할", + "to take revenge" + ], + [ + "거야", + "is the plan" + ] + ] + }, + { + "lvl": "B", + "ko": "배신자는 죽어.", + "en": "Traitors die.", + "parts": [ + [ + "배신자는", + "as for traitors" + ], + [ + "죽어", + "die" + ] + ] + }, + { + "lvl": "B", + "ko": "형사님, 여기 좀 보세요.", + "en": "Detective, take a look over here.", + "parts": [ + [ + "형사님", + "detective (respectful)" + ], + [ + "여기", + "here" + ], + [ + "좀", + "just, a bit" + ], + [ + "보세요", + "please look" + ] + ] + }, + { + "lvl": "B", + "ko": "괜찮아? 다쳤어?", + "en": "Are you okay? Are you hurt?", + "parts": [ + [ + "괜찮아", + "are you fine" + ], + [ + "다쳤어", + "got hurt" + ] + ] + } + ] +} \ No newline at end of file diff --git a/data/sfx.json b/data/sfx.json new file mode 100644 index 0000000..3339ad5 --- /dev/null +++ b/data/sfx.json @@ -0,0 +1,85 @@ +{ + "note": "의성어 · 의태어 — lettered into the artwork, absent from most textbooks.", + "items": [ + { + "ko": "쿵", + "en": "a heavy thud" + }, + { + "ko": "쾅", + "en": "a bang, a crash" + }, + { + "ko": "헉", + "en": "a gasp" + }, + { + "ko": "헐", + "en": "disbelief" + }, + { + "ko": "두근두근", + "en": "a pounding heart" + }, + { + "ko": "반짝", + "en": "a sparkle" + }, + { + "ko": "스윽", + "en": "a smooth, quiet movement" + }, + { + "ko": "털썩", + "en": "slumping down" + }, + { + "ko": "씨익", + "en": "a slow grin spreading" + }, + { + "ko": "꿀꺽", + "en": "a gulp" + }, + { + "ko": "덜덜", + "en": "trembling" + }, + { + "ko": "빙글", + "en": "turning around" + }, + { + "ko": "화악", + "en": "a sudden rush or flush" + }, + { + "ko": "철썩", + "en": "a slap" + }, + { + "ko": "바스락", + "en": "a rustle" + }, + { + "ko": "싸늘", + "en": "a chill in the air" + }, + { + "ko": "울컥", + "en": "a surge of emotion" + }, + { + "ko": "찌릿", + "en": "a jolt, a tingle" + }, + { + "ko": "멍", + "en": "blanked out, dazed" + }, + { + "ko": "끄덕", + "en": "a nod" + } + ] +} \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1b9db8b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,75 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; +import reactHooks from "eslint-plugin-react-hooks"; + +/* The clock guard. + + The artifact's sync bug was a fresh device stamping its own empty defaults + as newer than the server's real data. db/writes.ts owns the distinction + between a seed write (updated_at stays 0) and a user edit (stamped), so it + is the only file under src/db/ allowed to read the clock. Anywhere else, + reaching for Date.now() means someone is about to stamp a row without + deciding which kind of write it is. */ +const CLOCK_BAN = [ + { + selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']", + message: + "Only db/writes.ts may read the clock. Use a seedX() helper (leaves updated_at 0) " + + "or an editX() helper (stamps it) — see the timestamp rule in db/writes.ts.", + }, + { + selector: "NewExpression[callee.name='Date']", + message: + "Only db/writes.ts may read the clock. Use a seedX() or editX() helper from db/writes.ts.", + }, +]; + +export default tseslint.config( + // lib/, data/ and validate.mjs are copied in from the export bundle + // unchanged and must stay byte-identical — they are not ours to restyle. + { + ignores: [ + "**/dist/**", + "**/node_modules/**", + "export/**", + "vendor/**", + "app/android/**", + "lib/**", + "data/**", + "validate.mjs", + ], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + // Build scripts and the shared modules are plain Node ESM. + { + files: ["tools/**/*.mjs", "shared/**/*.mjs", "*.config.js"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + URL: "readonly", + fetch: "readonly", + Buffer: "readonly", + }, + }, + }, + /* The rules of hooks, and exhaustive deps in particular. A stale closure + in an effect is what let the tutor seed its opening turn three times. */ + { + files: ["app/src/**/*.{ts,tsx}"], + plugins: { "react-hooks": reactHooks }, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + }, + }, + { + files: ["app/src/db/**/*.ts"], + rules: { "no-restricted-syntax": ["error", ...CLOCK_BAN] }, + }, + { + files: ["app/src/db/writes.ts"], + rules: { "no-restricted-syntax": "off" }, + }, +); diff --git a/lib/blocks.js b/lib/blocks.js new file mode 100644 index 0000000..56c6a75 --- /dev/null +++ b/lib/blocks.js @@ -0,0 +1,86 @@ +/* The block protocol — how 선생님 drives the UI. + The tutor writes prose plus fenced blocks; the client renders them + as real interface and sends structured answers back. */ + +const RE = { + task: /::task\s+(translate|match|build|choice)\s*\n([\s\S]*?)(?:\n::|$)/, + words: /::words\s*\n([\s\S]*?)(?:\n::|$)/, + gloss: /::gloss\s*\n([\s\S]*?)(?:\n::|$)/, + progress: /::progress\s+(\d{1,3})\s*(?:\|\s*([^\n]*))?/, +}; +const rows = s => s.split("\n").map(l => l.trim()).filter(l => l && !/^::/.test(l)); +const cols = l => l.split("|").map(x => x.trim()); + +export function parse(text) { + const w = text.match(RE.words), t = text.match(RE.task); + const g = text.match(RE.gloss), p = text.match(RE.progress); + + const words = w ? rows(w[1]).map(l => { const c = cols(l); + return { ko: c[0], gloss: c[1] || "", note: c[2] || "" }; }) : null; + + let task = null; + if (t) { + const r = rows(t[2]); + if (t[1] === "translate") task = { type: "translate", items: r.map(q => ({ q })) }; + if (t[1] === "match") task = { type: "match", pairs: r.map(l => { const c = cols(l); + return { ko: c[0], gloss: c[1] }; }).filter(x => x.ko && x.gloss) }; + if (t[1] === "build") task = { type: "build", items: r.map(l => { const c = cols(l); + return { en: c[0], chips: c.slice(1).filter(Boolean) }; }).filter(x => x.en && x.chips.length) }; + if (t[1] === "choice") task = { type: "choice", items: r.map(l => { const c = cols(l); + return { q: c[0], options: c.slice(1).filter(Boolean) }; }).filter(x => x.q && x.options.length > 1) }; + } + + let gloss = null; + if (g) { + const blocks = []; let cur = null; + g[1].split("\n").forEach(line => { + const l = line.trim(); + if (!l || /^::/.test(l)) return; + if (l.startsWith("=")) { if (cur) cur.en = l.slice(1).trim(); return; } + const c = cols(l); + if (!cur) { cur = { parts: [], en: "" }; blocks.push(cur); } + cur.parts.push({ ko: c[0], role: (c[1] || "N").toUpperCase()[0], gloss: c[2] || "", highlight: c[3] || "" }); + }); + const keep = blocks.filter(b => b.parts.length); + if (keep.length) gloss = keep; + } + + let body = text; + if (p) body = body.replace(p[0], ""); + if (g) body = body.replace(g[0], ""); + if (w) body = body.slice(0, body.indexOf("::words") >= 0 ? body.indexOf("::words") : body.length); + if (t) body = body.replace(t[0], ""); + + return { + body: body.replace(/\n{3,}/g, "\n\n").trim(), + words, task, gloss, + progress: p ? { score: Math.max(0, Math.min(100, +p[1])), note: (p[2] || "").trim() } : null, + }; +} + +/** Roles a gloss part can carry, and what the UI should do with each. */ +export const ROLES = { + S: "주어 subject", T: "주제 topic", O: "목적어 object", + V: "서술어 predicate", P: "자리·때 place/time", C: "이음 connective", + Q: "인용 quotation", M: "수식 modifier", N: "", +}; + +/** Turn a completed task back into the message the student sends. */ +export function answerText(task, state, lookups = []) { + let body; + if (task.type === "translate") + body = "My answers:\n" + task.items.map((it, i) => + `${it.q} → ${(state[i] || "").trim() || "(not sure)"}`).join("\n"); + else if (task.type === "match") + body = "My pairings:\n" + (state.pairs.map(p => `${p.ko} = ${p.gloss}`).join("\n") || "(none)"); + else if (task.type === "build") + body = "My sentences:\n" + task.items.map((it, i) => + `${it.en} → ${(state[i] || []).join(" ") || "(not sure)"}`).join("\n"); + else + body = "My choices:\n" + task.items.map((it, i) => + `${it.q} → ${state[i] == null ? "(not sure)" : it.options[state[i]]}`).join("\n"); + + return body + (lookups.length + ? `\n\n(I had to look up: ${lookups.join(", ")})` + : "\n\n(No lookups.)"); +} diff --git a/lib/conjugation.js b/lib/conjugation.js new file mode 100644 index 0000000..7bcbb21 --- /dev/null +++ b/lib/conjugation.js @@ -0,0 +1,99 @@ +/* Korean conjugation — the 아/어 rule and the seven irregular classes. + Used three ways: to mark the conjugation trainer, to generate the + surface-form index at build time, and to explain WHICH rule was missed. */ +import { decompose, compose } from "./hangul.js"; + +/** Forms that do not fall out of the rules and are simply known. */ +export const IRREGULAR_FORMS = { + "덥다":"더워","춥다":"추워","쉽다":"쉬워","어렵다":"어려워","무섭다":"무서워", + "맵다":"매워","가깝다":"가까워", + "듣다":"들어","걷다":"걸어","묻다":"물어", + "모르다":"몰라","부르다":"불러","다르다":"달라","빠르다":"빨라","고르다":"골라", + "낫다":"나아","짓다":"지어","붓다":"부어", + "하다":"해","되다":"돼","이다":"야","그렇다":"그래","어떻다":"어때", +}; + +/** Dictionary form → 반말 present (해체). Returns null for non-verbs. */ +export function haeche(dict) { + if (IRREGULAR_FORMS[dict]) return IRREGULAR_FORMS[dict]; + if (!dict || dict.slice(-1) !== "다") return null; + const stem = dict.slice(0, -1); + if (!stem) return null; + if (stem.slice(-1) === "하") return stem.slice(0, -1) + "해"; + + const d = decompose(stem[stem.length - 1]); + if (!d) return null; + const [i, m, f] = d; + const bright = (m === 0 || m === 8); // ㅏ or ㅗ → 아, else 어 + + if (m === 18 && f === 0) { // ㅡ drops: 크다 → 커, 바쁘다 → 바빠 + let h = 4; + if (stem.length >= 2) { + const prev = decompose(stem[stem.length - 2]); + if (prev && (prev[1] === 0 || prev[1] === 8)) h = 0; + } + return stem.slice(0, -1) + compose(i, h, 0); + } + if (f === 0) { // vowel-final stem contracts + if ([0, 4, 1, 5, 6, 2].includes(m)) return stem; // 가 · 서 · 보내 · 세 · 켜 + if (m === 8) return stem.slice(0, -1) + compose(i, 9, 0); // ㅗ+아 → ㅘ 오다 → 와 + if (m === 13) return stem.slice(0, -1) + compose(i, 14, 0); // ㅜ+어 → ㅝ 주다 → 줘 + if (m === 20) return stem.slice(0, -1) + compose(i, 6, 0); // ㅣ+어 → ㅕ 마시다 → 마셔 + if (m === 11) return stem.slice(0, -1) + compose(i, 10, 0); // ㅚ+어 → ㅙ 되다 → 돼 + return stem + (bright ? "아" : "어"); + } + return stem + (bright ? "아" : "어"); +} + +/** 반말 present → 반말 past. 먹어 → 먹었어, 가 → 갔어, 해 → 했어. */ +export function past(present) { + if (!present) return null; + const d = decompose(present[present.length - 1]); + if (!d) return null; + if (d[2] !== 0) return present + "었어"; + return present.slice(0, -1) + compose(d[0], d[1], 20) + "어"; +} + +export const polite = present => present ? present + "요" : null; + +/** Which class a dictionary form belongs to — drives the "why" in feedback. */ +export function irregularClass(dict) { + if (IRREGULAR_FORMS[dict]) { + if (/르다$/.test(dict)) return "르"; + if (/^(듣다|걷다|묻다)$/.test(dict)) return "ㄷ"; + if (/(렇다|얗다|갛다|떻다)$/.test(dict)) return "ㅎ"; + if (/^(낫다|짓다|붓다)$/.test(dict)) return "ㅅ"; + if (/^(하다|되다|이다)$/.test(dict)) return "special"; + return "ㅂ"; + } + const stem = dict.slice(0, -1); + const d = decompose(stem[stem.length - 1]); + if (!d) return "regular"; + if (d[1] === 18 && d[2] === 0) return "ㅡ"; + return "regular"; +} + +/** Human explanation of the rule applied — shown when an answer is wrong. */ +export function explain(dict) { + const cls = irregularClass(dict); + if (cls !== "regular") return `${cls} 불규칙`; + const stem = dict.slice(0, -1); + if (stem.slice(-1) === "하") return "하다 → 해"; + const d = decompose(stem[stem.length - 1]); + const bright = d && (d[1] === 0 || d[1] === 8); + return `stem ${stem} · last vowel ${bright ? "ㅏ/ㅗ → 아" : "neither → 어"}`; +} + +/** Build-time: every surface form a learner will meet, mapped back to its lemma. + Feed this the dictionary; it replaces a runtime morphological analyser. */ +export function surfaceForms(dict, gloss) { + const out = []; + const p = haeche(dict); + if (!p) return out; + const g = gloss.replace(/^to be /, "").replace(/^to /, ""); + out.push({ form: p, gloss: g, note: `반말, from ${dict}` }); + out.push({ form: polite(p), gloss: g, note: `polite, from ${dict}` }); + const q = past(p); + if (q) out.push({ form: q, gloss: `${g} (past)`, note: `반말 past, from ${dict}` }); + return out; +} diff --git a/lib/gate.js b/lib/gate.js new file mode 100644 index 0000000..f1e0481 --- /dev/null +++ b/lib/gate.js @@ -0,0 +1,72 @@ +/* The gate — what the tutor is allowed to know, say and use, right now. + Generated from the curriculum plus progress. This is the mechanism that + stops material being taught out of order; port it before improving it. */ + +export function flatten(curriculum) { + const units = []; + curriculum.phases.forEach(p => p.units.forEach(u => units.push({ ...u, phase: p.phase, phaseKo: p.ko, phaseName: p.name }))); + return units; +} + +/** + * @param curriculum curriculum.json + * @param progress { current: "1.4", done: {"1.1":true,...}, confidence: {"1.4":62} } + * @param opts { vocabQuery } — with a dictionary, replaces the hand-listed words + */ +export function buildGate(curriculum, progress, opts = {}) { + const units = flatten(curriculum); + const at = id => units.findIndex(u => u.id === id); + const i = Math.max(0, at(progress.current)); + const unit = units[i]; + const done = units.filter(u => progress.done[u.id]); + + const taught = done.flatMap(u => u.teaches); + + // everything a later unit teaches is, by construction, forbidden now + const future = units.filter((u, k) => k > i || (!progress.done[u.id] && k !== i)); + const near = [...new Set(future.flatMap(u => u.teaches))].slice(0, 18); + const tail = future.length ? future[future.length - 1] : null; + + const vocabulary = opts.vocabQuery + ? opts.vocabQuery(unit, done) // e.g. freq_rank BETWEEN … + : [...new Set(done.flatMap(u => u.words))]; + + // spiral targets: words first met in an earlier unit that this unit should + // deliberately bring back. Only those whose home unit is actually finished. + const doneIds = new Set(done.map(u => u.id)); + const revisits = (unit.revisits || []).filter(r => doneIds.has(r.from)).map(r => r.word); + + return { + unit, phase: { n: unit.phase, ko: unit.phaseKo, name: unit.phaseName }, + taught, forbidden: { near, tailUnit: tail, count: future.length }, + vocabulary, newWords: unit.words, revisits, + confidence: progress.confidence?.[unit.id] ?? null, + next: units[i + 1] || null, + finished: done.map(u => u.id), + }; +} + +/** Render the gate into the system prompt section. Keep the headings — the + model keys off them, and the pre-flight check refers to them by name. */ +export function renderGate(g) { + const L = []; + L.push(`He is on PHASE ${g.phase.n} · ${g.phase.ko} (${g.phase.name}), UNIT ${g.unit.id} · ${g.unit.ko} (${g.unit.name})${g.unit.vocabUnit ? " — a VOCABULARY unit" : ""}.`); + L.push(`THIS UNIT'S GOAL: ${g.unit.goal}`, ""); + L.push("════ WHAT HE KNOWS — the complete list ════"); + L.push(g.taught.length ? g.taught.map(t => "• " + t).join("\n") : "• nothing yet — this is the very first unit", ""); + L.push("════ THIS UNIT ADDS ════", g.unit.teaches.map(t => "• " + t).join("\n")); + if (g.unit.avoid?.length) + L.push("\nAND EXPLICITLY EXCLUDES, even if it seems natural:\n" + g.unit.avoid.map(t => "✗ " + t).join("\n")); + L.push("", "════ NOT TAUGHT YET — MUST NOT APPEAR ════"); + L.push("Every one of these belongs to a later unit. Using any of them, even in passing, even to be helpful, breaks the sequence:"); + L.push(g.forbidden.near.length ? g.forbidden.near.map(t => "✗ " + t).join("\n") : "— nothing; this is the last unit"); + if (g.forbidden.tailUnit) + L.push(`…and everything else on the roadmap through ${g.forbidden.tailUnit.id} ${g.forbidden.tailUnit.ko}. If a thing is not on the KNOWS list above, it is not taught. That is the whole test — you do not need to recognise it on this list to exclude it.`); + L.push("", "════ VOCABULARY YOU MAY USE ════", g.vocabulary.join(" · ") || "(none yet)"); + L.push("NEW WORDS THIS UNIT MAY INTRODUCE — and no others:", + g.newWords.length ? g.newWords.join(" · ") + : "(none — this unit adds no new vocabulary on purpose. It is a contrast/synthesis unit: work it entirely with words he already has.)"); + if (g.revisits.length) + L.push("BRING BACK ON PURPOSE — he met these earlier and they are due for reuse here:", g.revisits.join(" · ")); + return L.join("\n"); +} diff --git a/lib/hangul.js b/lib/hangul.js new file mode 100644 index 0000000..5f04eb3 --- /dev/null +++ b/lib/hangul.js @@ -0,0 +1,125 @@ +/* 한글 — decomposition, composition, and a 두벌식 input method. + No dependencies. Lifted from the artifact; the IME is unit-tested + against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */ + +export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; +export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"; +export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; + +const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"}; +const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ", + "ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"}; +const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]])); + +/** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */ +export function decompose(ch) { + const c = ch.charCodeAt(0) - 0xAC00; + if (c < 0 || c > 11171) return null; + return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28]; +} +export function compose(i, m, f = 0) { + return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f); +} +export const isJamo = { + initial: c => CHO.includes(c), + medial: c => JUNG.includes(c), + final: c => c && JONG.indexOf(c) > 0, +}; + +/* ── 두벌식 IME ─────────────────────────────────────────── + Hold one composing buffer per input. Feed jamo with key(), + punctuation and spaces with text(), and Backspace with back(). + Each call returns the full new value. */ +export class Composer { + constructor() { this.reset(); } + reset() { this.cho = null; this.jung = null; this.jong = null; } + get empty() { return !this.cho && !this.jung && !this.jong; } + + /** The syllable currently being assembled, as text. */ + render() { + if (this.cho && this.jung) { + const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung); + const f = this.jong ? JONG.indexOf(this.jong) : 0; + if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f); + } + return (this.cho || "") + (this.jung || "") + (this.jong || ""); + } + /** Strip the composing tail off a value so it can be rebuilt. */ + _base(value) { + const cur = this.render(); + return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value; + } + + key(value, j) { + let base = this._base(value); + if (isJamo.medial(j)) { + if (this.jong) { // final splits off to start a new block + const parts = FSPLIT[this.jong]; + let moved; + if (parts) { this.jong = parts[0]; moved = parts[1]; } + else { moved = this.jong; this.jong = null; } + base += this.render(); + this.cho = moved; this.jung = j; this.jong = null; + } else if (this.jung) { + const join = VJOIN[this.jung + j]; + if (join) this.jung = join; + else { base += this.render(); this.reset(); this.jung = j; } + } else this.jung = j; + } else { + if (this.cho && this.jung) { + if (this.jong) { + const join = FJOIN[this.jong + j]; + if (join) this.jong = join; + else { base += this.render(); this.reset(); this.cho = j; } + } else if (isJamo.final(j)) this.jong = j; + else { base += this.render(); this.reset(); this.cho = j; } + } else { + if (!this.empty) base += this.render(); + this.reset(); this.cho = j; + } + } + return base + this.render(); + } + + back(value) { + let base = this._base(value); + if (this.jong) { + const parts = FSPLIT[this.jong]; + this.jong = parts ? parts[0] : null; + } else if (this.jung) { + let peeled = null; + for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0]; + this.jung = peeled; + } else if (this.cho) { + this.cho = null; + } else { // pull a finished block back in + const last = base.slice(-1); + base = base.slice(0, -1); + const d = last ? decompose(last) : null; + if (d) { + this.cho = CHO[d[0]]; this.jung = JUNG[d[1]]; + this.jong = d[2] ? JONG[d[2]] : null; + if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; } + else this.jung = null; + } + } + return base + this.render(); + } + + /** Commit the buffer and append literal text (space, punctuation). */ + text(value, t) { + const base = this._base(value) + this.render(); + this.reset(); + return base + t; + } +} + +/** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */ +export const KEYBOARD = { + rows: [ + ["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"], + ["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"], + ["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"], + ], + shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"}, +}; diff --git a/lib/srs.js b/lib/srs.js new file mode 100644 index 0000000..c8a11e7 --- /dev/null +++ b/lib/srs.js @@ -0,0 +1,46 @@ +/* SM-2 lite. Four grades, day-granularity intervals. */ +export const AGAIN = 0, HARD = 1, GOOD = 2, EASY = 3; +export const NEW = 0, LEARNING = 1, REVIEW = 2; +export const SECURE_INTERVAL = 21; // days at which a card counts as known + +export const newCard = () => ({ state: NEW, interval: 0, ease: 2.5, due: 0, reps: 0, lapses: 0 }); + +export function grade(card, g, today) { + const c = { ...card }; + if (c.state === NEW || c.state === LEARNING) { + if (g <= HARD) { c.state = LEARNING; c.interval = 0; c.due = today; } + else if (g === GOOD){ c.state = REVIEW; c.interval = 1; c.due = today + 1; } + else { c.state = REVIEW; c.interval = 4; c.due = today + 4; } + } else { + if (g === AGAIN) { c.ease = Math.max(1.3, c.ease - 0.2); c.lapses++; c.state = LEARNING; c.interval = 0; c.due = today; } + else if (g === HARD) { c.ease = Math.max(1.3, c.ease - 0.15); c.interval = Math.max(1, Math.round(c.interval * 1.2)); c.due = today + c.interval; } + else if (g === GOOD) { c.interval = Math.max(1, Math.round(c.interval * c.ease)); c.due = today + c.interval; } + else { c.ease = Math.min(3, c.ease + 0.15); c.interval = Math.max(2, Math.round(c.interval * c.ease * 1.3)); c.due = today + c.interval; } + c.interval = Math.min(c.interval, 365); + c.due = Math.min(c.due, today + 365); + } + c.reps++; + return c; +} + +export const markKnown = today => ({ state: REVIEW, interval: SECURE_INTERVAL, ease: 2.5, due: today + SECURE_INTERVAL, reps: 0, lapses: 0 }); + +export function statusOf(card) { + if (!card || card.state === NEW) return "new"; + if (card.state === LEARNING) return "learning"; + return card.interval >= SECURE_INTERVAL ? "secure" : "review"; +} + +/** Label for the interval a grade would produce — shown on the buttons. */ +export function preview(card, g, today) { + const c = grade(card || newCard(), g, today); + if (c.interval === 0) return "again now"; + if (c.interval === 1) return "1 day"; + if (c.interval < 30) return `${c.interval} days`; + const mo = Math.round(c.interval / 30); + return `${mo} month${mo === 1 ? "" : "s"}`; +} + +/** Local day number, DST-safe. */ +export const dayNumber = (d = new Date()) => + Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 864e5); diff --git a/package.json b/package.json new file mode 100644 index 0000000..da5d27b --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "hankan", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Hankan — a Korean reading tutor for manhwa. Offline-first, web + Android from one codebase.", + "workspaces": [ + "app" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "validate": "node validate.mjs", + "dict:fetch": "node tools/dict/fetch.mjs", + "dict:build": "node tools/dict/build.mjs", + "dict:assert": "node tools/dict/assert-roadmap.mjs", + "typecheck": "tsc -b --pretty", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "dev": "npm run dev -w app", + "build": "npm run build -w app", + "preview": "npm run preview -w app", + "cap:sync": "npm run cap:sync -w app", + "check": "npm run validate && npm run typecheck && npm run test && npm run dict:assert" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/node": "^22.10.2", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^7.1.1", + "typescript": "^5.7.2", + "typescript-eslint": "^8.18.1", + "vite": "^6.4.3", + "vitest": "^3.2.4" + } +} diff --git a/prompt/tutor-system.md b/prompt/tutor-system.md new file mode 100644 index 0000000..1a30564 --- /dev/null +++ b/prompt/tutor-system.md @@ -0,0 +1,100 @@ +# 선생님 — system prompt + +Assembled per turn. `{{GATE}}` is `renderGate()` from `lib/gate.js`; `{{VARIETY}}` +and `{{FOCUS}}` are one-liners built from recent state. Everything else is fixed. + +The conversation is sent as turns; there is no separate system role in the Agent +SDK path, so this whole document goes in as the leading instruction. + +--- + +You are 선생님, a Korean reading tutor built into the student's own study app. You teach ONE student. Everything below is his real profile. + +HIS GOAL: read Korean manhwa. Reading and decoding meaning ONLY. Never drill pronunciation production, handwriting or conversation. + +WHAT HE BRINGS +- Romanization is fully retired. NEVER write romanization or IPA — not once, not as a hint. 한글 and English only. For a spoken form use 한글 in brackets: 학교 [학꾜]. +- Register: manhwa is written in 반말 — teach 먹어, 가, 좋아, 안 돼, not 해요/합니다, until the roadmap says otherwise. + +{{GATE}} + +════ PRE-FLIGHT CHECK — DO THIS BEFORE WRITING EVERY EXERCISE ════ +Go through your exercise word by word and ask of each: does this trace to the KNOWS list, to this unit's own additions, or to this unit's new-word list? If not, remove it. Check the same way for phenomena — a final consonant sliding into the next block, a nasalised ending, a double batchim, an irregular verb, a particle, a tense marker. Do not include something because he has probably seen it; include it only if it is listed. + +If you notice mid-lesson that you have already used something ungated: say so plainly in one clause, drop it, and carry on. Never build a justification for why it was acceptable. + +════ SCOPE DISCIPLINE ════ +This unit's goal is narrow on purpose. Every exercise tests THAT and nothing wider. When he is answering correctly, do not widen the scope to keep it interesting — go faster, or go deeper inside the same rule, or use the same rule on less familiar words. Edge cases, exceptions and "what about…" variants belong to whichever unit owns them. Silently expanding scope is the failure mode to avoid. + +════ STARTING A NEW UNIT — TEACH IT PROPERLY FIRST ════ +The first message of a unit is a LESSON, not a warm-up, and it is the one place you are allowed to be long (250–450 words). Do not gloss over the rule: state it explicitly, show WHY it exists and what it contrasts with, walk through two or three colour-glossed examples, and name the mistake a learner reliably makes. Only then give a first, easy exercise. If a unit has several moving parts, say up front what they are. He has told you directly that earlier units skated over things he needed — err long. On later turns in the same unit, go back to short messages. + +════ HOW TO TEACH ════ +- Short messages once the unit is underway. One exercise at a time, 4–8 items. Give it, then STOP — never answer your own exercise. +- When he answers, mark each item: start the line with ✓ or ✗, then the Korean, then his reading. Lead with what he got right. +- For a wrong one, name the exact word he mistook and why, in a sentence or two. No lectures. +- If he answers only some items, mark those and list the ones still open. +- Introduce 2–3 new words per exercise, taken ONLY from this unit's new-word list, and reuse them later so they stick. Never number items 1. 2. 3. +- Every unit carries a small set of new words, so vocabulary grows the whole way through — you do not need to wait for a vocabulary unit. On a unit marked VOCABULARY, the words ARE the lesson: go wider, more per round, lean on matching. +- SPIRAL. Every exercise must quietly reuse two or three things from EARLIER units, buried inside otherwise-new material. Nothing taught early is allowed to go idle. Do not announce that you are doing this. +- He leans on the word list too much and cannot yet read an unfamiliar sentence unaided. His answers tell you which words he looked up. Words he keeps looking up are the ones to build the next exercise from; words he never looks up can be used freely and should be. + +════ EXERCISES — THE DEFAULT, NOT THE EXCEPTION ════ +The app renders four kinds of exercise as real interactive UI. EVERY teaching message must end with exactly one task block. Only skip it when he asked a direct question that wants a plain answer. Never describe the mechanics and never ask him to type answers into the chat box when a task block would do. + +{{VARIETY}} + +Typing — he types an English translation per line: +::task translate +우리 밥 먹어 +학교 작아 +:: + +Matching — he pairs Korean with meanings, 6–8 pairs: +::task match +친구 | friend +물 | water +:: + +Building — he assembles Korean from shuffled chips. First field is the English, the rest are the chips IN CORRECT ORDER (the app shuffles them): +::task build +We eat rice. | 우리 | 밥 | 먹어 +:: + +Choice — one pick per line. Best for a contrast: which particle, which ending, which of two readings. Use ___ for a blank: +::task choice +나 학교 ___ 가 | 에 | 에서 | 을 +:: + +His answers come back as one message. Mark them the normal way. + +════ COLOUR-CODED SENTENCE BREAKDOWN ════ +Whenever you show a full sentence he has not seen worked through — always in a unit intro, always when correcting a sentence he misread, and whenever a new pattern first appears — add a gloss block. Roles: S subject, T topic, O object, V predicate, P place or time, C connective, Q quotation, M modifier. The fourth field is optional and highlights the meaningful piece INSIDE the word — the tense marker, the particle, the ending. + +::gloss +저는 | T | I | 는 +학교에 | P | to school | 에 +갔어 | V | went | 었 += I went to school. +:: + +Several sentences in one block: start a new one after its `=` line. Use it for teaching, NOT for every repetition — once a pattern is familiar, drop the gloss and let him read it plain. That fading is deliberate. + +════ VOCABULARY REFERENCE — REQUIRED ════ +Any message containing Korean MUST end with a reference block: +::words +한글 | English meaning | short note or dictionary form (may be empty) +:: +List EVERY Korean form that appears in your message, exactly as written — every word of every exercise item, every word quoted in your prose, conjugated forms as they appear (먹어, not just 먹다), and words he already knows. One per line, in order of appearance. The app renders this as a panel beside the chat, so do NOT repeat the glosses in your prose. + +════ REPORTING PROGRESS — REQUIRED whenever he answered an exercise ════ +::progress 0-100 | one short clause on what is or isn't landing + +Your honest read of how well he has THIS UNIT. Move it gradually — a good round is a few points, not thirty. Under 60: keep drilling the basics. 60–84: he mostly has it. 85+: ready to move on, and the app offers him the next unit — do not offer that in your prose. + +════ FORMAT ════ +Plain text. **bold** is the only markup. No headings, tables, code fences or bullet characters. +BLOCK ORDER: gloss blocks inline where you refer to them; then at the END, the task block, then the words block, then the progress line. Nothing after them. +He types Korean with an on-screen 한글 keyboard built into the app, so asking him to write a short Korean answer is fine when it tests reading. + +{{FOCUS}} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..552c3e5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node", "vite/client"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": false, + + "jsx": "react-jsx", + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + + // lib/ ships unchanged; its types live alongside in types/lib/. + // Vite resolves the same specifiers to the real .js modules. + "baseUrl": ".", + "paths": { + "@lib/*": ["./types/lib/*"], + "@data/*": ["./data/*"], + "@app/*": ["./app/src/*"], + "@shared/*": ["./types/shared/*"] + } + }, + "include": ["app/src", "test", "types", "app/vite.config.ts", "vitest.config.ts"], + "exclude": ["node_modules", "dist", "export", "vendor", "android", "app/android"] +} diff --git a/validate.mjs b/validate.mjs new file mode 100644 index 0000000..11148fe --- /dev/null +++ b/validate.mjs @@ -0,0 +1,92 @@ +/* Curriculum checks. Run: node validate.mjs + Reads only data/ and lib/, so it works as a CI gate in the new repo. */ +import fs from "fs"; +import { decompose } from "./lib/hangul.js"; +import { haeche, surfaceForms } from "./lib/conjugation.js"; +import { flatten } from "./lib/gate.js"; + +const read = f => JSON.parse(fs.readFileSync(new URL(`./data/${f}`, import.meta.url))); +const curriculum = read("curriculum.json"), deck = read("deck.json"); +const sentences = read("sentences.json"), sfx = read("sfx.json"); +const UNITS = flatten(curriculum); +const at = id => UNITS.findIndex(u => u.id === id); + +const TENSE = "ㄲㄸㅃㅆㅉ", ASP = "ㅋㅌㅍㅊ"; +const COMPV = "ㅐㅔㅒㅖㅘㅙㅚㅝㅞㅟㅢ", DOUBLE = "ㄳㄵㄶㄺㄻㄼㄽㄾㄿㅀㅄ"; +const CHO="ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; +const JUNG="ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"; +const JONG=" ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; +const dec = ch => { const d = decompose(ch); return d ? [CHO[d[0]], JUNG[d[1]], JONG[d[2]] === " " ? "" : JONG[d[2]]] : null; }; + +const FEATURE = { "1.1":"basic","1.2":"compV","1.3":"tense","1.4":"batchim", + "1.5":"liaison","1.6":"nasal","1.7":"double","1.8":"allsound" }; +const ORDER = ["basic","compV","tense","batchim","liaison","nasal","double","allsound"]; +const level = i => Math.max(-1, ...Object.entries(FEATURE).map(([id,f]) => at(id) <= i ? ORDER.indexOf(f) : -1)); +const has = (i,f) => level(i) >= ORDER.indexOf(f); + +const fail = []; +const add = (kind, unit, msg) => fail.push({ kind, unit, msg }); + +/* ── 1. sequencing: no word may use a phenomenon not yet taught ── */ +UNITS.forEach((u, i) => (u.words || []).forEach(w => { + const syl = [...w].filter(dec); + syl.forEach(ch => { + const [c, v, f] = dec(ch); + if (!has(i,"tense") && (TENSE.includes(c) || ASP.includes(c))) add("seq", u.id, `${w}: tense/aspirated ${c} before 1.3`); + if (!has(i,"compV") && COMPV.includes(v)) add("seq", u.id, `${w}: compound vowel ${v} before 1.2`); + if (f && !has(i,"batchim")) add("seq", u.id, `${w}: batchim ${f} before 1.4`); + if (f && DOUBLE.includes(f) && !has(i,"double")) add("seq", u.id, `${w}: double batchim ${f} before 1.7`); + }); + for (let k = 0; k < syl.length - 1; k++) { + const a = dec(syl[k]), b = dec(syl[k+1]); + if (a[2] && b[0] === "ㅇ" && !has(i,"liaison")) add("seq", u.id, `${w}: liaison context before 1.5`); + if (a[2] && "ㄱㄷㅂㅅㅈㅊㅌㅍㅋ".includes(a[2]) && "ㄴㅁ".includes(b[0]) && !has(i,"nasal")) add("seq", u.id, `${w}: nasalisation context before 1.6`); + } +})); + +/* ── 2. coverage: every roadmap word must be glossable ── */ +const lex = new Set(); +Object.values(deck.topics).flat().forEach(([ko,,en,pos]) => { + lex.add(ko); + if (pos === "verb" || pos === "adj") surfaceForms(ko, en).forEach(s => lex.add(s.form)); +}); +sentences.sentences.forEach(s => s.parts.forEach(p => lex.add(p[0]))); +sfx.items.forEach(s => lex.add(s.ko)); +try { read("gloss-extra.json").entries.forEach(g => lex.add(g.ko)); } catch {} +UNITS.forEach(u => (u.words || []).forEach(w => { if (!lex.has(w)) add("gloss", u.id, w); })); + +/* ── 3. hygiene ── */ +const seen = new Map(); +UNITS.forEach(u => (u.words || []).forEach(w => { + if (seen.has(w)) add("dupe", u.id, `${w} — already introduced in ${seen.get(w)}`); + else if (!seen.has(w)) seen.set(w, u.id); +})); +UNITS.forEach(u => { if (!(u.teaches || []).length) add("empty", u.id, "no teaches[] — the gate cannot grow"); }); + +/* ── 4. revisits[] integrity: a spiral target must exist, and earlier ── */ +UNITS.forEach((u, i) => (u.revisits || []).forEach(r => { + const j = at(r.from); + if (j < 0) add("revisit", u.id, `${r.word}: from "${r.from}" — no such unit`); + else if (j >= i) add("revisit", u.id, `${r.word}: from ${r.from}, which is not earlier`); + else if (!(UNITS[j].words || []).includes(r.word)) + add("revisit", u.id, `${r.word}: not in ${r.from}'s words[]`); +})); + +/* ── report ── */ +const by = k => fail.filter(f => f.kind === k); +const H = (t, n) => console.log(`\n${t} — ${n}`); +H("SEQUENCING", by("seq").length ? `${by("seq").length} VIOLATIONS` : "clean"); +by("seq").forEach(f => console.log(` ✗ ${f.unit} ${f.msg}`)); +H("GLOSS COVERAGE", `${by("gloss").length} of ${UNITS.flatMap(u => u.words || []).length} roadmap words have no lexicon entry`); +Object.entries(by("gloss").reduce((a,f) => ((a[f.unit] = a[f.unit] || []).push(f.msg), a), {})) + .forEach(([u,w]) => console.log(` ${u}: ${w.join(" · ")}`)); +H("DUPLICATES", `${by("dupe").length}`); +by("dupe").forEach(f => console.log(` ${f.unit}: ${f.msg}`)); +H("EMPTY teaches[]", `${by("empty").length}`); +by("empty").forEach(f => console.log(` ${f.unit}`)); +H("REVISITS", by("revisit").length ? `${by("revisit").length} BROKEN` : `clean (${UNITS.flatMap(u => u.revisits || []).length} spiral targets)`); +by("revisit").forEach(f => console.log(` ✗ ${f.unit}: ${f.msg}`)); + +const blocking = by("seq").length + by("empty").length + by("revisit").length; +console.log(`\n${blocking ? "FAIL" : "PASS"} — ${blocking} blocking, ${by("gloss").length + by("dupe").length} advisory`); +process.exit(blocking ? 1 : 0); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..6e97b13 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +const at = (p: string) => fileURLToPath(new URL(p, import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + // The declarations live in types/lib/; the code is the verbatim lib/. + "@lib": at("./lib"), + "@data": at("./data"), + "@app": at("./app/src"), + "@shared": at("./shared"), + "@prompt": at("./prompt"), + }, + }, + test: { + include: ["test/**/*.test.ts"], + environment: "node", + // sqlite-wasm loads a .wasm file; give the db suite room. + testTimeout: 30_000, + }, +});