@ihabdevteam/core 0.318.0 → 0.322.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/dist/components.cjs.js +22 -22
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +188 -188
  7. package/dist/ihabdevteam-core.d.ts +144 -63
  8. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  9. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  10. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  11. package/dist/packages/core/src/components/PanelRow.js +2 -2
  12. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  13. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  14. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
  15. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  16. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
  17. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  18. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
  19. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
  20. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
  21. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
  22. package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
  23. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
  25. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -4
  26. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
  27. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
  28. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
  29. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
  31. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
  32. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
  33. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  34. package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
  35. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +60 -10
  36. package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
  37. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
  38. package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
  39. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  40. package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
  41. package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
  42. package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
  43. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  44. package/dist/packages/core/src/hooks/inputModality.js +1 -1
  45. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  46. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  47. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  48. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  49. package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
  50. package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
  51. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
  52. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +8 -4
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +44 -7
  55. package/dist/packages/core/src/i18n/locales/en.json +8 -5
  56. package/dist/packages/core/src/i18n/locales/ko.json +8 -5
  57. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  58. package/dist/packages/core/src/utils/devWarn.js +5 -5
  59. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  60. package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
  61. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  62. package/dist/packages/core/src/utils/navScroll.js +4 -4
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
  64. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  65. package/dist/packages/core/src/utils/pressable.js +3 -3
  66. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  67. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  68. package/dist/packages/core/src/utils/textSort.d.ts +15 -5
  69. package/dist/packages/core/src/utils/textSort.js +19 -9
  70. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
  71. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
  72. package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +7 -3
  76. package/package.json +2 -1
@@ -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
- "difficultyInfo1": "1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘",
1138
- "difficultyInfo2": "2등급 : 초등학교 1-2학년 난이도 어휘",
1139
- "difficultyInfo3": "3등급 : 초등학교 3-4학년 난이도 어휘",
1140
- "difficultyInfo4": "4등급 : 초등학교 5-6학년 난이도 어휘",
1141
- "difficultyInfo5": "등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어",
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": "음소 구조 같은 단어만"
@@ -1,5 +1,5 @@
1
1
  export declare const isDev: boolean;
2
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
- export declare function devWarnOnce(키: string, ...말: unknown[]): void;
2
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
+ export declare function devWarnOnce(key: string, ...args: unknown[]): void;
4
4
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
5
5
  export declare function __resetDevWarnOnce(): void;
@@ -27,12 +27,12 @@ export const isDev = (() => {
27
27
  return false;
28
28
  }
29
29
  })();
30
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
- export function devWarnOnce(키, ...말) {
32
- if (!isDev || 찍은것.has(키))
30
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
+ export function devWarnOnce(key, ...args) {
32
+ if (!isDev || 찍은것.has(key))
33
33
  return;
34
- 찍은것.add(키);
35
- console.warn(...말);
34
+ 찍은것.add(key);
35
+ console.warn(...args);
36
36
  }
37
37
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
38
38
  export function __resetDevWarnOnce() {
@@ -11,4 +11,4 @@ import type { FilterPanelSection } from '../types/filterPanel';
11
11
  * 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
12
12
  * 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
13
13
  */
14
- export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined, 전체말: string): string;
14
+ export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined, allLabel: string): string;
@@ -11,13 +11,13 @@ import { FILTER_ALL_VALUE } from '../types/filterPanel';
11
11
  * 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
12
12
  * 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
13
13
  */
14
- export function buildFilterSummary(sections, selected, 전체말) {
14
+ export function buildFilterSummary(sections, selected, allLabel) {
15
15
  const 조각들 = [];
16
16
  for (const section of sections ?? []) {
17
17
  if (section.type === 'note' || section.type === 'input')
18
18
  continue;
19
19
  const 이름 = section.displayLabel ?? section.label;
20
- const 값 = 값말(section, selected?.[section.label], 전체말);
20
+ const 값 = 값말(section, selected?.[section.label], allLabel);
21
21
  if (값 === null)
22
22
  continue;
23
23
  조각들.push(`${이름} ${값}`);
@@ -25,12 +25,12 @@ export function buildFilterSummary(sections, selected, 전체말) {
25
25
  return 조각들.join(' · ');
26
26
  }
27
27
  /** 한 절이 무엇으로 정해져 있는지 — 말할 것이 없으면 `null` */
28
- function 값말(section, sel, 전체말) {
28
+ function 값말(section, sel, allLabel) {
29
29
  const 고른것 = Array.isArray(sel) ? sel : sel === undefined ? [] : [sel];
30
30
  /* '전체' 는 두 꼴로 온다 — 아무것도 안 고른 것과 '전체' 보기를 고른 것. 같은 말로 읽는다. */
31
31
  const 실제 = 고른것.filter(v => v !== FILTER_ALL_VALUE);
32
32
  if (실제.length === 0) {
33
- return section.allOption ? (section.allLabel ?? 전체말) : null;
33
+ return section.allOption ? (section.allLabel ?? allLabel) : null;
34
34
  }
35
35
  return 실제.map(v => 보기말(section, v)).join(', ');
36
36
  }
@@ -1,6 +1,6 @@
1
1
  import type { RefObject } from 'react';
2
2
  /** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
3
- export declare const 기본통고르개 = ".content-area";
3
+ export declare const DEFAULT_SCROLL_SELECTOR = ".content-area";
4
4
  /**
5
5
  * 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
6
6
  *
@@ -8,4 +8,4 @@ export declare const 기본통고르개 = ".content-area";
8
8
  * 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
9
9
  * 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
10
10
  */
11
- export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>, 어디서?: string): void;
11
+ export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>, from?: string): void;
@@ -1,6 +1,6 @@
1
1
  import { devWarnOnce } from './devWarn';
2
2
  /** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
3
- export const 기본통고르개 = '.content-area';
3
+ export const DEFAULT_SCROLL_SELECTOR = '.content-area';
4
4
  /**
5
5
  * 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
6
6
  *
@@ -8,18 +8,18 @@ export const 기본통고르개 = '.content-area';
8
8
  * 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
9
9
  * 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
10
10
  */
11
- export function scrollNavTargetToTop(scrollContainer, 어디서 = 'Nav') {
11
+ export function scrollNavTargetToTop(scrollContainer, from = 'Nav') {
12
12
  try {
13
13
  const el = typeof scrollContainer === 'string'
14
14
  ? document.querySelector(scrollContainer)
15
- : scrollContainer?.current ?? document.querySelector(기본통고르개);
15
+ : scrollContainer?.current ?? document.querySelector(DEFAULT_SCROLL_SELECTOR);
16
16
  if (el) {
17
17
  el.scrollTop = 0;
18
18
  return;
19
19
  }
20
20
  const 문서 = document.scrollingElement ?? document.documentElement;
21
21
  if (문서 && 문서.scrollHeight <= 문서.clientHeight) {
22
- devWarnOnce('nav-scroll-container', `[${어디서}] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${기본통고르개}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
22
+ devWarnOnce('nav-scroll-container', `[${from}] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${DEFAULT_SCROLL_SELECTOR}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
23
23
  }
24
24
  window.scrollTo({ top: 0 });
25
25
  }
@@ -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 && !grades.some(g => word.grade === (wordAttributeValueCodeOf('grade', g) ?? g.replace(/\D/g, ''))))
159
- return false;
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 => {
@@ -17,7 +17,7 @@
17
17
  export declare function pressable<E extends {
18
18
  key: string;
19
19
  preventDefault: () => void;
20
- }>(onPress?: (e: E) => void, 옵션?: {
20
+ }>(onPress?: (e: E) => void, options?: {
21
21
  role?: 'button' | 'option';
22
22
  selected?: boolean;
23
23
  }): {};
@@ -14,13 +14,13 @@
14
14
  * 진짜 `<button>` 을 쓸 수 있으면 그쪽이 낫다. 이건 이미 `<div>`·`<li>` 로 짜인
15
15
  * 자리를 **모양을 안 바꾸고** 눌리게 만드는 길이다.
16
16
  */
17
- export function pressable(onPress, 옵션) {
17
+ export function pressable(onPress, options) {
18
18
  if (!onPress)
19
19
  return {};
20
20
  return {
21
- role: 옵션?.role ?? 'button',
21
+ role: options?.role ?? 'button',
22
22
  tabIndex: 0,
23
- ...(옵션?.selected === undefined ? {} : { 'aria-selected': 옵션.selected }),
23
+ ...(options?.selected === undefined ? {} : { 'aria-selected': options.selected }),
24
24
  onKeyDown: (e) => {
25
25
  /* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
26
26
  전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
@@ -20,17 +20,17 @@ export interface TabSessionLike {
20
20
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
21
21
  *
22
22
  * ```ts
23
- * onAuthStateChange((event, 받은것) => {
23
+ * onAuthStateChange((event, incoming) => {
24
24
  * getSession().then(({ data }) => {
25
- * const 이탭것 = data.session
26
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
27
- * …이탭것 으로 상태를 맞춘다
25
+ * const thisTab = data.session
26
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
27
+ * …thisTab 으로 상태를 맞춘다
28
28
  * })
29
29
  * })
30
30
  * ```
31
31
  *
32
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
33
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
32
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
33
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
34
34
  * @public
35
35
  */
36
- export declare function isSessionForThisTab(받은것: TabSessionLike | null | undefined, 이탭것: TabSessionLike | null | undefined): boolean;
36
+ export declare function isSessionForThisTab(incoming: TabSessionLike | null | undefined, thisTab: TabSessionLike | null | undefined): boolean;
@@ -16,21 +16,21 @@
16
16
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
17
17
  *
18
18
  * ```ts
19
- * onAuthStateChange((event, 받은것) => {
19
+ * onAuthStateChange((event, incoming) => {
20
20
  * getSession().then(({ data }) => {
21
- * const 이탭것 = data.session
22
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
23
- * …이탭것 으로 상태를 맞춘다
21
+ * const thisTab = data.session
22
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
23
+ * …thisTab 으로 상태를 맞춘다
24
24
  * })
25
25
  * })
26
26
  * ```
27
27
  *
28
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
29
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
28
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
29
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
30
30
  * @public
31
31
  */
32
- export function isSessionForThisTab(받은것, 이탭것) {
33
- if (!받은것)
32
+ export function isSessionForThisTab(incoming, thisTab) {
33
+ if (!incoming)
34
34
  return true;
35
- return !!이탭것 && 이탭것.access_token === 받은것.access_token;
35
+ return !!thisTab && thisTab.access_token === incoming.access_token;
36
36
  }
@@ -1,5 +1,5 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
- export declare function 한글순(a: string, b: string): number;
2
+ export declare function compareKorean(a: string, b: string): number;
3
3
  /**
4
4
  * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
5
5
  *
@@ -13,10 +13,20 @@ export declare function 한글순(a: string, b: string): number;
13
13
  * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
14
14
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
15
15
  */
16
- export declare function 칸순(a: unknown, b: unknown): number;
16
+ export declare function compareCell(a: unknown, b: unknown): number;
17
17
  /**
18
- * 표 정렬 한 번의 비교 — 칸순에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
19
- * 방향을 `-칸순` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
18
+ * @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
19
+ *
20
+ * 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
21
+ * `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
22
+ * 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
23
+ *
24
+ * 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
25
+ */
26
+ export declare const 칸순: typeof compareCell;
27
+ /**
28
+ * 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
29
+ * 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
20
30
  * ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
21
31
  */
22
- export declare function 칸순빈값뒤(a: unknown, b: unknown, asc: boolean): number;
32
+ export declare function compareCellBlankLast(a: unknown, b: unknown, asc: boolean): number;
@@ -19,7 +19,7 @@ const 콜레이터 = (() => {
19
19
  };
20
20
  })();
21
21
  /** 글자 둘을 한국어 차례로 견준다 */
22
- export function 한글순(a, b) {
22
+ export function compareKorean(a, b) {
23
23
  return 콜레이터().compare(a, b);
24
24
  }
25
25
  const 조각열쇠 = new Map();
@@ -77,7 +77,7 @@ function 전화열쇠(s) {
77
77
  * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
78
78
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
79
79
  */
80
- export function 칸순(a, b) {
80
+ export function compareCell(a, b) {
81
81
  const 글 = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
82
82
  const sa = 글(a), sb = 글(b);
83
83
  if (sa === sb)
@@ -102,25 +102,35 @@ export function 칸순(a, b) {
102
102
  return -1;
103
103
  if (typeof y === 'number')
104
104
  return 1;
105
- const c = 한글순(x, y);
105
+ const c = compareKorean(x, y);
106
106
  if (c !== 0)
107
107
  return c;
108
108
  }
109
109
  if (ka.length !== kb.length)
110
110
  return ka.length - kb.length;
111
- return 한글순(sa, sb);
111
+ return compareKorean(sa, sb);
112
112
  }
113
- /* 빈 값 — null·undefined·NaN·빈 글자(빈칸만 있는 글자 포함). 칸순이 '' 로 보는 것과 같은 범위. */
113
+ /**
114
+ * @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
115
+ *
116
+ * 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
117
+ * `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
118
+ * 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
119
+ *
120
+ * 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
121
+ */
122
+ export const 칸순 = compareCell;
123
+ /* 빈 값 — null·undefined·NaN·빈 글자(빈칸만 있는 글자 포함). compareCell이 '' 로 보는 것과 같은 범위. */
114
124
  const 빈칸값 = (v) => v == null || (typeof v === 'number' && Number.isNaN(v)) || (typeof v === 'string' && v.trim() === '');
115
125
  /**
116
- * 표 정렬 한 번의 비교 — 칸순에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
117
- * 방향을 `-칸순` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
126
+ * 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
127
+ * 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
118
128
  * ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
119
129
  */
120
- export function 칸순빈값뒤(a, b, asc) {
130
+ export function compareCellBlankLast(a, b, asc) {
121
131
  const ea = 빈칸값(a), eb = 빈칸값(b);
122
132
  if (ea || eb)
123
133
  return ea === eb ? 0 : ea ? 1 : -1;
124
- const c = 칸순(a, b);
134
+ const c = compareCell(a, b);
125
135
  return asc ? c : -c;
126
136
  }
@@ -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
+ * `isBlankGrade(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 isBlankGrade(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
+ * `isBlankGrade(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 isBlankGrade(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 (isBlankGrade(grade))
59
+ return set.has(GRADE_NONE);
60
+ return set.has(String(grade));
61
+ }
14
62
  const 섹션들 = ['grade', 'syllables', 'pos', 'wordType'];
15
- const 등급값 = ['1', '2', '3', '4'];
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: Object.fromEntries(등급값.map((n) => [n, t('wordSearch.filter.grade', { n: Number(n), defaultValue: `${n}등급` })])),
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 && (!w.grade || !gradeSet.has(w.grade)))
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 && (!w.grade || !gradeSet.has(w.grade)))
312
+ if (!matchesGradeCodes(gradeSet, w.grade))
312
313
  return false;
313
314
  if (useAllCandidates)
314
315
  return true;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.318.0";
49
+ export declare const CORE_VERSION = "0.322.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.318.0';
49
+ export const CORE_VERSION = '0.322.0';