@ihabdevteam/core 0.318.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 +236 -0
- package/dist/components.cjs.js +22 -22
- 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 +188 -188
- package/dist/ihabdevteam-core.d.ts +89 -12
- 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/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/stepAnswerable.d.ts +58 -8
- 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/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 +42 -7
- package/dist/packages/core/src/i18n/locales/en.json +8 -5
- package/dist/packages/core/src/i18n/locales/ko.json +8 -5
- 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/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
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* **지금 답을 고칠 수 있나** — 단계 상태 하나로 가른다.
|
|
3
3
|
*
|
|
4
|
-
* 이
|
|
5
|
-
* `TrainingStepConfirm
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* 이 결정은 **네 조각**에 `currentState !== 'active'` 로 적혀 있었다(`TrainingStepCard` ·
|
|
5
|
+
* `TrainingStepConfirm` · `TrainingStepConfirmWithText` · `TrainingStepCardOrder`). 그 막이는
|
|
6
|
+
* **까닭이 있어 들어왔다** — 단추는 `!== 'disabled'` 로 열려 있는데 기록은 `=== 'active'` 일 때만
|
|
7
|
+
* 나가서, **화면 선택은 바뀌는데 점수는 그대로**였다.
|
|
8
|
+
*
|
|
9
|
+
* > **처음에 여섯으로 셌는데 틀렸다**(말귀 실측이 바로잡음 2026-10-12). `TrainingStepOrderList` 의
|
|
10
|
+
* > `!== 'active'` 는 **조작 막이가 아니라** `useEffect` 안의 **첫 보고 문**이다((103) 의 `initial`).
|
|
11
|
+
* > 글자가 같아서 같은 꼴로 세어졌다 — **글로 자리를 집을 때는 그 글이 하나인지 먼저 센다** 를
|
|
12
|
+
* > 내가 적어 두고 내 셈에서 밟았다. `OrderList` 의 `move` 는 **원래 안 막혀 있다**(말귀가 끝낸
|
|
13
|
+
* > 단계에서 또 옮겨 차례가 바뀌는 것을 확인했다) — 이 집안이 아니다.
|
|
8
14
|
*
|
|
9
15
|
* 그런데 그 막이가 **자기 첫 고름에 자기를 막는다**(말귀 요청 (201), QA 버그 보고 2026-10-12):
|
|
10
16
|
* 고르면 `onDone` 이 나가고 단계가 넘어가 `currentState` 가 `'done'` 이 된다 — 그 뒤로는
|
|
@@ -22,10 +28,54 @@
|
|
|
22
28
|
* 알려도 차례는 그대로다(`TrainingPagePopup.tsx`). 재지 않고 고쳤으면 **고친 답이 다음 단계를
|
|
23
29
|
* 한 칸 더 밀** 수도 있었다.
|
|
24
30
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
31
|
+
* **옮긴 곳 넷**(말귀가 **화면에서 다 재고** 나서 옮겼다 — 2026-10-12):
|
|
32
|
+
*
|
|
33
|
+
* Card 변별훈련 › 같은소리고르기 QA 버그 보고
|
|
34
|
+
* ConfirmWithText 오늘의 듣기 › 소음 하 문장 듣기 `disabled:false` 라 막혀 보이지도 않았다
|
|
35
|
+
* Confirm(한 개) 오늘의 듣기 › 짧은 이야기 고르는 순간이 곧 답
|
|
36
|
+
* CardOrder 단어훈련 › 듣고 순서대로 기억하기 **마지막 한 장만 못 푸는** 꼴
|
|
37
|
+
*
|
|
38
|
+
* **한때 안 옮긴 곳이 하나 있었다** — `Confirm` 의 **여럿 고르기** 갈래다. 말귀가 재현했다
|
|
39
|
+
* (2026-10-12): `오늘의 듣기 › 긴 이야기 듣기 › 페트라`, *"기억나는 단어를 나열해 보세요."* —
|
|
40
|
+
* 둘을 골라 `확인` 을 누른 뒤에는 **선택도 안 바뀌고 `확인` 도 다시 안 눌렸다.** 그 갈래가
|
|
41
|
+
* 켜지는 자리는 앱에 하나이고 **정답이 둘 이상인 문항에서만** 켠다
|
|
42
|
+
* (`multiSelect={_corrects.length > 1}`) — 같은 훈련 안에서도 문항마다 갈려서 찾기 어려웠다.
|
|
43
|
+
*
|
|
44
|
+
* **그 까닭이 세 번 바뀌었고, 네 번째에 갈래가 없어졌다:**
|
|
45
|
+
*
|
|
46
|
+
* ① 못 쟀다 여럿 고르기를 쓰는 화면을 못 찾았다
|
|
47
|
+
* ② 뜻을 기다린다 재현됐다 — 낸 뒤에도 고치게 할지는 **뜻**에 달렸다
|
|
48
|
+
* ③ 내는 자리가 다르다 `확인` 이 곧 냄이다 → 그대로 둔다 (코드는 안 바뀜)
|
|
49
|
+
* ④ 내는 자리가 하나다 **`확인` 이 걷혔다** → 여럿 고르기도 열린다
|
|
50
|
+
*
|
|
51
|
+
* ## 규칙은 하나다 — **내기 전까지는 고칠 수 있다**
|
|
52
|
+
*
|
|
53
|
+
* ③ 때는 *내는 자리*가 갈래마다 달라서 가름이 섰다(사장님 지시를 말귀가 전함, 2026-10-12 —
|
|
54
|
+
* *"확인, 결과보기 누르기 전까지는 수정할 수 있어야 해. UX 바꾸지 마."*):
|
|
55
|
+
*
|
|
56
|
+
* 여럿 고르기 내는 자리 = **`확인`** → 낸 뒤 잠긴다
|
|
57
|
+
* 그 밖 모두 내는 자리 = **`결과보기`** → 그 전까지 고칠 수 있다
|
|
58
|
+
*
|
|
59
|
+
* 그런데 같은 날 사장님이 **그 `확인` 단추를 걷으라** 하셨다((206) — *"확인버튼 제거 하라고
|
|
60
|
+
* 했어"* · *"UX 바꾸지 마"*, 뒤 말은 **고치는 방식**을 바꾸지 말라는 뜻이다). 내는 자리가
|
|
61
|
+
* `결과보기` 하나가 되었으니 **가를 까닭도 같이 없어졌다.** 이제 **다섯 자리가 이 하나를
|
|
62
|
+
* 쓴다** — `Card` · `ConfirmWithText` · `Confirm`(두 갈래 다) · `CardOrder`.
|
|
63
|
+
*
|
|
64
|
+
* `확인` 을 걷을 때 `multiSelect && currentState !== 'active'` 막이를 **함께** 열었다.
|
|
65
|
+
* 하나만 하면 **반대쪽 어긋남**이 난다 — 단추만 걷으면 고름이 **첫 한 번에 얼어붙어**,
|
|
66
|
+
* 지키라 하신 *"수정할 수 있어야 해"* 를 **단추를 걷어서 어기는** 꼴이 된다.
|
|
67
|
+
*
|
|
68
|
+
* `canAnswerStep` 이 *"닿지 않은 단계만 막는다"* 로 적힌 것보다 **내는 자리**가 까닭이다 —
|
|
69
|
+
* 다음에 갈래가 늘면 *"이 갈래는 무엇으로 내는가"* 를 먼저 묻는다.
|
|
70
|
+
*
|
|
71
|
+
* > **까닭이 바뀐 자취를 지우지 않는다.** ③에서 ④로 간 것은 **재서가 아니라 뜻이 바뀌어서**다.
|
|
72
|
+
* > 그 둘을 섞지 않는다 — 다시 볼 때 묻는 물음이 다르다(실측이 뒤집은 가름은 *"또 재 보자"*,
|
|
73
|
+
* > 결정이 뒤집은 가름은 *"아직 그 뜻인가"*).
|
|
74
|
+
* >
|
|
75
|
+
* > (206) 은 하루에 **청함 → 철회 → 되살림**으로 세 번 흔들렸다. 두 번째 때 *"지시의
|
|
76
|
+
* > 뒷문장이 까닭인지 지시인지"* 가 갈렸고, 세 번째는 *"UX 바꾸지 마"* 가 **단추**를 말하는지
|
|
77
|
+
* > **고치는 방식**을 말하는지가 갈렸다. 그 세 번 동안 전해 받은 말로 짓지 않은 덕에
|
|
78
|
+
* > **되돌릴 것이 없었다.**
|
|
29
79
|
*/
|
|
30
80
|
export type 단계상태 = 'disabled' | 'done' | 'active';
|
|
31
81
|
/** 답을 고를 수 있나 — 닿지 않은 단계(`disabled`)만 막는다. @public */
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
background: transparent;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/* ── 표를 손보는 단추들 (표 바로 아래) — (210) ───────────
|
|
11
|
+
간격은 Section 의 flex gap 이 맡는다(`__meta` 와 같은 규칙 — 제 margin 을 더하면 이중 여백).
|
|
12
|
+
아래 `__actions` 와 같은 꼴로 선다: 모자라면 아랫줄로, 폰에서는 두 칸 격자. */
|
|
13
|
+
.word-table-block__table-actions {
|
|
14
|
+
display: flex;
|
|
15
|
+
gap: var(--spacing-xs);
|
|
16
|
+
flex-wrap: wrap;
|
|
17
|
+
/* **가운데**((211), 사장님 지시 *"가운데 배치"*). 종이(표)는 가운데 서는데 그 아래 줄만
|
|
18
|
+
왼쪽 끝에 붙어 한 덩이로 안 읽혔다(말귀 실측 1403 폭: 줄 폭 1272, 단추 둘이 왼끝).
|
|
19
|
+
아래 액션 줄은 `space-between` 이라 그대로 둔다 — 거기는 양끝에 갈라 세우는 줄이다. */
|
|
20
|
+
justify-content: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
/* ── 액션 버튼 ─────────────────────────────────────────── */
|
|
11
24
|
.word-table-block__actions {
|
|
12
25
|
display: flex;
|
|
@@ -53,6 +66,18 @@
|
|
|
53
66
|
min-width: 0;
|
|
54
67
|
width: 100%;
|
|
55
68
|
}
|
|
69
|
+
/* (210) 표 밑 줄도 같은 꼴 — **@media 를 따로 열지 않는다.** 처음 위에 따로 열었다가
|
|
70
|
+
`wordTableBlockActionsLeftExtra` 가 울었다: 그 못은 `css.slice(0, indexOf('@media'))` 로
|
|
71
|
+
넓은 쪽을 자르는데, 앞에 `@media` 가 하나 더 생기면 **자르는 자리가 앞당겨져** 넓은 쪽
|
|
72
|
+
규칙을 못 찾는다. 못이 글의 차례에 기대고 있었고, 내가 그 차례를 바꿨다. */
|
|
73
|
+
.word-table-block__table-actions {
|
|
74
|
+
display: grid;
|
|
75
|
+
grid-template-columns: repeat(2, 1fr);
|
|
76
|
+
}
|
|
77
|
+
.word-table-block__table-actions .button {
|
|
78
|
+
min-width: 0;
|
|
79
|
+
width: 100%;
|
|
80
|
+
}
|
|
56
81
|
}
|
|
57
82
|
|
|
58
83
|
/* ── 팝업 내 미리보기 ───────────────────────────────────── */
|
|
@@ -334,7 +334,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
334
334
|
(위 `onReorder` 와 같은 셈이다. 쪽이 몇으로 나뉘었는지는 부르는 쪽 일이 아니다). */
|
|
335
335
|
renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: 띠, code: code, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
|
|
336
336
|
/* 편집 모드에는 얹을 항목이 없다 — 칸을 손으로 채우는 꼴이다 */
|
|
337
|
-
renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
|
|
337
|
+
renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
|
|
338
338
|
/* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
|
|
339
339
|
이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
|
|
340
340
|
섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
|
|
@@ -349,5 +349,5 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
|
|
|
349
349
|
e.preventDefault();
|
|
350
350
|
onDescriptionChange?.(descriptionPlaceholder ?? '');
|
|
351
351
|
}
|
|
352
|
-
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly &&
|
|
352
|
+
} }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
|
|
353
353
|
}
|
|
@@ -24,7 +24,16 @@ export interface WordAttributeFilterKeys {
|
|
|
24
24
|
* 이 훅을 함께 호출해 라벨 문구와 posMap/typeMap(품사·어종 판정 규칙)을 어긋나지 않게 공유한다.
|
|
25
25
|
*/
|
|
26
26
|
export declare function useWordAttributeFilterKeys(): WordAttributeFilterKeys;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
|
|
29
|
+
*
|
|
30
|
+
* **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
|
|
31
|
+
* 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
|
|
32
|
+
* 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
|
|
33
|
+
*
|
|
34
|
+
* 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
|
|
35
|
+
* 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
|
|
36
|
+
*/
|
|
28
37
|
export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
|
|
29
38
|
allLabel?: string;
|
|
30
39
|
labelAction?: ReactNode;
|
|
@@ -31,11 +31,20 @@ export function useWordAttributeFilterKeys() {
|
|
|
31
31
|
},
|
|
32
32
|
}), [t]);
|
|
33
33
|
}
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
|
|
36
|
+
*
|
|
37
|
+
* **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
|
|
38
|
+
* 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
|
|
39
|
+
* 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
|
|
40
|
+
*
|
|
41
|
+
* 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
|
|
42
|
+
* 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
|
|
43
|
+
*/
|
|
35
44
|
export function buildGradeSection(t, keys, opts) {
|
|
36
45
|
return {
|
|
37
46
|
label: keys.difficulty,
|
|
38
|
-
items: [1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })),
|
|
47
|
+
items: [...[1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })), t('wordSearch.filter.gradeNone', '없음')],
|
|
39
48
|
multiple: true,
|
|
40
49
|
allOption: true,
|
|
41
50
|
allLabel: opts?.allLabel,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TFunction } from 'i18next';
|
|
1
2
|
import { type FilterPanelSection } from '../types/filterPanel';
|
|
2
3
|
import { type WordComposition } from './wordTrainingFilterModel';
|
|
3
4
|
export { valueToJamos, NO_CODA, SYLLABLE_MIN, syllableToFilter, charToPronValue, pronToCharValue, convertComposition, EMPTY_COMPOSITION, } from './wordTrainingFilterModel';
|
|
@@ -52,11 +53,12 @@ export interface WordTrainingFilterCaps {
|
|
|
52
53
|
requireCoda?: boolean;
|
|
53
54
|
}
|
|
54
55
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
56
|
+
* 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
|
|
57
|
+
*
|
|
58
|
+
* **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
|
|
59
|
+
* 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
|
|
59
60
|
*/
|
|
61
|
+
export declare function 등급설명(t: TFunction): string[];
|
|
60
62
|
export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
|
|
61
63
|
resetOnMount?: boolean;
|
|
62
64
|
resetKey?: string;
|
|
@@ -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
|
}
|
|
@@ -418,8 +439,18 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
418
439
|
*"국립국어원의 기초 어휘 등급이 결과에 반영됩니다"* 한 줄이라, 1등급이 쉬운 쪽인지
|
|
419
440
|
어려운 쪽인지도 알 수 없었다. 앱 두 화면에는 이미 이 다섯 줄이 들어가 있어
|
|
420
441
|
(앱 열쇠 `wordSearch.filter.gradeInfo`), 이 자리만 한 줄로 남아 **세 화면이 다른 말**을
|
|
421
|
-
하고 있었다 — 글자까지 같은 것을 쓴다.
|
|
422
|
-
|
|
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)),
|
|
423
454
|
}),
|
|
424
455
|
label: LABELS.gradeLabel,
|
|
425
456
|
});
|
|
@@ -479,7 +510,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
479
510
|
...(sameStructureOnly ? [LABELS.sameStructure] : []),
|
|
480
511
|
],
|
|
481
512
|
// 난이도 등급 — 선택된 등급들의 라벨 배열('1등급' 등)
|
|
482
|
-
[LABELS.gradeLabel]: gradeFilter.map(g =>
|
|
513
|
+
[LABELS.gradeLabel]: gradeFilter.map(g => wordAttributeValueLabelOf('grade', g)),
|
|
483
514
|
// 품사/어종 — 단어표·대립쌍표와 동일하게 선택된 라벨 배열 그대로 노출
|
|
484
515
|
...(caps.partOfSpeech ? { [attrKeys.partOfSpeech]: posFilter } : {}),
|
|
485
516
|
...(caps.wordType ? { [attrKeys.wordType]: wordTypeFilter } : {}),
|
|
@@ -509,7 +540,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
509
540
|
setGradeFilter([]);
|
|
510
541
|
return;
|
|
511
542
|
}
|
|
512
|
-
|
|
543
|
+
/* 라벨 → 코드는 사전으로 — 숫자만 남기면 `없음` 이 `''` 가 되어 저장되고 칩은 영영 안 켜졌다
|
|
544
|
+
(말귀_1 실측 2026-10-12). 못 읽는 라벨은 무시한다. */
|
|
545
|
+
const n = wordAttributeValueCodeOf('grade', value);
|
|
546
|
+
if (n == null)
|
|
547
|
+
return;
|
|
513
548
|
setGradeFilter(prev => prev.includes(n) ? prev.filter(g => g !== n) : [...prev, n]);
|
|
514
549
|
}
|
|
515
550
|
else if (sectionLabel === attrKeys.partOfSpeech) {
|
|
@@ -584,7 +619,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
584
619
|
setExcludeNoImage(s.excludeNoImage);
|
|
585
620
|
setSameStructureOnly(s.sameStructureOnly ?? true);
|
|
586
621
|
setPhonemeView(s.phonemeView);
|
|
587
|
-
setGradeFilter(
|
|
622
|
+
setGradeFilter(등급코드만(s.gradeFilter));
|
|
588
623
|
if (Array.isArray(s.posFilter))
|
|
589
624
|
setPosCodes(posCodesOf(s.posFilter));
|
|
590
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,11 +1146,13 @@
|
|
|
1145
1146
|
],
|
|
1146
1147
|
"orderAny": "Any",
|
|
1147
1148
|
"orderMatch": "Match",
|
|
1148
|
-
"
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
+
],
|
|
1153
1156
|
"selectLabel": "Select",
|
|
1154
1157
|
"onlyHasImage": "Only words with images",
|
|
1155
1158
|
"onlySameStructure": "Only same phoneme structure"
|
|
@@ -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,11 +1135,13 @@
|
|
|
1134
1135
|
],
|
|
1135
1136
|
"orderAny": "무관",
|
|
1136
1137
|
"orderMatch": "일치",
|
|
1137
|
-
"
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1138
|
+
"difficultyInfo": [
|
|
1139
|
+
"1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘",
|
|
1140
|
+
"2등급 : 초등학교 1-2학년 난이도 어휘",
|
|
1141
|
+
"3등급 : 초등학교 3-4학년 난이도 어휘",
|
|
1142
|
+
"4등급 : 초등학교 5-6학년 난이도 어휘",
|
|
1143
|
+
"등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어"
|
|
1144
|
+
],
|
|
1142
1145
|
"selectLabel": "선택",
|
|
1143
1146
|
"onlyHasImage": "이미지 있는 단어만",
|
|
1144
1147
|
"onlySameStructure": "음소 구조 같은 단어만"
|
|
@@ -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;
|