@ihabdevteam/core 0.150.0 → 0.152.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 (218) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/components.cjs.js +199 -186
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +5 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +200 -187
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +405 -67
  10. package/dist/packages/core/src/components/Accordion.d.ts +6 -0
  11. package/dist/packages/core/src/components/Accordion.js +5 -2
  12. package/dist/packages/core/src/components/Alert.d.ts +7 -1
  13. package/dist/packages/core/src/components/Alert.js +12 -3
  14. package/dist/packages/core/src/components/BarChart.js +7 -2
  15. package/dist/packages/core/src/components/Board.d.ts +5 -2
  16. package/dist/packages/core/src/components/Board.js +9 -3
  17. package/dist/packages/core/src/components/BottomNav.js +5 -4
  18. package/dist/packages/core/src/components/Breadcrumb.css +9 -0
  19. package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
  20. package/dist/packages/core/src/components/Breadcrumb.js +8 -2
  21. package/dist/packages/core/src/components/BulkBar.css +2 -0
  22. package/dist/packages/core/src/components/BulkBar.js +21 -2
  23. package/dist/packages/core/src/components/Button.d.ts +12 -2
  24. package/dist/packages/core/src/components/Button.js +70 -13
  25. package/dist/packages/core/src/components/Calendar.d.ts +6 -1
  26. package/dist/packages/core/src/components/Calendar.js +3 -3
  27. package/dist/packages/core/src/components/ChartSection.css +11 -4
  28. package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
  29. package/dist/packages/core/src/components/DatePicker.js +25 -15
  30. package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
  31. package/dist/packages/core/src/components/Dropdown.js +7 -5
  32. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
  33. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
  34. package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
  35. package/dist/packages/core/src/components/EmptyState.js +4 -1
  36. package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
  37. package/dist/packages/core/src/components/ErrorState.js +3 -1
  38. package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
  39. package/dist/packages/core/src/components/FileUpload.js +8 -2
  40. package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
  41. package/dist/packages/core/src/components/Header.js +8 -5
  42. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
  43. package/dist/packages/core/src/components/Icon.d.ts +2 -1
  44. package/dist/packages/core/src/components/Icon.js +4 -1
  45. package/dist/packages/core/src/components/Loader.css +28 -0
  46. package/dist/packages/core/src/components/Loader.d.ts +5 -1
  47. package/dist/packages/core/src/components/Loader.js +4 -2
  48. package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
  49. package/dist/packages/core/src/components/NoticeBox.js +15 -6
  50. package/dist/packages/core/src/components/PanelRow.js +2 -1
  51. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
  52. package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
  53. package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
  54. package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
  55. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
  56. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
  57. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
  58. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  59. package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
  60. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
  61. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  62. package/dist/packages/core/src/components/Popup/Popup.js +6 -3
  63. package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
  64. package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
  65. package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
  66. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
  67. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
  68. package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
  69. package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
  70. package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
  71. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
  72. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
  73. package/dist/packages/core/src/components/Rating.d.ts +8 -2
  74. package/dist/packages/core/src/components/Rating.js +22 -8
  75. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
  76. package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
  77. package/dist/packages/core/src/components/RingChart.d.ts +1 -1
  78. package/dist/packages/core/src/components/RingChart.js +1 -1
  79. package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
  80. package/dist/packages/core/src/components/SegmentedControl.js +9 -1
  81. package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
  82. package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
  83. package/dist/packages/core/src/components/SideNav.js +8 -2
  84. package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
  85. package/dist/packages/core/src/components/SummaryStrip.js +2 -2
  86. package/dist/packages/core/src/components/Table.d.ts +1 -1
  87. package/dist/packages/core/src/components/Table.js +4 -1
  88. package/dist/packages/core/src/components/Tabs.d.ts +1 -0
  89. package/dist/packages/core/src/components/Tabs.js +4 -1
  90. package/dist/packages/core/src/components/TextField.css +5 -6
  91. package/dist/packages/core/src/components/TextField.js +46 -3
  92. package/dist/packages/core/src/components/Toast.js +16 -2
  93. package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
  94. package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
  95. package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
  96. package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
  97. package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
  98. package/dist/packages/core/src/components/TopBanner.js +66 -8
  99. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
  100. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
  101. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
  102. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
  103. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
  104. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  105. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
  106. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
  107. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
  108. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
  109. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +25 -9
  110. package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
  111. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
  112. package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
  113. package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
  114. package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
  115. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
  116. package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
  117. package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
  118. package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
  119. package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
  120. package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
  121. package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
  122. package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
  123. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  124. package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
  125. package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
  126. package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
  127. package/dist/packages/core/src/components/UpdateAlert.css +4 -2
  128. package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
  129. package/dist/packages/core/src/components/UpdateAlert.js +21 -1
  130. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
  131. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
  132. package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
  133. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
  134. package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
  135. package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
  136. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
  137. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
  138. package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
  139. package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
  140. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
  141. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
  142. package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
  143. package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
  144. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
  145. package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
  146. package/dist/packages/core/src/components/WordTable.css +10 -0
  147. package/dist/packages/core/src/components/WordTable.js +39 -26
  148. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
  149. package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
  150. package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
  151. package/dist/packages/core/src/components/updateAlertStack.js +68 -0
  152. package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
  153. package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
  154. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
  155. package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
  156. package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
  157. package/dist/packages/core/src/components/wordTableLayout.js +6 -0
  158. package/dist/packages/core/src/components.d.ts +2 -1
  159. package/dist/packages/core/src/components.js +1 -1
  160. package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
  161. package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
  162. package/dist/packages/core/src/hooks/focusReturn.js +49 -0
  163. package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
  164. package/dist/packages/core/src/hooks/touchToggle.js +114 -0
  165. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
  166. package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
  167. package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
  168. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
  169. package/dist/packages/core/src/hooks/useIcon.js +29 -5
  170. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
  171. package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
  172. package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
  173. package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
  174. package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
  175. package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
  176. package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
  177. package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
  178. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
  179. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
  180. package/dist/packages/core/src/i18n/index.js +9 -1
  181. package/dist/packages/core/src/i18n/locales/en.json +24 -2
  182. package/dist/packages/core/src/i18n/locales/ko.json +24 -2
  183. package/dist/packages/core/src/tokens.css +5 -0
  184. package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
  185. package/dist/packages/core/src/utils/csv.d.ts +7 -2
  186. package/dist/packages/core/src/utils/csv.js +12 -5
  187. package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
  188. package/dist/packages/core/src/utils/date.d.ts +8 -0
  189. package/dist/packages/core/src/utils/date.js +44 -10
  190. package/dist/packages/core/src/utils/format.d.ts +1 -1
  191. package/dist/packages/core/src/utils/format.js +6 -7
  192. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
  193. package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
  194. package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
  195. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
  196. package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
  197. package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
  198. package/dist/packages/core/src/utils/safeHref.js +24 -0
  199. package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
  200. package/dist/packages/core/src/utils/savedWordTables.js +27 -19
  201. package/dist/packages/core/src/utils/textSort.d.ts +9 -0
  202. package/dist/packages/core/src/utils/textSort.js +37 -1
  203. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
  204. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
  205. package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
  206. package/dist/packages/core/src/utils/wordContrast.js +8 -2
  207. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
  208. package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
  209. package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
  210. package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
  211. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
  212. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
  213. package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
  214. package/dist/packages/core/src/utils/wornDate.js +15 -0
  215. package/dist/packages/core/src/version.d.ts +1 -1
  216. package/dist/packages/core/src/version.js +1 -1
  217. package/dist/utils.cjs.js +2 -2
  218. package/package.json +1 -1
@@ -2,7 +2,8 @@
2
2
  * 소음설정 영속 저장소 — TTS/Storage 리졸버, 훈련 로거와 동일한 전역 주입 패턴.
3
3
  *
4
4
  * 앱(src)에서 configureNoiseSettingsStore()로 Supabase load/save 콜백을 등록하면,
5
- * TrainingPagePopup이 마운트 시 load()로 초기화하고 변경 시 save()로 기록한다.
5
+ * TrainingPagePopup이 마운트 시 load()로 초기화하고 변경 시 save()로 기록한다 — 판 중 변경은
6
+ * 멈춘 뒤 300ms 에 마지막 값만, 판을 떠날 때·pagehide·탭 숨김에는 남은 값을 곧바로.
6
7
  * core는 사용자/고객 식별자를 모른다 — 본인(profiles) vs 고객(managed_user) 분기는
7
8
  * 앱이 주입한 콜백 내부에서 현재 훈련 대상에 따라 결정한다.
8
9
  */
@@ -11,11 +12,14 @@ import type { CoreNoiseSettings } from '../types/training';
11
12
  export interface NoiseSettingsStore {
12
13
  /** 현재 훈련 대상(본인 또는 고객)의 저장된 소음설정. 없으면 null. */
13
14
  load: () => Promise<Partial<CoreNoiseSettings> | null>;
14
- /** 현재 훈련 대상의 소음설정을 저장(fire-and-forget). */
15
+ /**
16
+ * 현재 훈련 대상의 소음설정을 저장(fire-and-forget).
17
+ * 훈련 팝업은 판 중 변경(슬라이더 끌기 등)을 **멈춘 뒤 300ms 에 마지막 값만** 넘기고, 떠날 때·pagehide 에 남은 값을 즉시 넘긴다.
18
+ */
15
19
  save: (settings: CoreNoiseSettings) => Promise<void> | void;
16
20
  /**
17
21
  * **판에 실제로 걸린 설정**이 정해지거나 바뀔 때마다 부른다 — 저장 병합 직후,
18
- * `noiseOverride` 적용 직후, 판 중 변경마다. 선택.
22
+ * `noiseOverride` 적용 직후, 판 중 변경마다(저장과 달리 기다리지 않는다). 선택.
19
23
  *
20
24
  * `save` 와 따로 두는 까닭: `noiseOverride` 가 걸린 판은 저장을 건너뛴다. 앱이 결과마다
21
25
  * 그 판의 소음 조건을 함께 기록한다면 `save` 로는 그 값을 못 받는다 — 저장값으로
@@ -4,6 +4,15 @@ export declare const VOWEL_TO_PHONEME: Record<string, {
4
4
  glide?: string;
5
5
  nucleus: string;
6
6
  }>;
7
+ /**
8
+ * @internal 두 모음(중성 자모)의 활음이 갈리는가 — '음소 구조 같은 단어만' 의 모음 쪽 정의, **대립쌍 표와 단어훈련 런타임이 함께 쓴다.**
9
+ *
10
+ * 활음(j·w·ɰ)은 초성 ㅇ·받침처럼 음절의 자리 하나다. ㅏ↔ㅘ(w+ㅏ) 는 모음을 바꾼 게 아니라 자리를 하나 채운 것이라 받침 없음↔있음과 같은
11
+ * 구조 차이로 본다. 활음 종류가 다른 것(ㅑ↔ㅘ = j↔w)도 표가 구조 차이로 보아 왔으므로 같게 둔다. 활음이 같으면(ㅏ↔ㅓ, ㅑ↔ㅕ) 모음 대립.
12
+ * 전에는 표(phonemePairSearch.diffHasEmpty)만 이것을 보고 런타임(wordContrast.hasStructureContrast)은 초성 ㅇ·받침만 봐,
13
+ * 같은 '음소 구조 같은 단어만' 인데 표에서 빠진 사↔솨 가 훈련 오답으로 나왔다.
14
+ */
15
+ export declare function vowelGlidesDiffer(a: string, b: string): boolean;
7
16
  /** 종성 글자 → 음소(대표음) 변환. 매핑에 없으면 원본 유지. */
8
17
  export declare function finalToPhoneme(jong: string): string;
9
18
  export declare function convertToPhoneme(sel: Record<string, string>): Record<string, string>;
@@ -29,6 +29,17 @@ export const VOWEL_TO_PHONEME = {
29
29
  ㅟ: { glide: 'w', nucleus: 'ㅣ' },
30
30
  ㅢ: { glide: 'ɰ', nucleus: 'ㅣ' },
31
31
  };
32
+ /**
33
+ * @internal 두 모음(중성 자모)의 활음이 갈리는가 — '음소 구조 같은 단어만' 의 모음 쪽 정의, **대립쌍 표와 단어훈련 런타임이 함께 쓴다.**
34
+ *
35
+ * 활음(j·w·ɰ)은 초성 ㅇ·받침처럼 음절의 자리 하나다. ㅏ↔ㅘ(w+ㅏ) 는 모음을 바꾼 게 아니라 자리를 하나 채운 것이라 받침 없음↔있음과 같은
36
+ * 구조 차이로 본다. 활음 종류가 다른 것(ㅑ↔ㅘ = j↔w)도 표가 구조 차이로 보아 왔으므로 같게 둔다. 활음이 같으면(ㅏ↔ㅓ, ㅑ↔ㅕ) 모음 대립.
37
+ * 전에는 표(phonemePairSearch.diffHasEmpty)만 이것을 보고 런타임(wordContrast.hasStructureContrast)은 초성 ㅇ·받침만 봐,
38
+ * 같은 '음소 구조 같은 단어만' 인데 표에서 빠진 사↔솨 가 훈련 오답으로 나왔다.
39
+ */
40
+ export function vowelGlidesDiffer(a, b) {
41
+ return (VOWEL_TO_PHONEME[a]?.glide ?? '') !== (VOWEL_TO_PHONEME[b]?.glide ?? '');
42
+ }
32
43
  /** 종성 글자 → 음소(대표음) 변환. 매핑에 없으면 원본 유지. */
33
44
  export function finalToPhoneme(jong) {
34
45
  return JONGSEONG_TO_PHONEME[jong] ?? jong;
@@ -11,7 +11,13 @@ export interface FilterableWord {
11
11
  pos?: string;
12
12
  wordType?: string;
13
13
  }
14
- /** 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리). */
14
+ /**
15
+ * 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리).
16
+ *
17
+ * **섹션 열쇠·값을 어느 언어로 적었든 읽는다** — 넘긴 keys 의 라벨, 지금 언어·ko·en 라벨, 옛 라벨('난이도 등급'·'체언(명사,수사)'),
18
+ * 코드(`grade`·`noun`·`5+` …) 모두. 전에는 keys 의 라벨만 읽어, 한국어로 저장한 표 구성을 영어 화면에서 열면 등급·음절 필터가
19
+ * **풀리고** 품사·어종 값은 posMap 에 없어 **모든 단어가 떨어졌다**.
20
+ */
15
21
  export declare function matchesFilters(word: FilterableWord, filterSelected: Record<string, string[]>, keys?: PairFilterKeys): boolean;
16
22
  export declare function applyFilters(filterSelected: Record<string, string[]>, words: WordEntry[], keys?: PairFilterKeys): WordEntry[];
17
23
  export declare function searchPairs(selectedList: Record<string, string>[], words: WordEntry[], enforcePosition?: number, phonemic?: boolean): WordSearchResultItem[];
@@ -1,4 +1,4 @@
1
- import { VOWEL_TO_PHONEME } from './phonemeConvert';
1
+ import { VOWEL_TO_PHONEME, vowelGlidesDiffer } from './phonemeConvert';
2
2
  // ── 음절 분해 ──────────────────────────────────────────────
3
3
  const CHO = ['ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
4
4
  const JUNG = ['ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ', 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'];
@@ -91,8 +91,8 @@ function diffHasEmptyPair(section, vals) {
91
91
  // 구조상 '비움'으로 보는 경우만 true:
92
92
  // - 종성: 받침 없음('없음' / '')
93
93
  // - 초성: 무자음 ㅇ (글자 분해='ㅇ' / 발음 분해='없음')
94
- // 활음 '없음'은 제외한다 — 활음 유무(예: ㅐ↔ㅙ = 없음↔w)는 음절 '구조' 차이가 아니라
95
- // 정당한 '모음 대립'이므로(글자 모드도 ㅐ↔ㅙ를 모음 대립으로 인정) 걸러내면 안 된다.
94
+ // '활음' 섹션의 '없음'은 비움으로 보지 않는다 — 발음 모드에서 사용자가 활음 자리를 **직접 골라** 찾는 대립(ㅐ↔ㅙ = 없음↔w)이라
95
+ // 그 검색 결과를 이 필터가 통째로 지우면 안 된다.
96
96
  const isEmpty = (v) => v === '' ||
97
97
  (section === '종성' && v === '없음') ||
98
98
  (section === '초성' && (v === 'ㅇ' || v === '없음'));
@@ -101,13 +101,11 @@ function diffHasEmptyPair(section, vals) {
101
101
  // 모음(글자 모드) 활음 구조 차이: 두 모음의 반모음(활음)이 다르면 음절 '구조' 차이로 본다.
102
102
  // 예) ㅓ(단모음) ↔ ㅟ(w+ㅣ), ㅏ ↔ ㅘ(w+ㅏ), ㅐ ↔ ㅙ(w+ㅐ) → 구조 상이 → 제외.
103
103
  // 같은 활음끼리(ㅏ↔ㅓ 둘 다 없음, ㅑ↔ㅕ 둘 다 j)는 정당한 모음 대립 → 유지.
104
- // 발음 모드는 모음=핵모음이라 VOWEL_GLIDE에 없어(둘 다 '') 영향 없음. 활음 대립은
104
+ // 발음 모드는 모음=핵모음이라 둘 다 활음 없음으로 읽혀 영향 없음. 활음 대립은
105
105
  // 별도 '활음' 섹션에서 처리되므로 여기(모음)에서 걸러도 활음 지정 대립과 충돌하지 않는다.
106
- if (section === '모음') {
107
- const glideOf = (v) => VOWEL_GLIDE[v] ?? '';
108
- if (glideOf(vals[0]) !== glideOf(vals[1]))
109
- return true;
110
- }
106
+ // 단어훈련 런타임(wordContrast.hasStructureContrast)도 **같은 함수**로 판정한다.
107
+ if (section === '모음' && vowelGlidesDiffer(vals[0], vals[1]))
108
+ return true;
111
109
  return false;
112
110
  }
113
111
  // ── 필터 적용 ──────────────────────────────────────────────
@@ -117,6 +115,7 @@ const POS_GROUP_MAP_KO = {
117
115
  '용언(동사,형용사)': ['동', '형'],
118
116
  '수식언(부사,관형사)': ['부', '관'],
119
117
  };
118
+ import { POS_CODE_TARGETS, WORD_TYPE_CODE_VALUES, wordAttributeSectionOf, wordAttributeValueCodeOf } from './wordAttributeFilterCodes';
120
119
  const DEFAULT_PAIR_FILTER_KEYS = {
121
120
  difficulty: '난이도 등급',
122
121
  syllables: '음절 수',
@@ -126,30 +125,55 @@ const DEFAULT_PAIR_FILTER_KEYS = {
126
125
  posMap: POS_GROUP_MAP_KO,
127
126
  typeMap: WORD_TYPE_MAP_KO,
128
127
  };
129
- /** 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리). */
128
+ const 모은것 = new WeakMap();
129
+ function 모으기(filterSelected, keys) {
130
+ const 있음 = 모은것.get(filterSelected);
131
+ if (있음 && 있음.keys === keys)
132
+ return 있음.값;
133
+ const 값 = { grades: [], syls: [], poses: [], types: [] };
134
+ const 칸 = { grade: 값.grades, syllables: 값.syls, pos: 값.poses, wordType: 값.types };
135
+ for (const [label, raw] of Object.entries(filterSelected ?? {})) {
136
+ /* 섹션은 넘긴 keys 의 라벨이면 그것, 아니면 어느 언어·옛 라벨·코드든 읽는다(wordAttributeSectionOf) */
137
+ const s = label === keys.difficulty ? 'grade' : label === keys.syllables ? 'syllables'
138
+ : label === keys.partOfSpeech ? 'pos' : label === keys.wordType ? 'wordType'
139
+ : wordAttributeSectionOf(label);
140
+ if (!s)
141
+ continue;
142
+ for (const v of Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [])
143
+ if (!칸[s].includes(v))
144
+ 칸[s].push(v);
145
+ }
146
+ 모은것.set(filterSelected, { keys, 값 });
147
+ return 값;
148
+ }
149
+ /**
150
+ * 난이도/음절수/품사/유형 필터를 단어 하나에 대해 판정(의미범주 제외 — WordEntry 전용이라 applyFilters에서 별도 처리).
151
+ *
152
+ * **섹션 열쇠·값을 어느 언어로 적었든 읽는다** — 넘긴 keys 의 라벨, 지금 언어·ko·en 라벨, 옛 라벨('난이도 등급'·'체언(명사,수사)'),
153
+ * 코드(`grade`·`noun`·`5+` …) 모두. 전에는 keys 의 라벨만 읽어, 한국어로 저장한 표 구성을 영어 화면에서 열면 등급·음절 필터가
154
+ * **풀리고** 품사·어종 값은 posMap 에 없어 **모든 단어가 떨어졌다**.
155
+ */
130
156
  export function matchesFilters(word, filterSelected, keys = DEFAULT_PAIR_FILTER_KEYS) {
131
- const grades = filterSelected[keys.difficulty];
132
- if (grades?.length && !grades.some(g => word.grade === g.replace(/\D/g, '')))
157
+ const { grades, syls, poses, types } = 모으기(filterSelected, keys);
158
+ if (grades.length && !grades.some(g => word.grade === (wordAttributeValueCodeOf('grade', g) ?? g.replace(/\D/g, ''))))
133
159
  return false;
134
- const syls = filterSelected[keys.syllables];
135
- if (syls?.length) {
160
+ if (syls.length) {
136
161
  const len = word.syllables ?? 0;
137
162
  const match = syls.some(syl => {
138
- if (syl === keys.syllable5plus)
163
+ const code = syl === keys.syllable5plus ? '5+' : wordAttributeValueCodeOf('syllables', syl);
164
+ if (code === '5+')
139
165
  return len >= 5;
140
- return len === parseInt(syl);
166
+ return len === parseInt(code ?? syl);
141
167
  });
142
168
  if (!match)
143
169
  return false;
144
170
  }
145
- const poses = filterSelected[keys.partOfSpeech];
146
- if (poses?.length) {
147
- const targets = poses.flatMap(pos => keys.posMap[pos] ?? [pos]);
171
+ if (poses.length) {
172
+ const targets = poses.flatMap(pos => keys.posMap[pos] ?? POS_CODE_TARGETS[wordAttributeValueCodeOf('pos', pos) ?? ''] ?? [pos]);
148
173
  if (!targets.some(p => (word.pos ?? '').includes(p)))
149
174
  return false;
150
175
  }
151
- const types = filterSelected[keys.wordType];
152
- if (types?.length && !types.some(t => word.wordType === keys.typeMap[t]))
176
+ if (types.length && !types.some(t => word.wordType === (keys.typeMap[t] ?? WORD_TYPE_CODE_VALUES[wordAttributeValueCodeOf('wordType', t) ?? ''])))
153
177
  return false;
154
178
  return true;
155
179
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * 새 창으로 열어도 되는 주소인가(내부).
3
+ *
4
+ * SideNav·Header 는 메뉴 항목의 `href` 를 `window.open` 으로 연다. 항목이 서버·설정에서 오면 `javascript:alert(1)` 같은 주소가
5
+ * 그대로 열려 스크립트가 돌 수 있었다. **http(s)·상대 경로·mailto·tel** 만 연다.
6
+ * 판정은 공백·제어 문자를 걷은 뒤 스킴으로 한다 — 브라우저는 스킴 안의 탭·줄바꿈을 무시해 `java(탭)script:` 도 스크립트로 연다.
7
+ */
8
+ export declare function isSafeOpenHref(href: unknown): href is string;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 새 창으로 열어도 되는 주소인가(내부).
3
+ *
4
+ * SideNav·Header 는 메뉴 항목의 `href` 를 `window.open` 으로 연다. 항목이 서버·설정에서 오면 `javascript:alert(1)` 같은 주소가
5
+ * 그대로 열려 스크립트가 돌 수 있었다. **http(s)·상대 경로·mailto·tel** 만 연다.
6
+ * 판정은 공백·제어 문자를 걷은 뒤 스킴으로 한다 — 브라우저는 스킴 안의 탭·줄바꿈을 무시해 `java(탭)script:` 도 스크립트로 연다.
7
+ */
8
+ export function isSafeOpenHref(href) {
9
+ if (typeof href !== 'string')
10
+ return false;
11
+ let 걷은 = '';
12
+ for (const ch of href) {
13
+ const c = ch.charCodeAt(0);
14
+ if (c <= 0x20 || (c >= 0x7f && c <= 0x9f))
15
+ continue;
16
+ 걷은 += ch;
17
+ }
18
+ if (!걷은)
19
+ return false;
20
+ const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(걷은);
21
+ if (!m)
22
+ return true; // 스킴이 없다 — 상대 경로(`/help`·`./a`·`?q`·`#x`)·`//host` 는 지금 문서의 스킴을 따른다
23
+ return ['http', 'https', 'mailto', 'tel'].includes(m[1].toLowerCase());
24
+ }
@@ -9,10 +9,26 @@ export interface SavedWordTable {
9
9
  description?: string;
10
10
  /** 대립쌍표를 만든 검색 구성 — 저장 시 함께 남겨 수정 시 검색 패널을 복원한다. */
11
11
  searchConfig?: WordTableSearchConfig;
12
+ /** 복습표면 맨 처음 원본 표의 id — `reviewMeta` 가 준다. 있으면 `reviewTableName` 이 이름 파싱보다 먼저 쓴다. */
13
+ reviewOf?: string;
14
+ /** 몇 번째 복습인가(1부터, 0 = 복습표 아님) — `reviewMeta` 가 준다. 없으면 옛 표로 보고 이름을 파싱한다. */
15
+ reviewRound?: number;
12
16
  }
13
17
  export declare function getSavedTables(): SavedWordTable[];
14
- export declare function saveTable(name: string, items: WordSearchResultItem[], description?: string, searchConfig?: WordTableSearchConfig): SavedWordTable;
15
- /** 저장된 단어표를 갱신한다(수정 저장). 지정한 필드만 덮어쓴다. */
16
- export declare function updateSavedTable(id: string, patch: Partial<Pick<SavedWordTable, 'name' | 'items' | 'description' | 'searchConfig' | 'group'>>): void;
18
+ /**
19
+ * 새 표를 목록 맨 앞에 저장한다.
20
+ *
21
+ * @returns 저장한 표. **저장소에 적지 못하면 `null`** — 예외를 삼키고 만든 표를 그대로 돌려주던 때는
22
+ * 부르는 쪽이 '저장했습니다' 를 띄웠는데 목록에는 표가 없었다. `null` 이면 실패를 알려야 한다.
23
+ */
24
+ export declare function saveTable(name: string, items: WordSearchResultItem[], description?: string, searchConfig?: WordTableSearchConfig): SavedWordTable | null;
25
+ /**
26
+ * 저장된 단어표를 갱신한다(수정 저장). 지정한 필드만 덮어쓴다.
27
+ *
28
+ * @returns 저장소에 적었으면 `true`, **적지 못하면 `false`**(용량 초과·저장 거부) — 전에는 `void` 로 실패를 삼켜
29
+ * 수정이 저장된 것처럼 보였다. 없는 id 는 바꿀 것이 없어 목록을 그대로 다시 적고 `true`.
30
+ */
31
+ export declare function updateSavedTable(id: string, patch: Partial<Pick<SavedWordTable, 'name' | 'items' | 'description' | 'searchConfig' | 'group' | 'reviewOf' | 'reviewRound'>>): boolean;
17
32
  export declare function deleteTable(id: string): void;
33
+ /** 표를 복사해 목록 맨 앞에 둔다. 원본이 없거나 **저장소에 적지 못하면 `null`**. */
18
34
  export declare function duplicateTable(id: string): SavedWordTable | null;
@@ -20,6 +20,22 @@ export function getSavedTables() {
20
20
  return [];
21
21
  }
22
22
  }
23
+ /** 목록을 통째로 적는다. 저장소가 가득 찼거나(QuotaExceededError) 거부되면(사파리 프라이빗 모드) false. */
24
+ function 적기(tables) {
25
+ try {
26
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(tables));
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /**
34
+ * 새 표를 목록 맨 앞에 저장한다.
35
+ *
36
+ * @returns 저장한 표. **저장소에 적지 못하면 `null`** — 예외를 삼키고 만든 표를 그대로 돌려주던 때는
37
+ * 부르는 쪽이 '저장했습니다' 를 띄웠는데 목록에는 표가 없었다. `null` 이면 실패를 알려야 한다.
38
+ */
23
39
  export function saveTable(name, items, description, searchConfig) {
24
40
  const tables = getSavedTables();
25
41
  const newTable = {
@@ -30,27 +46,23 @@ export function saveTable(name, items, description, searchConfig) {
30
46
  description,
31
47
  searchConfig,
32
48
  };
33
- try {
34
- localStorage.setItem(STORAGE_KEY, JSON.stringify([newTable, ...tables]));
35
- }
36
- catch { /* noop */ }
37
- return newTable;
49
+ return 적기([newTable, ...tables]) ? newTable : null;
38
50
  }
39
- /** 저장된 단어표를 갱신한다(수정 저장). 지정한 필드만 덮어쓴다. */
51
+ /**
52
+ * 저장된 단어표를 갱신한다(수정 저장). 지정한 필드만 덮어쓴다.
53
+ *
54
+ * @returns 저장소에 적었으면 `true`, **적지 못하면 `false`**(용량 초과·저장 거부) — 전에는 `void` 로 실패를 삼켜
55
+ * 수정이 저장된 것처럼 보였다. 없는 id 는 바꿀 것이 없어 목록을 그대로 다시 적고 `true`.
56
+ */
40
57
  export function updateSavedTable(id, patch) {
41
58
  const tables = getSavedTables().map(t => t.id === id ? { ...t, ...patch } : t);
42
- try {
43
- localStorage.setItem(STORAGE_KEY, JSON.stringify(tables));
44
- }
45
- catch { /* noop */ }
59
+ return 적기(tables);
46
60
  }
47
61
  export function deleteTable(id) {
48
62
  const tables = getSavedTables().filter(t => t.id !== id);
49
- try {
50
- localStorage.setItem(STORAGE_KEY, JSON.stringify(tables));
51
- }
52
- catch { /* noop */ }
63
+ 적기(tables);
53
64
  }
65
+ /** 표를 복사해 목록 맨 앞에 둔다. 원본이 없거나 **저장소에 적지 못하면 `null`**. */
54
66
  export function duplicateTable(id) {
55
67
  const tables = getSavedTables();
56
68
  const original = tables.find(t => t.id === id);
@@ -63,9 +75,5 @@ export function duplicateTable(id) {
63
75
  name: `${original.name} (복사)`,
64
76
  savedAt: new Date().toISOString(),
65
77
  };
66
- try {
67
- localStorage.setItem(STORAGE_KEY, JSON.stringify([copy, ...tables]));
68
- }
69
- catch { /* noop */ }
70
- return copy;
78
+ return 적기([copy, ...tables]) ? copy : null;
71
79
  }
@@ -7,7 +7,16 @@ export declare function 한글순(a: string, b: string): number;
7
7
  * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
8
8
  * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
9
9
  *
10
+ * 전화번호 꼴(숫자·하이픈·빈칸만, 숫자 9–11자리, 0 으로 시작 — 1 로 시작하면 하이픈·빈칸이 있을 때만)은 숫자만 뽑아 글자로 견준다 —
11
+ * `010-1234-5678` 이 `02-123-4567` 앞, `01012345678` 과 `010-1234-5678` 이 같은 규칙.
12
+ *
10
13
  * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
11
14
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
15
  */
13
16
  export declare function 칸순(a: unknown, b: unknown): number;
17
+ /**
18
+ * 표 정렬 한 번의 비교 — 칸순에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
19
+ * 방향을 `-칸순` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
20
+ * ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
21
+ */
22
+ export declare function 칸순빈값뒤(a: unknown, b: unknown, asc: boolean): number;
@@ -50,6 +50,20 @@ function 조각내기(s) {
50
50
  조각열쇠.set(s, out);
51
51
  return out;
52
52
  }
53
+ /* **전화번호 꼴**(숫자·하이픈·빈칸만, 숫자 9–11자리, 0 으로 시작 — 1 로 시작하면 하이픈·빈칸이 있을 때만)은 숫자만 뽑은 **글자 조각 하나**가 열쇠다.
54
+ 조각으로 쪼개 수로 읽으면 '02-123-4567'(2) 이 '010-1234-5678'(10) 앞에, 하이픈 없는 '01012345678' 은 수 하나라
55
+ '010-…' 뒤에 서서, 같은 번호가 하이픈 유무로 표의 다른 자리로 갔다.
56
+ 두 값이 모두 전화 꼴이면 숫자 글자끼리 견주는 셈이 된다. 한쪽만 전화 꼴일 때도 **값마다 열쇠가 하나로 정해져** 있어야
57
+ 차례가 돌지 않는다(값 쌍마다 규칙을 고르면 A<B<C<A 가 생긴다) — 글자 조각이라 수 조각 뒤, 다른 글자와는 한국어 차례. */
58
+ function 전화열쇠(s) {
59
+ if (!/^[\d\s-]+$/.test(s))
60
+ return null;
61
+ const 숫자 = s.replace(/\D/g, '');
62
+ if (숫자.length < 9 || 숫자.length > 11)
63
+ return null;
64
+ // 0 으로 시작하면 전화. 1 로 시작하면 하이픈·빈칸이 있을 때만 — 하이픈 없는 '1234567890' 같은 번호 ID 는 수로 둔다
65
+ return 숫자[0] === '0' || (숫자[0] === '1' && s !== 숫자) ? 숫자 : null;
66
+ }
53
67
  /**
54
68
  * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
55
69
  *
@@ -57,6 +71,9 @@ function 조각내기(s) {
57
71
  * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
58
72
  * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
59
73
  *
74
+ * 전화번호 꼴(숫자·하이픈·빈칸만, 숫자 9–11자리, 0 으로 시작 — 1 로 시작하면 하이픈·빈칸이 있을 때만)은 숫자만 뽑아 글자로 견준다 —
75
+ * `010-1234-5678` 이 `02-123-4567` 앞, `01012345678` 과 `010-1234-5678` 이 같은 규칙.
76
+ *
60
77
  * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
61
78
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
62
79
  */
@@ -66,7 +83,12 @@ export function 칸순(a, b) {
66
83
  if (sa === sb)
67
84
  return 0;
68
85
  /* 수(number)는 글로 바꿔 쪼개지 않고 **수 조각 하나** 로 — '1e+21'·'1e-7' 로 쪼개면 1e21 이 5 앞에, -Infinity 가 끝에 섰다 */
69
- const 열쇠 = (v, s) => (typeof v === 'number' && !Number.isNaN(v) ? [v] : 조각내기(s));
86
+ const 열쇠 = (v, s) => {
87
+ if (typeof v === 'number' && !Number.isNaN(v))
88
+ return [v];
89
+ const 전화 = 전화열쇠(s);
90
+ return 전화 !== null ? [전화] : 조각내기(s);
91
+ };
70
92
  const ka = 열쇠(a, sa), kb = 열쇠(b, sb);
71
93
  const n = Math.min(ka.length, kb.length);
72
94
  for (let i = 0; i < n; i++) {
@@ -88,3 +110,17 @@ export function 칸순(a, b) {
88
110
  return ka.length - kb.length;
89
111
  return 한글순(sa, sb);
90
112
  }
113
+ /* 빈 값 — null·undefined·NaN·빈 글자(빈칸만 있는 글자 포함). 칸순이 '' 로 보는 것과 같은 범위. */
114
+ const 빈칸값 = (v) => v == null || (typeof v === 'number' && Number.isNaN(v)) || (typeof v === 'string' && v.trim() === '');
115
+ /**
116
+ * 표 정렬 한 번의 비교 — 칸순에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
117
+ * 방향을 `-칸순` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
118
+ * ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
119
+ */
120
+ export function 칸순빈값뒤(a, b, asc) {
121
+ const ea = 빈칸값(a), eb = 빈칸값(b);
122
+ if (ea || eb)
123
+ return ea === eb ? 0 : ea ? 1 : -1;
124
+ const c = 칸순(a, b);
125
+ return asc ? c : -c;
126
+ }
@@ -0,0 +1,31 @@
1
+ /** @public 공용 속성 필터 섹션 코드 */
2
+ export type WordAttributeSectionCode = 'grade' | 'syllables' | 'pos' | 'wordType';
3
+ /**
4
+ * @public 공용 속성 필터를 **언어와 무관한 코드**로 담은 모양 — 저장(DB config·로컬 저장소)용.
5
+ * 예: `{ grade: ['1', '2'], syllables: ['5+'], pos: ['noun'], wordType: ['sino'] }`
6
+ */
7
+ export type WordAttributeFilterCodes = Partial<Record<WordAttributeSectionCode, string[]>>;
8
+ /** 품사 코드 → 사전 품사 약자(단어의 pos 에 들어 있는지 본다) · 어종 코드 → 사전 어종 값 */
9
+ export declare const POS_CODE_TARGETS: Record<string, string[]>;
10
+ export declare const WORD_TYPE_CODE_VALUES: Record<string, string>;
11
+ /** 섹션 라벨(어느 언어든·옛 라벨·코드) → 섹션 코드. 속성 섹션이 아니면 null. */
12
+ export declare function wordAttributeSectionOf(label: string): WordAttributeSectionCode | null;
13
+ /** 한 섹션의 값(코드·어느 언어 라벨·옛 라벨) → 값 코드. 못 읽으면 null. */
14
+ export declare function wordAttributeValueCodeOf(section: WordAttributeSectionCode, value: string): string | null;
15
+ /**
16
+ * @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
17
+ * 속성 섹션(등급·음절 수·품사·어종)만 담고, 그 밖의 섹션(초성·선택·단어 입력 등)은 뺀다. 못 읽는 값은 버린다.
18
+ */
19
+ export declare function toWordAttributeFilterCodes(selected: Record<string, unknown> | null | undefined): WordAttributeFilterCodes;
20
+ /**
21
+ * @public 코드 모양 → 지정한 언어(기본: 지금 i18n 언어)의 **라벨 filterSelected**. FilterPanel 의 selected 로 그대로 쓴다.
22
+ */
23
+ export declare function wordAttributeFilterFromCodes(codes: WordAttributeFilterCodes | null | undefined, lng?: string): Record<string, string[]>;
24
+ /**
25
+ * @public filterSelected 의 속성 섹션을 **지정한 언어(기본: 지금 언어)의 라벨**로 다시 적는다 — 다른 언어·옛 라벨로 저장된 구성을
26
+ * 열 때, 언어가 바뀐 뒤 상태를 맞출 때 쓴다(칩이 꺼진 채로 보이던 것). 속성 밖의 섹션은 그대로 둔다. 같은 섹션이 두 언어로
27
+ * 들어 있으면 합친다.
28
+ */
29
+ export declare function normalizeWordAttributeFilter<T extends Record<string, unknown>>(selected: T, lng?: string): T;
30
+ /** 라벨이 어느 언어의 섹션 라벨인가 — 토글이 그 언어로 값을 적게 */
31
+ export declare function wordAttributeLabelLanguageOf(label: string): string;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 공용 속성 필터(등급·음절 수·품사·어종)의 **고정 코드**와 라벨 사이를 오간다.
3
+ *
4
+ * 단어표·대립쌍표의 filterSelected 는 **번역된 라벨**을 섹션 열쇠·값으로 쓴다(`{ '품사': ['체언(명사, 수사)'] }`).
5
+ * 그래서 한국어로 저장한 표 구성을 영어 화면에서 열거나, 화면을 연 채 언어가 바뀌면(말귀: 고객 세션이 그 사람의 언어를 적용)
6
+ * 영어 열쇠('Part of Speech')로는 한국어 값이 안 읽혀 **필터가 풀리거나**, 품사 값이 posMap 에 없어 **모든 단어가 떨어졌다**.
7
+ * 여기서 섹션·값을 언어와 무관한 코드로 읽는다 — 코드, 지금 언어·ko·en 라벨, 옛 라벨('난이도 등급'·'체언(명사,수사)') 모두.
8
+ *
9
+ * 코드: 섹션 `grade`·`syllables`·`pos`·`wordType`, 값 등급 `'1'`~`'4'` · 음절 `'1'`~`'4'`·`'5+'` ·
10
+ * 품사 `noun`·`verb`·`modifier` · 어종 `native`·`sino`·`foreign`·`mixed`.
11
+ * (useWordTrainingFilter 의 POS_CODES·toAttrCodes 와 같은 코드다.)
12
+ */
13
+ import i18n from '../i18n';
14
+ const 섹션들 = ['grade', 'syllables', 'pos', 'wordType'];
15
+ const 등급값 = ['1', '2', '3', '4'];
16
+ const 음절값 = ['1', '2', '3', '4', '5+'];
17
+ const 품사값 = ['noun', 'verb', 'modifier'];
18
+ const 어종값 = ['native', 'sino', 'foreign', 'mixed'];
19
+ const 코드값 = { grade: 등급값, syllables: 음절값, pos: 품사값, wordType: 어종값 };
20
+ /** 품사 코드 → 사전 품사 약자(단어의 pos 에 들어 있는지 본다) · 어종 코드 → 사전 어종 값 */
21
+ export const POS_CODE_TARGETS = { noun: ['명', '수'], verb: ['동', '형'], modifier: ['부', '관'] };
22
+ export const WORD_TYPE_CODE_VALUES = { native: '고유', sino: '한자', foreign: '외래', mixed: '혼종' };
23
+ /* 사전 밖의 옛 라벨 — phonemePairSearch 의 DEFAULT_PAIR_FILTER_KEYS 가 쓰던 것(쉼표 뒤 빈칸 없음·'난이도 등급') */
24
+ const 옛섹션 = { grade: ['난이도 등급'] };
25
+ const 옛값 = {
26
+ pos: { noun: ['체언(명사,수사)'], verb: ['용언(동사,형용사)'], modifier: ['수식언(부사,관형사)'] },
27
+ };
28
+ /* 언어마다 한 번 만든다 — matchesFilters 는 낱말(2만여 개)마다 불린다. 사전이 더해지면(addResourceBundle) 비운다 */
29
+ const 사전들 = new Map();
30
+ try {
31
+ i18n.store?.on?.('added', () => 사전들.clear());
32
+ }
33
+ catch { /* 없는 환경 */ }
34
+ function 사전(lng) {
35
+ const 있음 = 사전들.get(lng);
36
+ if (있음)
37
+ return 있음;
38
+ const t = i18n.getFixedT(lng);
39
+ const d = {
40
+ 섹션: {
41
+ grade: t('wordSearch.filter.difficulty', '등급'),
42
+ syllables: t('wordSearch.filter.syllables', '음절 수'),
43
+ pos: t('wordSearch.filter.partOfSpeech', '품사'),
44
+ wordType: t('wordSearch.filter.wordType', '유형'),
45
+ },
46
+ 값: {
47
+ grade: Object.fromEntries(등급값.map((n) => [n, t('wordSearch.filter.grade', { n: Number(n), defaultValue: `${n}등급` })])),
48
+ syllables: {
49
+ ...Object.fromEntries(['1', '2', '3', '4'].map((n) => [n, t('wordSearch.filter.syllable', { n: Number(n), defaultValue: `${n}음절` })])),
50
+ '5+': t('wordSearch.filter.syllable5plus', '5음절 이상'),
51
+ },
52
+ pos: {
53
+ noun: t('wordSearch.filter.pos.noun', '체언(명사, 수사)'),
54
+ verb: t('wordSearch.filter.pos.verb', '용언(동사, 형용사)'),
55
+ modifier: t('wordSearch.filter.pos.modifier', '수식언(부사, 관형사)'),
56
+ },
57
+ wordType: {
58
+ native: t('wordSearch.filter.type.native', '고유어'),
59
+ sino: t('wordSearch.filter.type.sino', '한자어'),
60
+ foreign: t('wordSearch.filter.type.foreign', '외래어'),
61
+ mixed: t('wordSearch.filter.type.mixed', '혼종어'),
62
+ },
63
+ },
64
+ };
65
+ 사전들.set(lng, d);
66
+ return d;
67
+ }
68
+ const 언어들 = () => Array.from(new Set([i18n.language, 'ko', 'en'].filter((x) => !!x)));
69
+ /** 섹션 라벨(어느 언어든·옛 라벨·코드) → 섹션 코드. 속성 섹션이 아니면 null. */
70
+ export function wordAttributeSectionOf(label) {
71
+ if (섹션들.includes(label))
72
+ return label;
73
+ for (const lng of 언어들()) {
74
+ const d = 사전(lng);
75
+ for (const s of 섹션들)
76
+ if (d.섹션[s] === label)
77
+ return s;
78
+ }
79
+ for (const s of 섹션들)
80
+ if (옛섹션[s]?.includes(label))
81
+ return s;
82
+ return null;
83
+ }
84
+ /** 한 섹션의 값(코드·어느 언어 라벨·옛 라벨) → 값 코드. 못 읽으면 null. */
85
+ export function wordAttributeValueCodeOf(section, value) {
86
+ if (코드값[section].includes(value))
87
+ return value;
88
+ for (const lng of 언어들()) {
89
+ const 표 = 사전(lng).값[section];
90
+ for (const [code, label] of Object.entries(표))
91
+ if (label === value)
92
+ return code;
93
+ }
94
+ for (const [code, labels] of Object.entries(옛값[section] ?? {}))
95
+ if (labels.includes(value))
96
+ return code;
97
+ /* 등급·음절은 숫자로도 읽는다('Grade 3'·'3등급'·'6음절') — 사전에 없는 꼴이 와도 풀리지 않게 */
98
+ if (section === 'grade' || section === 'syllables') {
99
+ const n = parseInt(value.replace(/\D/g, ''), 10);
100
+ if (Number.isFinite(n) && n >= 1) {
101
+ if (section === 'grade')
102
+ return n <= 4 ? String(n) : null;
103
+ return n >= 5 ? '5+' : String(n);
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+ const 배열로 = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : typeof v === 'string' && v ? [v] : []);
109
+ /**
110
+ * @public filterSelected(라벨 열쇠·라벨 값, 어느 언어든 섞여 있어도) → **코드 모양**. 저장할 때 쓴다.
111
+ * 속성 섹션(등급·음절 수·품사·어종)만 담고, 그 밖의 섹션(초성·선택·단어 입력 등)은 뺀다. 못 읽는 값은 버린다.
112
+ */
113
+ export function toWordAttributeFilterCodes(selected) {
114
+ const out = {};
115
+ for (const [k, v] of Object.entries(selected ?? {})) {
116
+ const s = wordAttributeSectionOf(k);
117
+ if (!s)
118
+ continue;
119
+ for (const raw of 배열로(v)) {
120
+ const c = wordAttributeValueCodeOf(s, raw);
121
+ if (!c)
122
+ continue;
123
+ const arr = (out[s] ??= []);
124
+ if (!arr.includes(c))
125
+ arr.push(c);
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+ /**
131
+ * @public 코드 모양 → 지정한 언어(기본: 지금 i18n 언어)의 **라벨 filterSelected**. FilterPanel 의 selected 로 그대로 쓴다.
132
+ */
133
+ export function wordAttributeFilterFromCodes(codes, lng = i18n.language) {
134
+ const d = 사전(lng || 'ko');
135
+ const out = {};
136
+ for (const s of 섹션들) {
137
+ const labels = (codes?.[s] ?? []).map((c) => d.값[s][c]).filter((x) => !!x);
138
+ if (labels.length)
139
+ out[d.섹션[s]] = labels;
140
+ }
141
+ return out;
142
+ }
143
+ /**
144
+ * @public filterSelected 의 속성 섹션을 **지정한 언어(기본: 지금 언어)의 라벨**로 다시 적는다 — 다른 언어·옛 라벨로 저장된 구성을
145
+ * 열 때, 언어가 바뀐 뒤 상태를 맞출 때 쓴다(칩이 꺼진 채로 보이던 것). 속성 밖의 섹션은 그대로 둔다. 같은 섹션이 두 언어로
146
+ * 들어 있으면 합친다.
147
+ */
148
+ export function normalizeWordAttributeFilter(selected, lng = i18n.language) {
149
+ const 남김 = {};
150
+ for (const [k, v] of Object.entries(selected ?? {}))
151
+ if (!wordAttributeSectionOf(k))
152
+ 남김[k] = v;
153
+ return { ...남김, ...wordAttributeFilterFromCodes(toWordAttributeFilterCodes(selected), lng) };
154
+ }
155
+ /** 라벨이 어느 언어의 섹션 라벨인가 — 토글이 그 언어로 값을 적게 */
156
+ export function wordAttributeLabelLanguageOf(label) {
157
+ for (const lng of 언어들()) {
158
+ const d = 사전(lng);
159
+ if (섹션들.some((s) => d.섹션[s] === label))
160
+ return lng;
161
+ }
162
+ return i18n.language || 'ko';
163
+ }
@@ -16,7 +16,9 @@ export declare const hasSel: (v: string | string[] | null | undefined) => boolea
16
16
  * 두 (표준발음) 분해배열 A·B가 '음절 구조'에서 달라지는 자리가 있는지.
17
17
  * 초성 ㅇ(무음)은 '초성 없음', 종성 0은 '받침 없음'(둘 다 구조상 '비움')으로 본다.
18
18
  * 어떤 자리에서 초성 ㅇ 유무 또는 받침 유무가 뒤바뀌면, 자음 '교체'가 아니라 음절 '구조' 차이이므로
19
- * 청각 변별 오답으로 부적절 → true(제외 대상). 자음↔자음, 모음↔모음 교체는 false.
19
+ * 청각 변별 오답으로 부적절 → true(제외 대상). 자음↔자음 교체는 false.
20
+ * 모음은 **활음이 갈리면**(ㅏ↔ㅘ·ㅐ↔ㅙ·ㅑ↔ㅘ) 구조 차이, 활음이 같으면(ㅏ↔ㅓ·ㅑ↔ㅕ) 모음 교체라 false —
21
+ * 대립쌍 표('음소 구조 같은 단어만', phonemePairSearch)와 같은 함수(vowelGlidesDiffer)로 판정한다.
20
22
  */
21
23
  export declare function hasStructureContrast(A: Decomp, B: Decomp): boolean;
22
24
  /** 표준발음 기준 오답 필터에 필요한 의존성 */
@@ -27,7 +29,7 @@ export interface ContrastFilterOpts {
27
29
  pronOf: (w: string) => string;
28
30
  /** 표준발음 문자열 → 음절 분해(캐시 권장). sameStructureOnly일 때만 필요. */
29
31
  decompOf?: (pron: string) => Decomp;
30
- /** true면 구조 대립(초성 ㅇ 유무·받침 유무)도 제외(decompOf 필요). */
32
+ /** true면 구조 대립(초성 ㅇ 유무·활음 유무·종류·받침 유무)도 제외(decompOf 필요). */
31
33
  sameStructureOnly?: boolean;
32
34
  }
33
35
  /**