@ihabdevteam/core 0.146.0 → 0.148.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 +57 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +22 -14
- package/dist/packages/core/src/components/BottomNav.js +15 -6
- package/dist/packages/core/src/components/Dropdown.js +2 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +3 -1
- package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -4
- package/dist/packages/core/src/components/Popup/Popup.js +1 -1
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +3 -2
- package/dist/packages/core/src/components/Report/ReportDocument.js +5 -3
- package/dist/packages/core/src/components/SegmentedControl.js +3 -1
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/TopBanner.js +7 -2
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/useNoiseFade.js +25 -7
- package/dist/packages/core/src/components/Word/WordTableBlock.d.ts +0 -1
- package/dist/packages/core/src/components/Word/WordTableBlock.js +47 -22
- package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +2 -1
- package/dist/packages/core/src/components/Word/wordTablePrint.js +8 -1
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +35 -7
- package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +5 -2
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +45 -8
- package/dist/packages/core/src/hooks/useWordMeanings.js +13 -2
- package/dist/packages/core/src/utils/getTrainingData.d.ts +5 -1
- package/dist/packages/core/src/utils/getTrainingData.js +20 -4
- package/dist/packages/core/src/utils/phonemePairSearch.js +10 -4
- package/dist/packages/core/src/utils/planFeatures.d.ts +1 -1
- package/dist/packages/core/src/utils/planFeatures.js +7 -1
- package/dist/packages/core/src/utils/textSort.js +4 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +2 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.d.ts +2 -1
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +5 -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 +2 -2
- package/package.json +1 -1
|
@@ -105,7 +105,14 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
105
105
|
아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
|
|
106
106
|
'인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
|
|
107
107
|
clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
|
|
108
|
-
|
|
108
|
+
/* 불러오는 중이던 그림은 opacity 를 **그대로 두고** 받아지면 보이고 실패하면 숨게 한다 — 지우기만 하면 없는 그림(404, 화면에서는
|
|
109
|
+
onError 로 칸이 사라짐)이 인쇄 문서에서 깨진 그림 아이콘과 alt 글자로 찍혔다. autoPrint 는 그림 로드를 기다린 뒤 인쇄한다. */
|
|
110
|
+
clone.querySelectorAll('img').forEach(i => {
|
|
111
|
+
if (i.style.opacity !== '0')
|
|
112
|
+
return;
|
|
113
|
+
i.setAttribute('onload', "this.style.opacity=''");
|
|
114
|
+
i.setAttribute('onerror', "this.style.visibility='hidden'");
|
|
115
|
+
});
|
|
109
116
|
return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
|
|
110
117
|
}).join('\n');
|
|
111
118
|
/* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
|
|
@@ -45,6 +45,8 @@ export function useDailyTrainingSelection(userId) {
|
|
|
45
45
|
}, []);
|
|
46
46
|
useEffect(() => {
|
|
47
47
|
let cancelled = false;
|
|
48
|
+
let 떠남 = false;
|
|
49
|
+
let 적용됨 = false;
|
|
48
50
|
let timer;
|
|
49
51
|
let attempts = 0;
|
|
50
52
|
const MAX_ATTEMPTS = 40; // 250ms * 40 = 10초 후 폴링 중단(풀이 영영 비는 경우 무한루프 방지)
|
|
@@ -60,6 +62,9 @@ export function useDailyTrainingSelection(userId) {
|
|
|
60
62
|
}
|
|
61
63
|
catch { }
|
|
62
64
|
const apply = (sel) => {
|
|
65
|
+
if (떠남)
|
|
66
|
+
return;
|
|
67
|
+
적용됨 = true;
|
|
63
68
|
todayRef.current = sel.today;
|
|
64
69
|
yesterdayRef.current = sel.yesterday;
|
|
65
70
|
setLoaded(true);
|
|
@@ -79,13 +84,16 @@ export function useDailyTrainingSelection(userId) {
|
|
|
79
84
|
return;
|
|
80
85
|
}
|
|
81
86
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
/* 캐시 miss → 데이터 로드 후 새 선택. 로드는 **2초마다 한 번만** 다시 청한다 — 250ms 마다 부르면 받기가 곧바로 실패하는
|
|
88
|
+
배포(형식 오류)에서 10초에 850KB 자료를 40번 받았다. 그 사이 폴링은 선택만 다시 계산한다. */
|
|
89
|
+
if (attempts % 8 === 0) {
|
|
90
|
+
try {
|
|
91
|
+
await ensureTrainingDataLoaded(3000);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
if (cancelled)
|
|
95
|
+
return;
|
|
85
96
|
}
|
|
86
|
-
catch { }
|
|
87
|
-
if (cancelled)
|
|
88
|
-
return;
|
|
89
97
|
const sel = resolveSelection(forceRefresh, undefined, userId);
|
|
90
98
|
if (hasItems(sel.today)) {
|
|
91
99
|
apply(sel);
|
|
@@ -101,9 +109,29 @@ export function useDailyTrainingSelection(userId) {
|
|
|
101
109
|
stop();
|
|
102
110
|
}
|
|
103
111
|
};
|
|
112
|
+
/* 포기한 뒤(10초)라도 자료가 도착하면 다시 계산한다 — 느린 회선에서 13초 걸려 받은 회원은 새로고침 전까지 "자료를 못 찾음" 에 남았다 */
|
|
113
|
+
const 자료도착 = () => {
|
|
114
|
+
if (떠남 || 적용됨)
|
|
115
|
+
return;
|
|
116
|
+
const sel = resolveSelection(forceRefresh, undefined, userId);
|
|
117
|
+
if (hasItems(sel.today))
|
|
118
|
+
apply(sel);
|
|
119
|
+
};
|
|
120
|
+
try {
|
|
121
|
+
window.addEventListener('speechear.data.updated', 자료도착);
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
104
124
|
tryFill();
|
|
105
125
|
timer = setInterval(tryFill, 250);
|
|
106
|
-
return () => {
|
|
126
|
+
return () => {
|
|
127
|
+
cancelled = true;
|
|
128
|
+
떠남 = true;
|
|
129
|
+
stop();
|
|
130
|
+
try {
|
|
131
|
+
window.removeEventListener('speechear.data.updated', 자료도착);
|
|
132
|
+
}
|
|
133
|
+
catch { }
|
|
134
|
+
};
|
|
107
135
|
// 오늘 은 날짜가 바뀌었다는 신호 — resolveSelection 이 안에서 오늘 날짜를 읽는다
|
|
108
136
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
109
137
|
}, [userId, 오늘]);
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 훈련
|
|
3
|
-
* 5초 미만
|
|
2
|
+
* 훈련 팝업을 **보고 있던** 시간을 기록합니다.
|
|
3
|
+
* 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
|
|
4
|
+
*
|
|
5
|
+
* 한 팝업에 **한 줄** 만 보냅니다 — 떠날 때, 또는 페이지가 내려갈 때(pagehide: 팝업을 연 채 탭·앱을 닫음). 화면이 가려진
|
|
6
|
+
* 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간 잠가 둔 시간이 훈련 시간으로 적혔다.
|
|
4
7
|
*
|
|
5
8
|
* @public
|
|
6
9
|
*/
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
import { logTrainingTime } from '../utils/trainingLogger';
|
|
4
4
|
/**
|
|
5
|
-
* 훈련
|
|
6
|
-
* 5초 미만
|
|
5
|
+
* 훈련 팝업을 **보고 있던** 시간을 기록합니다.
|
|
6
|
+
* 보인 시간이 5초 미만(잘못 열었다 닫은 경우)이면 무시합니다.
|
|
7
|
+
*
|
|
8
|
+
* 한 팝업에 **한 줄** 만 보냅니다 — 떠날 때, 또는 페이지가 내려갈 때(pagehide: 팝업을 연 채 탭·앱을 닫음). 화면이 가려진
|
|
9
|
+
* 동안(탭 전환·앱 백그라운드)의 시간은 빼고 모읍니다 — 전에는 연 채 3시간 잠가 둔 시간이 훈련 시간으로 적혔다.
|
|
7
10
|
*
|
|
8
11
|
* @public
|
|
9
12
|
*/
|
|
@@ -11,16 +14,50 @@ export default function useTrainingSessionLog(trainingType) {
|
|
|
11
14
|
useEffect(() => {
|
|
12
15
|
if (!trainingType)
|
|
13
16
|
return; // 타입 미상이면 기록하지 않음
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/* **한 팝업 = 한 줄.** 보이는 구간마다 따로 보내면 서버가 줄 수를 세션 수로 세어(v_user_training_time_stats) 탭을 오갈 때마다
|
|
18
|
+
문장 훈련 횟수가 늘었다. 보이던 시간만 모아 두었다가 떠날 때(또는 페이지가 내려갈 때) 한 번 보낸다 — 끝 시각은 처음 보인 때 + 보인 시간. */
|
|
19
|
+
const 보임 = () => typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
20
|
+
let 처음보임 = null;
|
|
21
|
+
let 구간시작 = null;
|
|
22
|
+
let 모은ms = 0;
|
|
23
|
+
let 보냄 = false;
|
|
24
|
+
const 구간열기 = () => { const t = Date.now(); if (처음보임 == null)
|
|
25
|
+
처음보임 = t; if (구간시작 == null)
|
|
26
|
+
구간시작 = t; };
|
|
27
|
+
const 구간닫기 = () => { if (구간시작 != null) {
|
|
28
|
+
모은ms += Date.now() - 구간시작;
|
|
29
|
+
구간시작 = null;
|
|
30
|
+
} };
|
|
31
|
+
if (보임())
|
|
32
|
+
구간열기();
|
|
33
|
+
const 보내기 = () => {
|
|
34
|
+
if (보냄)
|
|
35
|
+
return;
|
|
36
|
+
구간닫기();
|
|
37
|
+
if (처음보임 == null || 모은ms < 5000)
|
|
18
38
|
return;
|
|
39
|
+
보냄 = true;
|
|
19
40
|
logTrainingTime({
|
|
20
41
|
trainingType,
|
|
21
|
-
startedAt:
|
|
22
|
-
endedAt:
|
|
42
|
+
startedAt: new Date(처음보임).toISOString(),
|
|
43
|
+
endedAt: new Date(처음보임 + 모은ms).toISOString(),
|
|
23
44
|
});
|
|
24
45
|
};
|
|
46
|
+
const 보임바뀜 = () => { if (보임())
|
|
47
|
+
구간열기();
|
|
48
|
+
else
|
|
49
|
+
구간닫기(); };
|
|
50
|
+
if (typeof document !== 'undefined')
|
|
51
|
+
document.addEventListener('visibilitychange', 보임바뀜);
|
|
52
|
+
// 페이지가 내려가면(탭·앱 닫기) React 정리가 안 돈다 — 그때 보낸다
|
|
53
|
+
if (typeof window !== 'undefined')
|
|
54
|
+
window.addEventListener('pagehide', 보내기);
|
|
55
|
+
return () => {
|
|
56
|
+
if (typeof document !== 'undefined')
|
|
57
|
+
document.removeEventListener('visibilitychange', 보임바뀜);
|
|
58
|
+
if (typeof window !== 'undefined')
|
|
59
|
+
window.removeEventListener('pagehide', 보내기);
|
|
60
|
+
보내기();
|
|
61
|
+
};
|
|
25
62
|
}, [trainingType]);
|
|
26
63
|
}
|
|
@@ -26,6 +26,10 @@ const memCache = new Map();
|
|
|
26
26
|
/* 받아 봤는데 뜻이 없던 열쇠 — 다시 묻지 않는다. 전에는 뜻 없는 낱말이 캐시에 안 남아 표를 다시 정렬하거나 쪽을
|
|
27
27
|
넘길 때마다 같은 낱말을 또 조회했다. (조회가 실패한 것은 여기 넣지 않는다 — 다음에 다시 받는다.) */
|
|
28
28
|
const noMeaning = new Set();
|
|
29
|
+
/* 빈 응답(한 줄도 못 받음)으로 '뜻 없음' 이 된 열쇠와 그 때 — 조회 함수는 실패도 빈 배열로 돌려주므로 영영 굳히지 않고, 사용자 추가
|
|
30
|
+
낱말처럼 정말 뜻이 없는 쪽을 오갈 때마다 다시 묻지도 않게 **잠시만**(1분) 묻지 않는다. */
|
|
31
|
+
const 빈응답때 = new Map();
|
|
32
|
+
const 빈응답유예ms = 60_000;
|
|
29
33
|
export function useWordMeanings(rows, fetchMeanings) {
|
|
30
34
|
/* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
|
|
31
35
|
올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
|
|
@@ -35,7 +39,8 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
35
39
|
const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort().join('|');
|
|
36
40
|
useEffect(() => {
|
|
37
41
|
const wanted = wantedKey ? wantedKey.split('|') : [];
|
|
38
|
-
const
|
|
42
|
+
const 지금 = Date.now();
|
|
43
|
+
const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(지금 - (빈응답때.get(k) ?? -Infinity) < 빈응답유예ms));
|
|
39
44
|
if (missing.length === 0)
|
|
40
45
|
return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
|
|
41
46
|
let cancelled = false;
|
|
@@ -50,9 +55,15 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
50
55
|
/* 한 줄도 못 받았으면 '뜻 없음' 으로 굳히지 않는다 — 조회 함수는 실패를 빈 배열로 돌려주는 약속이라, 잠깐 끊긴 망이
|
|
51
56
|
그 쪽 낱말들을 새로고침 전까지 '뜻 없음' 으로 묶었다. 받은 게 있으면 그 응답에 빠진 낱말만 없는 것으로 본다. */
|
|
52
57
|
if (got.length > 0)
|
|
53
|
-
for (const k of missing)
|
|
58
|
+
for (const k of missing) {
|
|
54
59
|
if (!memCache.has(k))
|
|
55
60
|
noMeaning.add(k);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const t = Date.now();
|
|
64
|
+
for (const k of missing)
|
|
65
|
+
빈응답때.set(k, t);
|
|
66
|
+
}
|
|
56
67
|
if (cancelled || got.length === 0)
|
|
57
68
|
return;
|
|
58
69
|
setFetched(v => v + 1);
|
|
@@ -56,7 +56,11 @@ export declare function getTrainingPools(): Record<string, unknown[]>;
|
|
|
56
56
|
export declare function loadTrainingData(): Promise<void>;
|
|
57
57
|
/** @public */
|
|
58
58
|
export declare function isTrainingDataLoaded(): boolean;
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
|
|
61
|
+
* 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
|
|
62
|
+
*/
|
|
63
|
+
export declare function ensureTrainingDataLoaded(timeout?: number): Promise<boolean>;
|
|
60
64
|
declare const _default: {
|
|
61
65
|
NOISE_DATAS: unknown[];
|
|
62
66
|
SHORT_STORIES: unknown[];
|
|
@@ -111,6 +111,9 @@ export async function loadTrainingData() {
|
|
|
111
111
|
for (const key of Object.keys(map || {})) {
|
|
112
112
|
const groups = map[key] || [];
|
|
113
113
|
for (const g of groups) {
|
|
114
|
+
// 한 행이 null·글자면 건너뛴다 — 그 한 행의 TypeError 로 자료 전체를 버리고 매번 같은 실패를 되풀이했다
|
|
115
|
+
if (!g || typeof g !== 'object')
|
|
116
|
+
continue;
|
|
114
117
|
const gr = g;
|
|
115
118
|
const t = String(gr.training_type || key);
|
|
116
119
|
const cat0 = String(gr.category_main_code || gr.category_main || gr.category_0 || '');
|
|
@@ -149,22 +152,35 @@ export async function loadTrainingData() {
|
|
|
149
152
|
SHORT_STORIES.push(...받음.short);
|
|
150
153
|
LONG_STORIES.push(...받음.long);
|
|
151
154
|
ORDER_SETS.push(...받음.order);
|
|
155
|
+
// 다 채운 뒤 _loaded 를 먼저 세운다 — 'updated' 를 받은 쪽이 isTrainingDataLoaded() 로 확인하면 아직 false 였다
|
|
156
|
+
_loaded = true;
|
|
152
157
|
try {
|
|
153
|
-
|
|
158
|
+
computeDailySelection();
|
|
154
159
|
}
|
|
155
160
|
catch (e) { }
|
|
156
161
|
try {
|
|
157
|
-
|
|
162
|
+
window.dispatchEvent(new Event('speechear.data.updated'));
|
|
158
163
|
}
|
|
159
164
|
catch (e) { }
|
|
160
|
-
_loaded = true;
|
|
161
165
|
})();
|
|
162
166
|
return _loading;
|
|
163
167
|
}
|
|
164
168
|
/** @public */
|
|
165
169
|
export function isTrainingDataLoaded() { return _loaded; }
|
|
170
|
+
/**
|
|
171
|
+
* 자료를 받게 하고 **받았는지** 를 돌려준다(시간 초과·실패는 false). 전에는 성공·3초 초과·실패가 모두 undefined 로 끝나
|
|
172
|
+
* 부르는 쪽이 빈 풀로 계획을 만들고도 알 길이 없었다. 늦게 도착하면 'speechear.data.updated' 가 온다.
|
|
173
|
+
*/
|
|
166
174
|
export async function ensureTrainingDataLoaded(timeout = 3000) {
|
|
167
175
|
const p = loadTrainingData();
|
|
168
|
-
|
|
176
|
+
let t;
|
|
177
|
+
try {
|
|
178
|
+
await Promise.race([p, new Promise((res) => { t = setTimeout(res, timeout); })]);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
if (t !== undefined)
|
|
182
|
+
clearTimeout(t);
|
|
183
|
+
}
|
|
184
|
+
return _loaded;
|
|
169
185
|
}
|
|
170
186
|
export default { NOISE_DATAS, SHORT_STORIES, LONG_STORIES, ORDER_SETS, loadTrainingData, ensureTrainingDataLoaded };
|
|
@@ -461,8 +461,10 @@ preferAsTarget) {
|
|
|
461
461
|
}
|
|
462
462
|
const targetWords = allWords.filter(({ syls, wordSyls }) => {
|
|
463
463
|
const filterSyls = !phonemic && wordSyls ? wordSyls : syls;
|
|
464
|
+
/* 일치(ordered) 는 조건의 자리가 곧 음절 자리다 — 빈 슬롯을 걸러 낸 reqs 를 쓰면 [{}, {초성:ㄴ}] 의 ㄴ 이 첫 음절에 걸렸다.
|
|
465
|
+
wordContainsAllPhonemes 는 빈 슬롯을 스스로 건너뛴다. */
|
|
464
466
|
return ordered
|
|
465
|
-
? wordContainsAllPhonemes(filterSyls,
|
|
467
|
+
? wordContainsAllPhonemes(filterSyls, requiredPhonemes)
|
|
466
468
|
: wordContainsAllPhonemesUnordered(filterSyls, reqs);
|
|
467
469
|
});
|
|
468
470
|
const groups = new Map();
|
|
@@ -534,10 +536,12 @@ preferAsTarget) {
|
|
|
534
536
|
if (tv !== cv)
|
|
535
537
|
diffValues = [tv, cv];
|
|
536
538
|
}
|
|
537
|
-
const
|
|
538
|
-
const reqVal = req[diffSection];
|
|
539
|
+
const 맞나 = (req) => {
|
|
540
|
+
const reqVal = req?.[diffSection];
|
|
539
541
|
return reqVal !== undefined && (diffValues[0] === reqVal || diffValues[1] === reqVal);
|
|
540
|
-
}
|
|
542
|
+
};
|
|
543
|
+
// 일치(ordered): "첫번째 음절에서 초성 ㄱ 이 대립" — 대립한 자리의 조건이어야 한다(다른 음절에서 대립한 쌍이 섞였다)
|
|
544
|
+
const isRelevant = ordered ? 맞나(requiredPhonemes[analysis.diff.idx]) : reqs.some(맞나);
|
|
541
545
|
if (!isRelevant)
|
|
542
546
|
continue;
|
|
543
547
|
}
|
|
@@ -545,6 +549,8 @@ preferAsTarget) {
|
|
|
545
549
|
const diffVals = analysis.diff.diffs[diffSection];
|
|
546
550
|
const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
|
|
547
551
|
const item = {
|
|
552
|
+
/* 정렬 꼴 그대로 — 기준 방향을 넣으면 음소를 ㄱ→ㄴ 으로 바꿔 찾거나 저장한 표를 다시 열 때 같은 쌍의 id 가 달라져
|
|
553
|
+
바구니(id 로 견줌)에서 선택이 풀리고 두 번 담겼다. 이미 저장된 대립쌍표 id 도 이 꼴이다. */
|
|
548
554
|
id: dedupKey,
|
|
549
555
|
diffSection,
|
|
550
556
|
diffHasEmpty,
|
|
@@ -58,4 +58,4 @@ export interface PlanLimits {
|
|
|
58
58
|
/** @public 요금제 티어별 제한 (표시되지 않는 레거시 티어는 생략) */
|
|
59
59
|
export declare const PLAN_LIMITS: Partial<Record<PlanTier, PlanLimits>>;
|
|
60
60
|
/** product code → 요금제 티어 */
|
|
61
|
-
export declare function getPlanTier(productCode: string): PlanTier;
|
|
61
|
+
export declare function getPlanTier(productCode: string | null | undefined): PlanTier;
|
|
@@ -145,7 +145,13 @@ export const PLAN_LIMITS = {
|
|
|
145
145
|
};
|
|
146
146
|
/** product code → 요금제 티어 */
|
|
147
147
|
export function getPlanTier(productCode) {
|
|
148
|
-
const c = productCode.toLowerCase();
|
|
148
|
+
const c = (productCode ?? '').toLowerCase();
|
|
149
|
+
/* 무료 코드는 접두사 규칙보다 먼저 — 'speechear_free' 가 아래 speechear_ 규칙에 걸려 유료 개인 스탠다드로 올라갔고,
|
|
150
|
+
'manager_free' 는 규칙이 없어 none 이었다(어드민 요금제 PLANS 의 plan_id). null 코드는 던지지 않고 none. */
|
|
151
|
+
if (c === 'speechear_free' || c === 'free')
|
|
152
|
+
return 'free';
|
|
153
|
+
if (c.startsWith('manager_free') || c.startsWith('mgr_free'))
|
|
154
|
+
return 'mgr_free';
|
|
149
155
|
if (c.startsWith('manager_enterprise'))
|
|
150
156
|
return 'enterprise';
|
|
151
157
|
if (c.startsWith('manager_pro'))
|
|
@@ -65,7 +65,9 @@ export function 칸순(a, b) {
|
|
|
65
65
|
const sa = 글(a), sb = 글(b);
|
|
66
66
|
if (sa === sb)
|
|
67
67
|
return 0;
|
|
68
|
-
|
|
68
|
+
/* 수(number)는 글로 바꿔 쪼개지 않고 **수 조각 하나** 로 — '1e+21'·'1e-7' 로 쪼개면 1e21 이 5 앞에, -Infinity 가 끝에 섰다 */
|
|
69
|
+
const 열쇠 = (v, s) => (typeof v === 'number' && !Number.isNaN(v) ? [v] : 조각내기(s));
|
|
70
|
+
const ka = 열쇠(a, sa), kb = 열쇠(b, sb);
|
|
69
71
|
const n = Math.min(ka.length, kb.length);
|
|
70
72
|
for (let i = 0; i < n; i++) {
|
|
71
73
|
const x = ka[i], y = kb[i];
|
|
@@ -73,7 +75,7 @@ export function 칸순(a, b) {
|
|
|
73
75
|
if (x !== y)
|
|
74
76
|
return x - y;
|
|
75
77
|
continue;
|
|
76
|
-
}
|
|
78
|
+
} // 같지 않은 ±Infinity 끼리도 부호가 맞다
|
|
77
79
|
if (typeof x === 'number')
|
|
78
80
|
return -1;
|
|
79
81
|
if (typeof y === 'number')
|
|
@@ -59,7 +59,7 @@ export declare function syllablePron(ch: string): string;
|
|
|
59
59
|
export declare function soundOf(syll: string, pronMap: Map<string, string>): string;
|
|
60
60
|
/** @public comp에서 실제로 선택된 파트 1개(초성 우선)를 반환. 없으면 null. */
|
|
61
61
|
export declare function primarySelPart(comp: CompositionFilter | null): 'cho' | 'jung' | 'jong' | null;
|
|
62
|
-
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
|
|
62
|
+
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
|
|
63
63
|
export declare function syllMatchesComp(s: Syllable, comp: CompositionFilter): boolean;
|
|
64
64
|
/**
|
|
65
65
|
* @public 발음 모드에서 comp 매칭에 쓸 음절 배열. 표준발음(pronMap)의 음절수가 철자와 같으면 발음 음절로,
|
|
@@ -94,7 +94,7 @@ export declare function buildDropContrast3Problem(word: string, comp: Compositio
|
|
|
94
94
|
* @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
|
|
95
95
|
* 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
|
|
96
96
|
* 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
|
|
97
|
-
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
|
|
97
|
+
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
|
|
98
98
|
*/
|
|
99
99
|
export declare function buildDropCompoundProblem(word: string, comp: CompositionFilter | null, pronMap: Map<string, string>, phonemeView: 'char' | 'pron', wordSet: Set<string>, dropPos?: number | null): TrainingProblem | null;
|
|
100
100
|
/**
|
|
@@ -207,7 +207,7 @@ export function primarySelPart(comp) {
|
|
|
207
207
|
return 'jong';
|
|
208
208
|
return null;
|
|
209
209
|
}
|
|
210
|
-
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
|
|
210
|
+
/** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). `jongNone` 이면 받침 없는 음절만 맞고 `jong` 은 보지 않는다(풀 필터와 같게). */
|
|
211
211
|
export function syllMatchesComp(s, comp) {
|
|
212
212
|
const ok = (arr, val) => !hasSel(arr) || asArr(arr).includes(val);
|
|
213
213
|
/* '받침 없음'(jongNone)도 본다 — 풀 필터(wordTrainingPool)는 jongNone 이면 받침 없는 음절만 맞다고 보는데 여기만 무시해, 초성 ㅎ +
|
|
@@ -367,7 +367,7 @@ export function buildDropContrast3Problem(word, comp, pronMap, phonemeView, word
|
|
|
367
367
|
* @public 합성어 부분 탈락 → [문제단어(합성어), 남는 실단어(정답), 그 단어의 대립쌍 실단어 오답 2].
|
|
368
368
|
* 합성어 근사: 앞/뒤로 나눈 두 조각이 '둘 다 실단어'(wordSet)인 첫 조합을 채택(예: 손수건=손+수건).
|
|
369
369
|
* 정답·오답 전부 실단어라 발음이 그대로. comp(훈련음소) 지정 시 남는 단어의 그 자리를 대립, 없으면 전 음절.
|
|
370
|
-
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 편집기·런타임 공용.
|
|
370
|
+
* dropPos: 0=어두 어근(앞) 탈락, 1=어말 어근(뒤) 탈락. 결과의 `dropped` 에 뺀 조각을 담는다. 편집기·런타임 공용.
|
|
371
371
|
*/
|
|
372
372
|
export function buildDropCompoundProblem(word, comp, pronMap, phonemeView, wordSet, dropPos = null) {
|
|
373
373
|
const chars = [...word];
|
|
@@ -19,7 +19,8 @@ export declare function getViableChoseongs(opts?: {
|
|
|
19
19
|
excludeNoImage?: boolean;
|
|
20
20
|
}): Promise<Set<string>>;
|
|
21
21
|
/** @public 현재 필터(GenOptions) 조건으로 만들 수 있는 정답 단어 수.
|
|
22
|
-
* fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지).
|
|
22
|
+
* fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지).
|
|
23
|
+
* 보기형 생성과 같게 **서로 소리가 다른 오답이 보기 수(choicesPerItem, 기본 3) − 1 개** 있는 낱말만 센다. */
|
|
23
24
|
export declare function countUsableWords(opts: GenOptions): Promise<number>;
|
|
24
25
|
/** @public 위치별(초성/중성/종성) 선택 가능한 자모 set + 음절 수 set — 실제 단어에 존재하는 값만.
|
|
25
26
|
*
|
|
@@ -659,12 +659,15 @@ export async function getViableChoseongs(opts = {}) {
|
|
|
659
659
|
return set;
|
|
660
660
|
}
|
|
661
661
|
/** @public 현재 필터(GenOptions) 조건으로 만들 수 있는 정답 단어 수.
|
|
662
|
-
* fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지).
|
|
662
|
+
* fullCount로 샘플링 캡을 우회해 실제 가능 단어 수를 그대로 센다(무작위 왜곡 방지).
|
|
663
|
+
* 보기형 생성과 같게 **서로 소리가 다른 오답이 보기 수(choicesPerItem, 기본 3) − 1 개** 있는 낱말만 센다. */
|
|
663
664
|
export async function countUsableWords(opts) {
|
|
664
665
|
const pool = await buildUsablePool({ ...opts, fullCount: true });
|
|
665
666
|
/* 보기형 생성(genChoicePick)은 **서로 소리가 다른 오답** 이 보기 수 − 1 개 있어야 문항을 만든다 — 오답 1개만 있어도 세어
|
|
666
667
|
"41개 가능" 인데 같은 소리 고르기가 4문항만 만들었다. 같은 기준으로 센다. */
|
|
667
|
-
|
|
668
|
+
/* 보기 수를 **알려 줄 때만**(choicesPerItem) 그만큼의 서로 다른 소리 오답을 요구한다. 안 주면(여러 유형이 함께 쓰는 안내 숫자)
|
|
669
|
+
오답 하나로 센다 — 늘 둘을 요구하면 같다/다르다·음절 합치기처럼 만들 수 있는 유형이 있는데도 "0개" 가 떴다. */
|
|
670
|
+
const need = opts.choicesPerItem != null ? Math.max(1, opts.choicesPerItem - 1) : 1;
|
|
668
671
|
return pool.filter(e => new Set(e.usable.map(u => pronOfWord(u))).size >= need).length;
|
|
669
672
|
}
|
|
670
673
|
/** @public 위치별(초성/중성/종성) 선택 가능한 자모 set + 음절 수 set — 실제 단어에 존재하는 값만.
|