@ihabdevteam/core 0.342.0 → 0.344.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 +103 -2
- package/dist/components.cjs.js +40 -40
- package/dist/hooks.cjs.js +5 -3
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +188 -188
- package/dist/ihabdevteam-core.d.ts +2 -2
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +4 -3
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +14 -1
- package/dist/packages/core/src/hooks/useCacheManagement.js +15 -5
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +16 -4
- package/dist/packages/core/src/hooks/useEditableList.js +11 -2
- package/dist/packages/core/src/hooks/useFocusTrap.js +11 -1
- package/dist/packages/core/src/hooks/useIcon.js +7 -1
- package/dist/packages/core/src/hooks/useInlineSvg.js +47 -18
- package/dist/packages/core/src/hooks/useListControls.js +11 -7
- package/dist/packages/core/src/hooks/usePasswordChange.js +15 -1
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +5 -3
- package/dist/packages/core/src/hooks/useTablePaging.js +10 -5
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +8 -4
- package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +5 -4
- package/dist/packages/core/src/hooks/useVisitAllPanel.js +11 -3
- package/dist/packages/core/src/hooks/useWordImage.js +15 -7
- package/dist/packages/core/src/hooks/useWordMeanings.js +36 -9
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -5
- package/dist/packages/core/src/utils/getTrainingData.js +9 -2
- package/dist/packages/core/src/utils/imageConfig.js +52 -17
- package/dist/packages/core/src/utils/prefetchChunk.js +10 -3
- package/dist/packages/core/src/utils/speak.js +51 -27
- package/dist/packages/core/src/utils/ttsConfig.js +10 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +16 -1
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -34,18 +34,41 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
34
34
|
/* 캐시는 모듈 스코프 Map 이라 갱신이 렌더에 자동 반영되지 않는다 — 조회가 끝나면 이 값을
|
|
35
35
|
올려 아래 스냅샷을 다시 계산시킨다(캐시 히트만으로 리렌더가 연쇄되지 않도록 분리). */
|
|
36
36
|
const [fetched, setFetched] = useState(0);
|
|
37
|
+
const [retry, setRetry] = useState(0);
|
|
37
38
|
// rows 는 매 렌더 새 배열이라 의존성으로 쓸 수 없다 — 조회 키만 뽑아 문자열로 비교한다
|
|
38
39
|
// 차례는 조회와 무관하다 — 정렬해 두어야 같은 낱말들을 다시 정렬만 해도 새 조회로 보지 않는다
|
|
39
|
-
const wantedKey = Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort()
|
|
40
|
+
const wantedKey = JSON.stringify(Array.from(new Set(rows.map(r => meaningKey(r.word, r.homonum)))).sort());
|
|
40
41
|
useEffect(() => {
|
|
41
|
-
const wanted =
|
|
42
|
+
const wanted = JSON.parse(wantedKey);
|
|
42
43
|
const now = Date.now();
|
|
43
44
|
const missing = wanted.filter(k => !memCache.has(k) && !noMeaning.has(k) && !(now - (emptyAt.get(k) ?? -Infinity) < EMPTY_GRACE_MS));
|
|
44
|
-
if (missing.length === 0)
|
|
45
|
-
return; // 전부 캐시에 있음 — 네트워크·상태 갱신 불필요
|
|
46
45
|
let cancelled = false;
|
|
46
|
+
let retryTimer = null;
|
|
47
|
+
let retryDue = Infinity;
|
|
48
|
+
const scheduleRetry = (delay = EMPTY_GRACE_MS) => {
|
|
49
|
+
if (cancelled)
|
|
50
|
+
return;
|
|
51
|
+
const due = Date.now() + delay;
|
|
52
|
+
if (due >= retryDue)
|
|
53
|
+
return;
|
|
54
|
+
if (retryTimer !== null)
|
|
55
|
+
clearTimeout(retryTimer);
|
|
56
|
+
retryDue = due;
|
|
57
|
+
retryTimer = setTimeout(() => setRetry(n => n + 1), delay);
|
|
58
|
+
};
|
|
59
|
+
// 유예 중 화면을 닫았다 다시 열어도 만료 시 재조회한다.
|
|
60
|
+
for (const k of wanted) {
|
|
61
|
+
if (memCache.has(k) || noMeaning.has(k))
|
|
62
|
+
continue;
|
|
63
|
+
const at = emptyAt.get(k);
|
|
64
|
+
if (at != null && now - at < EMPTY_GRACE_MS)
|
|
65
|
+
scheduleRetry(Math.max(0, EMPTY_GRACE_MS - (now - at)));
|
|
66
|
+
}
|
|
67
|
+
if (missing.length === 0)
|
|
68
|
+
return () => { cancelled = true; if (retryTimer !== null)
|
|
69
|
+
clearTimeout(retryTimer); };
|
|
47
70
|
const words = Array.from(new Set(missing.map(k => k.slice(0, k.lastIndexOf(':')))));
|
|
48
|
-
void fetchMeanings(words).then(got => {
|
|
71
|
+
void Promise.resolve().then(() => fetchMeanings(words)).then(got => {
|
|
49
72
|
/* 받은 것은 **취소됐어도 캐시에 담는다** — 쪽을 빨리 넘기면 앞 조회가 취소되며 받은 결과까지 버려, 돌아오면 또
|
|
50
73
|
조회했다. 화면 갱신(setFetched)만 취소된 쪽에서 건너뛴다. */
|
|
51
74
|
for (const row of got) {
|
|
@@ -63,18 +86,22 @@ export function useWordMeanings(rows, fetchMeanings) {
|
|
|
63
86
|
const t = Date.now();
|
|
64
87
|
for (const k of missing)
|
|
65
88
|
emptyAt.set(k, t);
|
|
89
|
+
// 화면을 그대로 보고 있어도 유예가 끝나면 다시 묻는다.
|
|
90
|
+
scheduleRetry();
|
|
66
91
|
}
|
|
67
92
|
if (cancelled || got.length === 0)
|
|
68
93
|
return;
|
|
69
94
|
setFetched(v => v + 1);
|
|
70
95
|
}).catch(() => {
|
|
71
96
|
// 조회 함수가 약속을 어기고 거절해도(망 끊김 등) 처리되지 않은 거절로 새지 않게 삼킨다 —
|
|
72
|
-
// 뜻은 있으면 좋은 것이라 빈 결과와 같게 다룬다.
|
|
97
|
+
// 뜻은 있으면 좋은 것이라 빈 결과와 같게 다룬다. 같은 화면에서도 잠시 뒤 다시 묻는다.
|
|
98
|
+
scheduleRetry();
|
|
73
99
|
});
|
|
74
|
-
return () => { cancelled = true;
|
|
75
|
-
|
|
100
|
+
return () => { cancelled = true; if (retryTimer !== null)
|
|
101
|
+
clearTimeout(retryTimer); };
|
|
102
|
+
}, [wantedKey, fetchMeanings, retry]);
|
|
76
103
|
return useMemo(() => {
|
|
77
|
-
const wanted =
|
|
104
|
+
const wanted = JSON.parse(wantedKey);
|
|
78
105
|
const out = new Map();
|
|
79
106
|
for (const k of wanted) {
|
|
80
107
|
const v = memCache.get(k);
|
|
@@ -257,12 +257,16 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
|
|
|
257
257
|
if (cancelled)
|
|
258
258
|
return;
|
|
259
259
|
setViable(v);
|
|
260
|
-
// 빈 결과 = 단어 풀 미로드 상태.
|
|
261
|
-
|
|
262
|
-
|
|
260
|
+
// 빈 결과 = 단어 풀 미로드 상태. 앞서 한 번 준비됐더라도 갱신 중엔 다시
|
|
261
|
+
// 준비 안 됨으로 돌려 저장된 음소 선택을 빈 집합으로 검증하지 않는다.
|
|
262
|
+
setViableReady(v.cho.size + v.jung.size + v.jong.size > 0);
|
|
263
263
|
})
|
|
264
|
-
.catch(() => {
|
|
265
|
-
|
|
264
|
+
.catch(() => {
|
|
265
|
+
if (!cancelled) {
|
|
266
|
+
setViable({ cho: new Set(), jung: new Set(), jong: new Set(), syllable: new Set() });
|
|
267
|
+
setViableReady(false);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
266
270
|
return () => { cancelled = true; };
|
|
267
271
|
}, [caps.choseong, difficulty, excludeNoImage, poolVersion]);
|
|
268
272
|
// 현재 필터 조건으로 가능한 정답 단어 수 계산 (버튼 선택이 바뀔 때마다)
|
|
@@ -108,8 +108,12 @@ export async function loadTrainingData() {
|
|
|
108
108
|
중복이 쌓였다(하루 선택이 중복 문항을 뽑고 기기마다 세트가 달라짐). */
|
|
109
109
|
const received = { noise: [], short: [], long: [], order: [] };
|
|
110
110
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
if (typeof map !== 'object' || Array.isArray(map))
|
|
112
|
+
throw new Error('Invalid training data map');
|
|
113
|
+
for (const key of Object.keys(map)) {
|
|
114
|
+
const groups = map[key];
|
|
115
|
+
if (!Array.isArray(groups))
|
|
116
|
+
throw new Error('Invalid training data group');
|
|
113
117
|
for (const g of groups) {
|
|
114
118
|
// 한 행이 null·글자면 건너뛴다 — 그 한 행의 TypeError 로 자료 전체를 버리고 매번 같은 실패를 되풀이했다
|
|
115
119
|
if (!g || typeof g !== 'object')
|
|
@@ -154,6 +158,9 @@ export async function loadTrainingData() {
|
|
|
154
158
|
ORDER_SETS.push(...received.order);
|
|
155
159
|
// 다 채운 뒤 _loaded 를 먼저 세운다 — 'updated' 를 받은 쪽이 isTrainingDataLoaded() 로 확인하면 아직 false 였다
|
|
156
160
|
_loaded = true;
|
|
161
|
+
// 데이터가 오기 전에 계산한 빈 세트가 같은 날짜·개수의 캐시로 남을 수 있다.
|
|
162
|
+
// 어제 세트도 옛 풀을 재사용하지 않도록 캐시 전체를 무효화한다.
|
|
163
|
+
_cachedSelection = null;
|
|
157
164
|
try {
|
|
158
165
|
computeDailySelection();
|
|
159
166
|
}
|
|
@@ -27,7 +27,14 @@ function ensureLoaded() {
|
|
|
27
27
|
const v = raw ? JSON.parse(raw) : null;
|
|
28
28
|
/* 객체가 아닌 JSON(null·"x"·42·배열)이 들어 있으면 `k in map`·Object.values 가 부를 때마다 던지고 memMap 이 null 로 남아
|
|
29
29
|
매번 다시 파싱했다 — 확장 프로그램·손으로 고친 값이라도 캐시 비우기 전까지 이미지 확인·훈련 생성이 막혔다. */
|
|
30
|
-
|
|
30
|
+
const valid = {};
|
|
31
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
32
|
+
for (const [key, value] of Object.entries(v)) {
|
|
33
|
+
if (typeof value === 'boolean')
|
|
34
|
+
valid[key] = value;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
memMap = valid;
|
|
31
38
|
}
|
|
32
39
|
catch {
|
|
33
40
|
memMap = {};
|
|
@@ -36,7 +43,7 @@ function ensureLoaded() {
|
|
|
36
43
|
}
|
|
37
44
|
let savePending = null;
|
|
38
45
|
function scheduleSave() {
|
|
39
|
-
if (savePending)
|
|
46
|
+
if (savePending !== null)
|
|
40
47
|
return;
|
|
41
48
|
savePending = setTimeout(() => {
|
|
42
49
|
savePending = null;
|
|
@@ -51,31 +58,42 @@ function scheduleSave() {
|
|
|
51
58
|
function keyOf(word, homonum) {
|
|
52
59
|
return `${word}_${homonum ?? 1}`;
|
|
53
60
|
}
|
|
54
|
-
|
|
55
|
-
* @public
|
|
56
|
-
* 단어 이미지 존재 여부 확인 — localStorage 캐시 우선, 없으면 HEAD 요청 후 캐시 저장.
|
|
57
|
-
* 한 번 확인된 단어는 영구 캐싱되어 다음 접속 시에도 즉시 반환.
|
|
58
|
-
*/
|
|
59
|
-
export async function checkImageExists(word, homonum) {
|
|
61
|
+
async function probeImageExists(word, homonum, force = false) {
|
|
60
62
|
const map = ensureLoaded();
|
|
61
63
|
const k = keyOf(word, homonum);
|
|
62
|
-
if (k in map)
|
|
64
|
+
if (!force && k in map)
|
|
63
65
|
return map[k];
|
|
64
|
-
if (inflight.has(k))
|
|
66
|
+
if (!force && inflight.has(k))
|
|
65
67
|
return inflight.get(k);
|
|
66
68
|
const resolver = getImageResolver();
|
|
67
69
|
if (!resolver)
|
|
68
70
|
return false;
|
|
69
|
-
|
|
71
|
+
let url;
|
|
72
|
+
try {
|
|
73
|
+
url = resolver(word, homonum);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
70
78
|
if (!url)
|
|
71
79
|
return false;
|
|
72
80
|
// 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
|
|
73
81
|
// 검색 버튼(이미지 있음 필터)이 영구 비활성화된다
|
|
74
|
-
/* **확인이 안 된 것은 캐시하지 않는다.** 망
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
/* **확인이 안 된 것은 캐시하지 않는다.** 망 끊김·타임아웃·권한 오류·5xx 는 이미지가 없다는 증거가 아니다.
|
|
83
|
+
있음(2xx)과 명시적 없음(404·410)만 담는다. */
|
|
84
|
+
let request;
|
|
85
|
+
try {
|
|
86
|
+
request = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const p = request
|
|
92
|
+
.then(res => (res.ok ? true : res.status === 404 || res.status === 410 ? false : null), () => null)
|
|
78
93
|
.then(ok => {
|
|
94
|
+
// 강제 재확인이 앞 요청을 대체했거나 캐시를 비웠다면 늦은 응답으로 새 값을 덮지 않는다.
|
|
95
|
+
if (inflight.get(k) !== p)
|
|
96
|
+
return ok ?? false;
|
|
79
97
|
inflight.delete(k);
|
|
80
98
|
if (ok === null)
|
|
81
99
|
return false;
|
|
@@ -87,6 +105,14 @@ export async function checkImageExists(word, homonum) {
|
|
|
87
105
|
inflight.set(k, p);
|
|
88
106
|
return p;
|
|
89
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* @public
|
|
110
|
+
* 단어 이미지 존재 여부 확인 — localStorage 캐시 우선, 없으면 HEAD 요청 후 캐시 저장.
|
|
111
|
+
* 한 번 확인된 단어는 영구 캐싱되어 다음 접속 시에도 즉시 반환.
|
|
112
|
+
*/
|
|
113
|
+
export async function checkImageExists(word, homonum) {
|
|
114
|
+
return probeImageExists(word, homonum);
|
|
115
|
+
}
|
|
90
116
|
/** @public 단어 이미지 존재 여부 동기 조회 (캐시에 있을 때만). 없으면 undefined. */
|
|
91
117
|
export function getImageExistsCached(word, homonum) {
|
|
92
118
|
const map = ensureLoaded();
|
|
@@ -107,7 +133,8 @@ export async function prewarmImageCache(words, options = {}) {
|
|
|
107
133
|
let done = 0;
|
|
108
134
|
if (todo.length > 0) {
|
|
109
135
|
let idx = 0;
|
|
110
|
-
const
|
|
136
|
+
const workerCount = Math.min(todo.length, Math.max(1, Number.isFinite(concurrency) ? Math.floor(concurrency) : 16));
|
|
137
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
111
138
|
while (idx < todo.length) {
|
|
112
139
|
const i = idx++;
|
|
113
140
|
const w = todo[i];
|
|
@@ -118,7 +145,7 @@ export async function prewarmImageCache(words, options = {}) {
|
|
|
118
145
|
continue;
|
|
119
146
|
}
|
|
120
147
|
try {
|
|
121
|
-
await
|
|
148
|
+
await probeImageExists(w, undefined, force);
|
|
122
149
|
}
|
|
123
150
|
catch { }
|
|
124
151
|
done++;
|
|
@@ -170,6 +197,9 @@ export function getImageOkWordSet() {
|
|
|
170
197
|
*/
|
|
171
198
|
export function bulkSetImageExistence(entries) {
|
|
172
199
|
const map = ensureLoaded();
|
|
200
|
+
// 일괄 주입이 정본이다. 앞서 시작한 HEAD가 늦게 끝나 새 값을 덮지 못하게 한다.
|
|
201
|
+
for (const k of Object.keys(entries))
|
|
202
|
+
inflight.delete(k);
|
|
173
203
|
Object.assign(map, entries);
|
|
174
204
|
cacheVersion++;
|
|
175
205
|
try {
|
|
@@ -181,7 +211,12 @@ export function bulkSetImageExistence(entries) {
|
|
|
181
211
|
}
|
|
182
212
|
/** @public 캐시 초기화 (이미지 데이터 변경 시 호출) */
|
|
183
213
|
export function clearImageExistenceCache() {
|
|
214
|
+
if (savePending !== null) {
|
|
215
|
+
clearTimeout(savePending);
|
|
216
|
+
savePending = null;
|
|
217
|
+
}
|
|
184
218
|
memMap = {};
|
|
219
|
+
inflight.clear();
|
|
185
220
|
cacheVersion++;
|
|
186
221
|
try {
|
|
187
222
|
if (typeof localStorage !== 'undefined')
|
|
@@ -27,9 +27,16 @@
|
|
|
27
27
|
*/
|
|
28
28
|
export function prefetchChunk(...loads) {
|
|
29
29
|
let cancelled = false;
|
|
30
|
-
const load = () => {
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const load = () => {
|
|
31
|
+
if (cancelled)
|
|
32
|
+
return;
|
|
33
|
+
for (const load of loads) {
|
|
34
|
+
try {
|
|
35
|
+
void load().catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
catch { /* 미리 받기 실패는 화면 전환 때 다시 시도한다 */ }
|
|
38
|
+
}
|
|
39
|
+
};
|
|
33
40
|
const idle = globalThis.requestIdleCallback;
|
|
34
41
|
if (idle) {
|
|
35
42
|
const id = idle(load, { timeout: 2000 });
|
|
@@ -15,40 +15,64 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
15
15
|
}
|
|
16
16
|
const synth = window.speechSynthesis;
|
|
17
17
|
clearPendingWait();
|
|
18
|
+
let finished = false;
|
|
19
|
+
const finish = () => {
|
|
20
|
+
if (finished)
|
|
21
|
+
return;
|
|
22
|
+
finished = true;
|
|
23
|
+
try {
|
|
24
|
+
onEnd?.();
|
|
25
|
+
}
|
|
26
|
+
catch (e) { }
|
|
27
|
+
};
|
|
28
|
+
// 새 말을 위한 목소리 목록을 기다리는 동안에도 이전 발화는 바로 멈춘다.
|
|
29
|
+
try {
|
|
30
|
+
synth.cancel();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
finish();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
18
36
|
const doSpeak = () => {
|
|
19
37
|
clearPendingWait();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
u.onerror = onEnd
|
|
37
|
-
? function () {
|
|
38
|
-
try {
|
|
39
|
-
onEnd();
|
|
40
|
-
}
|
|
41
|
-
catch (e) { }
|
|
42
|
-
}
|
|
43
|
-
: null;
|
|
44
|
-
synth.speak(u);
|
|
38
|
+
try {
|
|
39
|
+
synth.cancel();
|
|
40
|
+
const u = new SpeechSynthesisUtterance(text);
|
|
41
|
+
const voices = synth.getVoices();
|
|
42
|
+
const ko = voices.find(v => v.lang.startsWith('ko'));
|
|
43
|
+
if (ko)
|
|
44
|
+
u.voice = ko;
|
|
45
|
+
u.rate = rate;
|
|
46
|
+
u.onend = onEnd ? finish : null;
|
|
47
|
+
// 합성이 실패하면 end 없이 error 만 온다 — 끝난 것으로 알린다(안 그러면 onEnd 를 기다리는 쪽이 멈춘 채)
|
|
48
|
+
u.onerror = onEnd ? finish : null;
|
|
49
|
+
synth.speak(u);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
finish();
|
|
53
|
+
}
|
|
45
54
|
};
|
|
46
|
-
|
|
55
|
+
let voices;
|
|
56
|
+
try {
|
|
57
|
+
voices = synth.getVoices();
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
finish();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (voices.length > 0) {
|
|
47
64
|
doSpeak();
|
|
48
65
|
return;
|
|
49
66
|
}
|
|
50
67
|
const timer = setTimeout(doSpeak, VOICE_WAIT_MS);
|
|
51
|
-
|
|
68
|
+
try {
|
|
69
|
+
synth.addEventListener('voiceschanged', doSpeak, { once: true });
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
finish();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
52
76
|
pendingWait = {
|
|
53
77
|
clear: () => {
|
|
54
78
|
clearTimeout(timer);
|
|
@@ -76,7 +76,7 @@ export async function checkAudioExists(word) {
|
|
|
76
76
|
return audioExistsCache.get(url);
|
|
77
77
|
// 타임아웃 없으면 HEAD 요청 하나가 응답 없이 걸릴 때 이 확인을 기다리는
|
|
78
78
|
// 검색 버튼(음성 있음 필터)이 영구 비활성화된다
|
|
79
|
-
//
|
|
79
|
+
// 성공(2xx)과 명시적 부재(404·410)만 캐시에 남긴다. 권한 오류·네트워크 실패·타임아웃·5xx 까지 캐시하면
|
|
80
80
|
// 한 번의 통신 끊김·서버 장애가 그 단어를 세션 내내 '음성 없음'으로 고정해, '음성 있음' 필터에서
|
|
81
81
|
// 통째로 빠진다. 실패는 지워서 다음 호출이 다시 물어보게 한다 — 단 **이 요청이 담은 칸일 때만**:
|
|
82
82
|
// clearAudioExistsCache() 뒤 새로 담긴 확인을 늦게 끝난 옛 요청이 지우면 같은 주소를 또 묻는다.
|
|
@@ -85,8 +85,15 @@ export async function checkAudioExists(word) {
|
|
|
85
85
|
audioExistsCache.delete(url);
|
|
86
86
|
return false;
|
|
87
87
|
};
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
let request;
|
|
89
|
+
try {
|
|
90
|
+
request = fetch(url, { method: 'HEAD', signal: timeoutSignal(8000) });
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
const promise = request
|
|
96
|
+
.then(res => (res.ok ? true : res.status === 404 || res.status === 410 ? false : onUnconfirmed()), onUnconfirmed);
|
|
90
97
|
audioExistsCache.set(url, promise);
|
|
91
98
|
return promise;
|
|
92
99
|
}
|
|
@@ -19,6 +19,20 @@ const LS_PAIR_INDEX = 'word_pair_index_v2';
|
|
|
19
19
|
const LS_PAIR_INDEX_SIG = 'word_pair_index_sig_v2';
|
|
20
20
|
let memIndex = null;
|
|
21
21
|
let memSig = null;
|
|
22
|
+
function isPairIndex(value) {
|
|
23
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
24
|
+
return false;
|
|
25
|
+
for (const entry of Object.values(value)) {
|
|
26
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
|
27
|
+
return false;
|
|
28
|
+
const parts = entry;
|
|
29
|
+
for (const kind of ['cho', 'jung', 'jong']) {
|
|
30
|
+
if (!Array.isArray(parts[kind]) || !parts[kind].every((word) => typeof word === 'string'))
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
22
36
|
function loadFromLocalStorage() {
|
|
23
37
|
try {
|
|
24
38
|
if (typeof localStorage === 'undefined')
|
|
@@ -27,7 +41,8 @@ function loadFromLocalStorage() {
|
|
|
27
41
|
const sig = localStorage.getItem(LS_PAIR_INDEX_SIG);
|
|
28
42
|
if (!raw || !sig)
|
|
29
43
|
return null;
|
|
30
|
-
|
|
44
|
+
const index = JSON.parse(raw);
|
|
45
|
+
return isPairIndex(index) ? { index, sig } : null;
|
|
31
46
|
}
|
|
32
47
|
catch {
|
|
33
48
|
return null;
|