@ihabdevteam/core 0.315.0 → 0.321.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +425 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +114 -15
- package/dist/packages/core/src/components/BarChart.js +16 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +2 -2
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +4 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/ListPopup.d.ts +12 -2
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
- package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +11 -6
- package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +13 -8
- package/dist/packages/core/src/components/Training/audioWaits.js +22 -4
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +82 -0
- package/dist/packages/core/src/components/Training/stepAnswerable.js +4 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +5 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +13 -1
- package/dist/packages/core/src/components/wordTableLayout.js +13 -1
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +6 -4
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +46 -6
- package/dist/packages/core/src/i18n/locales/en.json +9 -1
- package/dist/packages/core/src/i18n/locales/ko.json +9 -1
- package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
- package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
- package/dist/packages/core/src/utils/wornDate.d.ts +14 -2
- package/dist/packages/core/src/utils/wornDate.js +38 -3
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +7 -3
- package/package.json +2 -1
|
@@ -6,6 +6,9 @@ import { FILTER_ALL_VALUE } from '../types/filterPanel';
|
|
|
6
6
|
import { useWindowEvent } from './useWindowEvent';
|
|
7
7
|
import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
|
|
8
8
|
import { matchesFilters } from '../utils/phonemePairSearch';
|
|
9
|
+
import { wordAttributeValueCodeOf, wordAttributeValueLabelOf, isGradeCode } from '../utils/wordAttributeFilterCodes';
|
|
10
|
+
/** 저장된 등급을 되읽을 때 코드 아닌 것은 버린다 — 0.320.0 작업본이 잠깐 `['']` 를 저장했다 */
|
|
11
|
+
const 등급코드만 = (v) => (Array.isArray(v) ? v.filter(isGradeCode) : []);
|
|
9
12
|
import { hasSel } from '../utils/wordContrast';
|
|
10
13
|
import { phoneticLabel } from '../utils/phonemeGroups';
|
|
11
14
|
/* 글자↔발음 변환과 음절 상수는 wordTrainingFilterModel.ts 로 옮겼다(React 없는 순수 계층).
|
|
@@ -74,6 +77,24 @@ function readComposition(saved) {
|
|
|
74
77
|
* @param caps 그룹이 노출할 설정 (난이도/단어구성). 제외 필터는 항상 노출.
|
|
75
78
|
* @public
|
|
76
79
|
*/
|
|
80
|
+
/** 사전이 배열을 안 주면 한국어 원문으로 물러선다 — 빈 줄을 띄우지 않는다((205)) */
|
|
81
|
+
const 등급설명기본 = [
|
|
82
|
+
'1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘',
|
|
83
|
+
'2등급 : 초등학교 1-2학년 난이도 어휘',
|
|
84
|
+
'3등급 : 초등학교 3-4학년 난이도 어휘',
|
|
85
|
+
'4등급 : 초등학교 5-6학년 난이도 어휘',
|
|
86
|
+
'등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어',
|
|
87
|
+
];
|
|
88
|
+
/**
|
|
89
|
+
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
90
|
+
*
|
|
91
|
+
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
92
|
+
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
93
|
+
*/
|
|
94
|
+
export function 등급설명(t) {
|
|
95
|
+
const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: 등급설명기본 });
|
|
96
|
+
return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : 등급설명기본;
|
|
97
|
+
}
|
|
77
98
|
export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
78
99
|
const { t, i18n } = useTranslation();
|
|
79
100
|
// resetOnMount(단어훈련 만들기 등): 진입 시 항상 기본값에서 시작하도록 저장값을 불러오지 않는다.
|
|
@@ -122,7 +143,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
122
143
|
// 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
|
|
123
144
|
const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
|
|
124
145
|
// 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
|
|
125
|
-
const [gradeFilter, setGradeFilter] = useState(
|
|
146
|
+
const [gradeFilter, setGradeFilter] = useState(등급코드만(saved?.gradeFilter));
|
|
126
147
|
// 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
|
|
127
148
|
const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
|
|
128
149
|
const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
|
|
@@ -190,7 +211,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
190
211
|
setSameStructureOnly(s?.sameStructureOnly ?? true);
|
|
191
212
|
// 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
|
|
192
213
|
setPhonemeView(readPhonemeView(s));
|
|
193
|
-
setGradeFilter(
|
|
214
|
+
setGradeFilter(등급코드만(s?.gradeFilter));
|
|
194
215
|
setPosCodes(posCodesOf(s?.posFilter));
|
|
195
216
|
setTypeCodes(typeCodesOf(s?.wordTypeFilter));
|
|
196
217
|
}
|
|
@@ -414,7 +435,22 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
414
435
|
// label만 오버라이드하고 나머지(items/allOption 등)는 공용 빌더 그대로 사용.
|
|
415
436
|
sections.push({
|
|
416
437
|
...buildGradeSection(t, attrKeys, {
|
|
417
|
-
|
|
438
|
+
/* **등급마다 무엇인지 적는다**(말귀 요청 (205), 사장님이 주신 글 — 2026-10-12). 전에는
|
|
439
|
+
*"국립국어원의 기초 어휘 등급이 결과에 반영됩니다"* 한 줄이라, 1등급이 쉬운 쪽인지
|
|
440
|
+
어려운 쪽인지도 알 수 없었다. 앱 두 화면에는 이미 이 다섯 줄이 들어가 있어
|
|
441
|
+
(앱 열쇠 `wordSearch.filter.gradeInfo`), 이 자리만 한 줄로 남아 **세 화면이 다른 말**을
|
|
442
|
+
하고 있었다 — 글자까지 같은 것을 쓴다.
|
|
443
|
+
|
|
444
|
+
**한 열쇠에 배열로 둔다**(말귀 요청, 2026-10-12). 처음엔 `difficultyInfo1`~`5` 로 나눴는데
|
|
445
|
+
그러면 ① 말귀 못(`deadI18nKeys`)이 **산 열쇠를 죽은 것으로** 센다 — 그 못은 `a.b.${x}` 처럼
|
|
446
|
+
**점 자리에서 잘린 앞머리**를 쓰는 것으로 세는데, 이것은 *마지막 마디 뒤에 숫자를 붙여*
|
|
447
|
+
안 걸린다. ② **줄 수가 다섯에 못 박힌다** — 배열이면 언어마다 줄 수가 달라도 된다.
|
|
448
|
+
③ 앱이 같은 꼴(`returnObjects`)을 이미 쓴다.
|
|
449
|
+
|
|
450
|
+
사전이 배열을 안 주면(옛 사전을 덮어쓴 소비자 등) **한국어 원문으로 물러선다** — 빈 줄을
|
|
451
|
+
띄우지 않는다. 재 보고 적는다: `returnObjects` 는 배열을 그대로 주고 `defaultValue` 도
|
|
452
|
+
배열로 물러선다(i18next 26.4.1). */
|
|
453
|
+
labelInfo: infoAction(등급설명(t)),
|
|
418
454
|
}),
|
|
419
455
|
label: LABELS.gradeLabel,
|
|
420
456
|
});
|
|
@@ -474,7 +510,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
474
510
|
...(sameStructureOnly ? [LABELS.sameStructure] : []),
|
|
475
511
|
],
|
|
476
512
|
// 난이도 등급 — 선택된 등급들의 라벨 배열('1등급' 등)
|
|
477
|
-
[LABELS.gradeLabel]: gradeFilter.map(g =>
|
|
513
|
+
[LABELS.gradeLabel]: gradeFilter.map(g => wordAttributeValueLabelOf('grade', g)),
|
|
478
514
|
// 품사/어종 — 단어표·대립쌍표와 동일하게 선택된 라벨 배열 그대로 노출
|
|
479
515
|
...(caps.partOfSpeech ? { [attrKeys.partOfSpeech]: posFilter } : {}),
|
|
480
516
|
...(caps.wordType ? { [attrKeys.wordType]: wordTypeFilter } : {}),
|
|
@@ -504,7 +540,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
504
540
|
setGradeFilter([]);
|
|
505
541
|
return;
|
|
506
542
|
}
|
|
507
|
-
|
|
543
|
+
/* 라벨 → 코드는 사전으로 — 숫자만 남기면 `없음` 이 `''` 가 되어 저장되고 칩은 영영 안 켜졌다
|
|
544
|
+
(말귀_1 실측 2026-10-12). 못 읽는 라벨은 무시한다. */
|
|
545
|
+
const n = wordAttributeValueCodeOf('grade', value);
|
|
546
|
+
if (n == null)
|
|
547
|
+
return;
|
|
508
548
|
setGradeFilter(prev => prev.includes(n) ? prev.filter(g => g !== n) : [...prev, n]);
|
|
509
549
|
}
|
|
510
550
|
else if (sectionLabel === attrKeys.partOfSpeech) {
|
|
@@ -579,7 +619,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
579
619
|
setExcludeNoImage(s.excludeNoImage);
|
|
580
620
|
setSameStructureOnly(s.sameStructureOnly ?? true);
|
|
581
621
|
setPhonemeView(s.phonemeView);
|
|
582
|
-
setGradeFilter(
|
|
622
|
+
setGradeFilter(등급코드만(s.gradeFilter));
|
|
583
623
|
if (Array.isArray(s.posFilter))
|
|
584
624
|
setPosCodes(posCodesOf(s.posFilter));
|
|
585
625
|
if (Array.isArray(s.wordTypeFilter))
|
|
@@ -901,6 +901,7 @@
|
|
|
901
901
|
"positionTail": "End",
|
|
902
902
|
"difficulty": "Grade",
|
|
903
903
|
"grade": "Grade {{n}}",
|
|
904
|
+
"gradeNone": "None",
|
|
904
905
|
"syllables": "Syllables",
|
|
905
906
|
"syllable": "{{n}} Syll.",
|
|
906
907
|
"syllable5plus": "5+ Syllables",
|
|
@@ -1145,7 +1146,13 @@
|
|
|
1145
1146
|
],
|
|
1146
1147
|
"orderAny": "Any",
|
|
1147
1148
|
"orderMatch": "Match",
|
|
1148
|
-
"difficultyInfo":
|
|
1149
|
+
"difficultyInfo": [
|
|
1150
|
+
"Level 1 — everyday vocabulary acquired before school age and used for life",
|
|
1151
|
+
"Level 2 — difficulty of grades 1–2",
|
|
1152
|
+
"Level 3 — difficulty of grades 3–4",
|
|
1153
|
+
"Level 4 — difficulty of grades 5–6",
|
|
1154
|
+
"No level — frequent everyday words outside the national standard list, plus coinages and loanwords"
|
|
1155
|
+
],
|
|
1149
1156
|
"selectLabel": "Select",
|
|
1150
1157
|
"onlyHasImage": "Only words with images",
|
|
1151
1158
|
"onlySameStructure": "Only same phoneme structure"
|
|
@@ -1363,6 +1370,7 @@
|
|
|
1363
1370
|
"title": "Linked Visit Records"
|
|
1364
1371
|
},
|
|
1365
1372
|
"detail": "Details",
|
|
1373
|
+
"detailFor": "{{name}} details",
|
|
1366
1374
|
"trainingType": {
|
|
1367
1375
|
"LISTEN_IN_NOISE": "Sentence in Noise",
|
|
1368
1376
|
"SHORT_STORY": "Short Story",
|
|
@@ -888,6 +888,7 @@
|
|
|
888
888
|
"positionTail": "어말",
|
|
889
889
|
"difficulty": "등급",
|
|
890
890
|
"grade": "{{n}}등급",
|
|
891
|
+
"gradeNone": "없음",
|
|
891
892
|
"syllables": "음절 수",
|
|
892
893
|
"syllable": "{{n}}음절",
|
|
893
894
|
"syllable5plus": "5음절 이상",
|
|
@@ -1134,7 +1135,13 @@
|
|
|
1134
1135
|
],
|
|
1135
1136
|
"orderAny": "무관",
|
|
1136
1137
|
"orderMatch": "일치",
|
|
1137
|
-
"difficultyInfo":
|
|
1138
|
+
"difficultyInfo": [
|
|
1139
|
+
"1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘",
|
|
1140
|
+
"2등급 : 초등학교 1-2학년 난이도 어휘",
|
|
1141
|
+
"3등급 : 초등학교 3-4학년 난이도 어휘",
|
|
1142
|
+
"4등급 : 초등학교 5-6학년 난이도 어휘",
|
|
1143
|
+
"등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어"
|
|
1144
|
+
],
|
|
1138
1145
|
"selectLabel": "선택",
|
|
1139
1146
|
"onlyHasImage": "이미지 있는 단어만",
|
|
1140
1147
|
"onlySameStructure": "음소 구조 같은 단어만"
|
|
@@ -1350,6 +1357,7 @@
|
|
|
1350
1357
|
"title": "방문 연동 기록"
|
|
1351
1358
|
},
|
|
1352
1359
|
"detail": "상세보기",
|
|
1360
|
+
"detailFor": "{{name}} 상세보기",
|
|
1353
1361
|
"trainingType": {
|
|
1354
1362
|
"LISTEN_IN_NOISE": "소음 하 문장",
|
|
1355
1363
|
"SHORT_STORY": "짧은 이야기",
|
|
@@ -115,7 +115,7 @@ const POS_GROUP_MAP_KO = {
|
|
|
115
115
|
'용언(동사,형용사)': ['동', '형'],
|
|
116
116
|
'수식언(부사,관형사)': ['부', '관'],
|
|
117
117
|
};
|
|
118
|
-
import { POS_CODE_TARGETS, WORD_TYPE_CODE_VALUES, wordAttributeSectionOf, wordAttributeValueCodeOf } from './wordAttributeFilterCodes';
|
|
118
|
+
import { POS_CODE_TARGETS, WORD_TYPE_CODE_VALUES, wordAttributeSectionOf, wordAttributeValueCodeOf, matchesGradeCodes } from './wordAttributeFilterCodes';
|
|
119
119
|
const DEFAULT_PAIR_FILTER_KEYS = {
|
|
120
120
|
difficulty: '난이도 등급',
|
|
121
121
|
syllables: '음절 수',
|
|
@@ -155,8 +155,15 @@ function 모으기(filterSelected, keys) {
|
|
|
155
155
|
*/
|
|
156
156
|
export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
|
|
157
157
|
const { grades, syls, poses, types } = 모으기(filterSelected, keys);
|
|
158
|
-
if (grades.length
|
|
159
|
-
|
|
158
|
+
if (grades.length) {
|
|
159
|
+
/* **`없음` 은 값이 아니라 빔이다**((205), 2026-10-12) — 거르는 결정은 `matchesGradeCodes` **하나**다
|
|
160
|
+
(단어훈련 풀도 같은 것을 부른다). 여기서는 라벨을 코드로만 바꾼다.
|
|
161
|
+
되거름(숫자만 남기기)이 `''` 를 내면 **버린다** — 빈 되거름이 그대로 가면 빈 등급 단어가 **우연히**
|
|
162
|
+
걸려, 사전에서 라벨이 빠진 날 `없음` 이 겉보기엔 되는 꼴이 된다. 그 흠은 제일 늦게 들킨다. */
|
|
163
|
+
const codes = grades.map(g => wordAttributeValueCodeOf('grade', g) ?? g.replace(/\D/g, '')).filter(c => c !== '');
|
|
164
|
+
if (codes.length === 0 || !matchesGradeCodes(codes, word.grade))
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
160
167
|
if (syls.length) {
|
|
161
168
|
const len = word.syllables ?? 0;
|
|
162
169
|
const match = syls.some(syl => {
|
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
/** @public 공용 속성 필터 섹션 코드 */
|
|
2
2
|
export type WordAttributeSectionCode = 'grade' | 'syllables' | 'pos' | 'wordType';
|
|
3
|
+
/**
|
|
4
|
+
* **등급이 빈 단어를 가리키는 코드** — `'none'`((205), 사장님 지시 2026-10-12).
|
|
5
|
+
*
|
|
6
|
+
* ## 이것은 **값이 아니라 빔**이다
|
|
7
|
+
*
|
|
8
|
+
* `'1'`~`'4'` 는 단어의 `grade` 에 **그 글자가 들어 있다.** `'none'` 은 다르다 — 어느 단어의
|
|
9
|
+
* `grade` 도 `'none'` 이 아니고, 가리키는 것은 **`grade` 가 비어 있다**는 사실이다
|
|
10
|
+
* (`undefined` · `null` · `''`, 그리고 공백만 든 것도).
|
|
11
|
+
*
|
|
12
|
+
* 그래서 **맞추개가 이 코드만은 다른 길로 간다** — `word.grade === code` 가 아니라
|
|
13
|
+
* `빈등급인가(word.grade)` 로 본다(`phonemePairSearch.matchesFilters`). 같은 꼴로 재면
|
|
14
|
+
* 한 단어도 안 걸리고, 그런데 **필터는 멀쩡히 켜져 보인다.**
|
|
15
|
+
*
|
|
16
|
+
* **숫자 길에도 안 걸린다** — 아래 `wordAttributeValueCodeOf` 가 등급을 숫자로도 읽는데
|
|
17
|
+
* (`'3등급'` → `'3'`), `'없음'` 은 숫자가 없어 `NaN` 이 되어 그 길에서 `null` 로 떨어진다.
|
|
18
|
+
* 그러니 **사전에 라벨이 있어야** 읽힌다. 사전에서 빠지면 조용히 안 읽힌다 —
|
|
19
|
+
* `test/gradeNoneOption.test.ts` 가 그 자리를 센다.
|
|
20
|
+
*/
|
|
21
|
+
export declare const GRADE_NONE = "none";
|
|
22
|
+
/** 등급이 비었나 — `'none'` 이 가리키는 그 **빔**을 한 자리에서 판정한다. 공백만 든 것도 빔이다. */
|
|
23
|
+
export declare function 빈등급인가(grade: string | null | undefined): boolean;
|
|
24
|
+
/** 등급 코드인가 — `'1'`~`'4'`·`'none'`. 저장된 값을 되읽을 때 **아닌 것은 버린다**(한때 `''` 가 저장됐다). */
|
|
25
|
+
export declare function isGradeCode(v: unknown): v is string;
|
|
26
|
+
/**
|
|
27
|
+
* **등급 코드 집합으로 단어 하나를 거른다 — 이 결정은 여기 하나다.**
|
|
28
|
+
*
|
|
29
|
+
* (205) 를 지을 때 맞추개를 `phonemePairSearch.matchesFilters` 하나로 알고 넷을 고쳤는데,
|
|
30
|
+
* **단어훈련 풀**(`wordTrainingPool`)이 같은 결정을 **두 줄로 따로** 갖고 있었다
|
|
31
|
+
* (`!w.grade || !gradeSet.has(w.grade)`). 그 둘은 빈 등급을 **늘 떨어뜨리므로** `없음` 을 골라도
|
|
32
|
+
* 0개였다(말귀_1 실측 2026-10-12: 매니저 단어표는 맞고 단어훈련 페이지는 틀렸다).
|
|
33
|
+
*
|
|
34
|
+
* **같은 결정이 세 자리에 적혀 있으면 하나는 안 따라온다.** 그래서 세 자리가 이 하나를 부른다.
|
|
35
|
+
* `codes` 가 비면 거르지 않는다(전체).
|
|
36
|
+
*/
|
|
37
|
+
export declare function matchesGradeCodes(codes: ReadonlySet<string> | readonly string[] | null | undefined, grade: string | null | undefined): boolean;
|
|
3
38
|
/**
|
|
4
39
|
* @public 공용 속성 필터를 **언어와 무관한 코드**로 담은 모양 — 저장(DB config·로컬 저장소)용.
|
|
5
40
|
* 예: `{ grade: ['1', '2'], syllables: ['5+'], pos: ['noun'], wordType: ['sino'] }`
|
|
@@ -12,6 +47,8 @@ export declare const WORD_TYPE_CODE_VALUES: Record<string, string>;
|
|
|
12
47
|
export declare function wordAttributeSectionOf(label: string): WordAttributeSectionCode | null;
|
|
13
48
|
/** 한 섹션의 값(코드·어느 언어 라벨·옛 라벨) → 값 코드. 못 읽으면 null. */
|
|
14
49
|
export declare function wordAttributeValueCodeOf(section: WordAttributeSectionCode, value: string): string | null;
|
|
50
|
+
/** 코드 → 지금 언어의 라벨. `'none'` 도 사전 꼴로 나온다(숫자 틀 `{{n}}등급` 에 넣으면 `NaN등급` 이 된다). 모르는 코드는 그대로. */
|
|
51
|
+
export declare function wordAttributeValueLabelOf(section: WordAttributeSectionCode, code: string, lng?: string): string;
|
|
15
52
|
/**
|
|
16
53
|
* @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
|
|
17
54
|
* 속성 섹션(등급·음절 수·품사·어종)만 담고, 그 밖의 섹션(초성·선택·단어 입력 등)은 뺀다. 못 읽는 값은 버린다.
|
|
@@ -6,13 +6,62 @@
|
|
|
6
6
|
* 영어 열쇠('Part of Speech')로는 한국어 값이 안 읽혀 **필터가 풀리거나**, 품사 값이 posMap 에 없어 **모든 단어가 떨어졌다**.
|
|
7
7
|
* 여기서 섹션·값을 언어와 무관한 코드로 읽는다 — 코드, 지금 언어·ko·en 라벨, 옛 라벨('난이도 등급'·'체언(명사,수사)') 모두.
|
|
8
8
|
*
|
|
9
|
-
* 코드: 섹션 `grade`·`syllables`·`pos`·`wordType`, 값 등급 `'1'`~`'4'` · 음절 `'1'`~`'4'`·`'5+'` ·
|
|
9
|
+
* 코드: 섹션 `grade`·`syllables`·`pos`·`wordType`, 값 등급 `'1'`~`'4'`·`'none'` · 음절 `'1'`~`'4'`·`'5+'` ·
|
|
10
10
|
* 품사 `noun`·`verb`·`modifier` · 어종 `native`·`sino`·`foreign`·`mixed`.
|
|
11
11
|
* (useWordTrainingFilter 의 POS_CODES·toAttrCodes 와 같은 코드다.)
|
|
12
12
|
*/
|
|
13
13
|
import i18n from '../i18n';
|
|
14
|
+
/**
|
|
15
|
+
* **등급이 빈 단어를 가리키는 코드** — `'none'`((205), 사장님 지시 2026-10-12).
|
|
16
|
+
*
|
|
17
|
+
* ## 이것은 **값이 아니라 빔**이다
|
|
18
|
+
*
|
|
19
|
+
* `'1'`~`'4'` 는 단어의 `grade` 에 **그 글자가 들어 있다.** `'none'` 은 다르다 — 어느 단어의
|
|
20
|
+
* `grade` 도 `'none'` 이 아니고, 가리키는 것은 **`grade` 가 비어 있다**는 사실이다
|
|
21
|
+
* (`undefined` · `null` · `''`, 그리고 공백만 든 것도).
|
|
22
|
+
*
|
|
23
|
+
* 그래서 **맞추개가 이 코드만은 다른 길로 간다** — `word.grade === code` 가 아니라
|
|
24
|
+
* `빈등급인가(word.grade)` 로 본다(`phonemePairSearch.matchesFilters`). 같은 꼴로 재면
|
|
25
|
+
* 한 단어도 안 걸리고, 그런데 **필터는 멀쩡히 켜져 보인다.**
|
|
26
|
+
*
|
|
27
|
+
* **숫자 길에도 안 걸린다** — 아래 `wordAttributeValueCodeOf` 가 등급을 숫자로도 읽는데
|
|
28
|
+
* (`'3등급'` → `'3'`), `'없음'` 은 숫자가 없어 `NaN` 이 되어 그 길에서 `null` 로 떨어진다.
|
|
29
|
+
* 그러니 **사전에 라벨이 있어야** 읽힌다. 사전에서 빠지면 조용히 안 읽힌다 —
|
|
30
|
+
* `test/gradeNoneOption.test.ts` 가 그 자리를 센다.
|
|
31
|
+
*/
|
|
32
|
+
export const GRADE_NONE = 'none';
|
|
33
|
+
/** 등급이 비었나 — `'none'` 이 가리키는 그 **빔**을 한 자리에서 판정한다. 공백만 든 것도 빔이다. */
|
|
34
|
+
export function 빈등급인가(grade) {
|
|
35
|
+
return grade == null || String(grade).trim() === '';
|
|
36
|
+
}
|
|
37
|
+
/** 등급 코드인가 — `'1'`~`'4'`·`'none'`. 저장된 값을 되읽을 때 **아닌 것은 버린다**(한때 `''` 가 저장됐다). */
|
|
38
|
+
export function isGradeCode(v) {
|
|
39
|
+
return typeof v === 'string' && 등급값.includes(v);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* **등급 코드 집합으로 단어 하나를 거른다 — 이 결정은 여기 하나다.**
|
|
43
|
+
*
|
|
44
|
+
* (205) 를 지을 때 맞추개를 `phonemePairSearch.matchesFilters` 하나로 알고 넷을 고쳤는데,
|
|
45
|
+
* **단어훈련 풀**(`wordTrainingPool`)이 같은 결정을 **두 줄로 따로** 갖고 있었다
|
|
46
|
+
* (`!w.grade || !gradeSet.has(w.grade)`). 그 둘은 빈 등급을 **늘 떨어뜨리므로** `없음` 을 골라도
|
|
47
|
+
* 0개였다(말귀_1 실측 2026-10-12: 매니저 단어표는 맞고 단어훈련 페이지는 틀렸다).
|
|
48
|
+
*
|
|
49
|
+
* **같은 결정이 세 자리에 적혀 있으면 하나는 안 따라온다.** 그래서 세 자리가 이 하나를 부른다.
|
|
50
|
+
* `codes` 가 비면 거르지 않는다(전체).
|
|
51
|
+
*/
|
|
52
|
+
export function matchesGradeCodes(codes, grade) {
|
|
53
|
+
if (!codes)
|
|
54
|
+
return true;
|
|
55
|
+
const set = codes instanceof Set ? codes : new Set(codes);
|
|
56
|
+
if (set.size === 0)
|
|
57
|
+
return true;
|
|
58
|
+
if (빈등급인가(grade))
|
|
59
|
+
return set.has(GRADE_NONE);
|
|
60
|
+
return set.has(String(grade));
|
|
61
|
+
}
|
|
14
62
|
const 섹션들 = ['grade', 'syllables', 'pos', 'wordType'];
|
|
15
|
-
|
|
63
|
+
/** 등급 코드 — `'none'` 은 **등급이 빈 단어**다(값이 아니라 **빔**이다. GRADE_NONE 주석을 볼 것) */
|
|
64
|
+
const 등급값 = ['1', '2', '3', '4', 'none'];
|
|
16
65
|
const 음절값 = ['1', '2', '3', '4', '5+'];
|
|
17
66
|
const 품사값 = ['noun', 'verb', 'modifier'];
|
|
18
67
|
const 어종값 = ['native', 'sino', 'foreign', 'mixed'];
|
|
@@ -44,7 +93,11 @@ function 사전(lng) {
|
|
|
44
93
|
wordType: t('wordSearch.filter.wordType', '유형'),
|
|
45
94
|
},
|
|
46
95
|
값: {
|
|
47
|
-
grade:
|
|
96
|
+
grade: {
|
|
97
|
+
...Object.fromEntries(등급값.filter(n => n !== GRADE_NONE).map((n) => [n, t('wordSearch.filter.grade', { n: Number(n), defaultValue: `${n}등급` })])),
|
|
98
|
+
/* **`없음` 은 숫자 틀을 안 쓴다** — `t('…grade', { n: Number('none') })` 은 `NaN등급` 이 된다 */
|
|
99
|
+
[GRADE_NONE]: t('wordSearch.filter.gradeNone', '없음'),
|
|
100
|
+
},
|
|
48
101
|
syllables: {
|
|
49
102
|
...Object.fromEntries(['1', '2', '3', '4'].map((n) => [n, t('wordSearch.filter.syllable', { n: Number(n), defaultValue: `${n}음절` })])),
|
|
50
103
|
'5+': t('wordSearch.filter.syllable5plus', '5음절 이상'),
|
|
@@ -105,6 +158,10 @@ export function wordAttributeValueCodeOf(section, value) {
|
|
|
105
158
|
}
|
|
106
159
|
return null;
|
|
107
160
|
}
|
|
161
|
+
/** 코드 → 지금 언어의 라벨. `'none'` 도 사전 꼴로 나온다(숫자 틀 `{{n}}등급` 에 넣으면 `NaN등급` 이 된다). 모르는 코드는 그대로. */
|
|
162
|
+
export function wordAttributeValueLabelOf(section, code, lng = i18n.language) {
|
|
163
|
+
return 사전(lng).값[section][code] ?? code;
|
|
164
|
+
}
|
|
108
165
|
const 배열로 = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : typeof v === 'string' && v ? [v] : []);
|
|
109
166
|
/**
|
|
110
167
|
* @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { getImageExistenceStats, getImageOkWordSet } from './imageConfig';
|
|
13
13
|
import { getWordPool } from './wordPool';
|
|
14
|
+
import { matchesGradeCodes } from './wordAttributeFilterCodes';
|
|
14
15
|
import { shuffle } from './array';
|
|
15
16
|
import { getPairIndex } from './wordPairIndex';
|
|
16
17
|
import { classifyPairDifficulty, wordContainsPhoneme, wordHasNoCoda, wordHasSyllableMatching, syllableLength, findTypedContrastWords, countPhonemeDiff, createDecompCache } from './koreanSyllable';
|
|
@@ -259,7 +260,7 @@ export function answerWordFilter(opts) {
|
|
|
259
260
|
return null;
|
|
260
261
|
const pron = opts.phonemeView === 'pron';
|
|
261
262
|
return (w) => {
|
|
262
|
-
if (gradeSet
|
|
263
|
+
if (!matchesGradeCodes(gradeSet, w.grade))
|
|
263
264
|
return false;
|
|
264
265
|
if (preds.length === 0)
|
|
265
266
|
return true;
|
|
@@ -308,7 +309,7 @@ async function _buildUsablePool(opts) {
|
|
|
308
309
|
let candidates = MVP.filter(w => {
|
|
309
310
|
if (!w.w)
|
|
310
311
|
return false;
|
|
311
|
-
if (gradeSet
|
|
312
|
+
if (!matchesGradeCodes(gradeSet, w.grade))
|
|
312
313
|
return false;
|
|
313
314
|
if (useAllCandidates)
|
|
314
315
|
return true;
|
|
@@ -5,8 +5,20 @@
|
|
|
5
5
|
* `2026.null.null` 이 찍혔고, 정렬 열쇠를 만들 때는 `null.padStart` 에서 표가 통째로 터졌다. 빈 값은 '' 로 다룬다.
|
|
6
6
|
*/
|
|
7
7
|
type 칸값 = string | number | null | undefined;
|
|
8
|
-
/**
|
|
9
|
-
|
|
8
|
+
/**
|
|
9
|
+
* 화면에 적을 착용일.
|
|
10
|
+
*
|
|
11
|
+
* **`lang` 을 주면 보는 말을 따른다**(말귀 요청 (118), 사장님 지시 2026-10-11 *"날짜 꼴도 하나로
|
|
12
|
+
* 통일해"*). 한국어 `2026년 9월 9일` · 영어 `Sep 9, 2026` — 앱이 다른 화면 날짜를 모은 그 꼴이고,
|
|
13
|
+
* 코어 `formatDateLocale` 이 내는 것과 같다(글자를 또 짓지 않고 그것에 넘긴다).
|
|
14
|
+
*
|
|
15
|
+
* **연·월·일이 다 있을 때만** 그렇게 한다. 반쪽 값(연도만 · 연월만)은 그 말로 옮길 수가 없어
|
|
16
|
+
* 예전처럼 점으로 잇는다(`2026` · `2026.9`) — 서버에 월·일이 `null` 로 오는 옛 기록이 있다.
|
|
17
|
+
*
|
|
18
|
+
* `lang` 을 안 주면 **예전 그대로** 점 꼴이다(`2026.09.09`) — 부르는 자리가 말을 모르는 곳도 있을
|
|
19
|
+
* 수 있어 깨지지 않게 둔다. 화면에 그리는 자리는 주는 쪽이 맞다.
|
|
20
|
+
*/
|
|
21
|
+
export declare function formatWornDate(year: 칸값, month: 칸값, day: 칸값, lang?: string): string;
|
|
10
22
|
/** 착용일 정렬 열쇠 — 자리수를 채워(`2026-05-02`) 글자 비교가 날짜 차례와 같게. 연도가 없으면 ''. */
|
|
11
23
|
export declare function wornDateSortKey(year: 칸값, month: 칸값, day: 칸값): string;
|
|
12
24
|
export {};
|
|
@@ -1,7 +1,42 @@
|
|
|
1
|
+
import { formatDateLocale } from './date';
|
|
1
2
|
const 글자 = (v) => (v == null ? '' : String(v).trim());
|
|
2
|
-
/**
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* 화면에 적을 착용일.
|
|
5
|
+
*
|
|
6
|
+
* **`lang` 을 주면 보는 말을 따른다**(말귀 요청 (118), 사장님 지시 2026-10-11 *"날짜 꼴도 하나로
|
|
7
|
+
* 통일해"*). 한국어 `2026년 9월 9일` · 영어 `Sep 9, 2026` — 앱이 다른 화면 날짜를 모은 그 꼴이고,
|
|
8
|
+
* 코어 `formatDateLocale` 이 내는 것과 같다(글자를 또 짓지 않고 그것에 넘긴다).
|
|
9
|
+
*
|
|
10
|
+
* **연·월·일이 다 있을 때만** 그렇게 한다. 반쪽 값(연도만 · 연월만)은 그 말로 옮길 수가 없어
|
|
11
|
+
* 예전처럼 점으로 잇는다(`2026` · `2026.9`) — 서버에 월·일이 `null` 로 오는 옛 기록이 있다.
|
|
12
|
+
*
|
|
13
|
+
* `lang` 을 안 주면 **예전 그대로** 점 꼴이다(`2026.09.09`) — 부르는 자리가 말을 모르는 곳도 있을
|
|
14
|
+
* 수 있어 깨지지 않게 둔다. 화면에 그리는 자리는 주는 쪽이 맞다.
|
|
15
|
+
*/
|
|
16
|
+
export function formatWornDate(year, month, day, lang) {
|
|
17
|
+
const y = 글자(year), m = 글자(month), d = 글자(day);
|
|
18
|
+
if (lang && y && m && d) {
|
|
19
|
+
/* 자리수를 채워 ISO 로 만든다 — `formatDateLocale` 이 읽는 꼴. */
|
|
20
|
+
const iso = `${y.padStart(4, '0')}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
|
21
|
+
/**
|
|
22
|
+
* **되돌아오는지 본다 — `new Date` 는 없는 날짜를 조용히 굴린다.**
|
|
23
|
+
* `2026-09-31` → **2026-10-01**, `2026-02-30` → **2026-03-02**(실측). 그러면 자료에 적힌 날과
|
|
24
|
+
* **다른 날**이 화면에 선다 — 잘못된 값을 보여 주는 것보다 **조용히 고쳐 보여 주는 것**이 나쁘다
|
|
25
|
+
* (사람이 틀린 줄 모른다). 지은 ISO 가 그대로 돌아올 때만 말로 옮기고, 아니면 아래 점 꼴로
|
|
26
|
+
* 자료에 적힌 그대로 보여 준다.
|
|
27
|
+
*
|
|
28
|
+
* *이 가름은 못이 안 울어서 찾았다* — `&& m && d` 조건만 깨뜨려 봤을 때는 조용했다(그때는
|
|
29
|
+
* `2026-00-00` 이 **못 읽는 날짜**라 저절로 떨어졌다). 못이 안 울면 **무엇이 막고 있나**를 묻는다.
|
|
30
|
+
*/
|
|
31
|
+
const 날 = new Date(iso);
|
|
32
|
+
const 되돌아옴 = !Number.isNaN(날.getTime()) && 날.toISOString().slice(0, 10) === iso;
|
|
33
|
+
if (되돌아옴) {
|
|
34
|
+
const 말대로 = formatDateLocale(iso, lang);
|
|
35
|
+
if (말대로)
|
|
36
|
+
return 말대로;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const 조각 = [y, m, d];
|
|
5
40
|
while (조각.length > 0 && 조각[조각.length - 1] === '')
|
|
6
41
|
조각.pop();
|
|
7
42
|
return 조각.join('.');
|