@ihabdevteam/core 0.152.0 → 0.154.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 +31 -0
- package/dist/components.cjs.js +178 -173
- package/dist/components.esm.css +3 -3
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +186 -181
- package/dist/ihabdevteam-core.d.ts +37 -3
- package/dist/packages/core/src/components/Table.js +41 -15
- package/dist/packages/core/src/components/TableField.css +32 -0
- package/dist/packages/core/src/components/WordTable.css +48 -1
- package/dist/packages/core/src/components/WordTable.d.ts +7 -1
- package/dist/packages/core/src/components/WordTable.js +168 -10
- package/dist/packages/core/src/hooks/useTrainingSessionLog.d.ts +16 -2
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +34 -11
- package/dist/packages/core/src/i18n/locales/en.json +5 -0
- package/dist/packages/core/src/i18n/locales/ko.json +5 -0
- package/dist/packages/core/src/utils/lucideIconSet.js +2 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +73 -52
- package/dist/packages/core/src/utils/planFeatures.d.ts +28 -1
- package/dist/packages/core/src/utils/planFeatures.js +38 -8
- package/dist/packages/core/src/utils/wordTrainingPool.js +31 -3
- package/dist/packages/core/src/utils.d.ts +1 -1
- package/dist/packages/core/src/utils.js +1 -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 +2 -2
- package/package.json +1 -1
|
@@ -836,6 +836,11 @@
|
|
|
836
836
|
"pronoun": "대명사",
|
|
837
837
|
"auxVerb": "보조동사",
|
|
838
838
|
"auxAdjective": "보조형용사"
|
|
839
|
+
},
|
|
840
|
+
"reorder": {
|
|
841
|
+
"moved": "{{total}}칸 가운데 {{n}}번째로 옮김",
|
|
842
|
+
"handle": "{{word}} 순서 바꾸기 (Alt + 화살표)",
|
|
843
|
+
"hint": "Alt + ← → 로 순서 바꾸기"
|
|
839
844
|
}
|
|
840
845
|
},
|
|
841
846
|
"wordSearch": {
|
|
@@ -72,6 +72,7 @@ import GamepadIcon from 'lucide-react/dist/esm/icons/gamepad.mjs';
|
|
|
72
72
|
import GavelIcon from 'lucide-react/dist/esm/icons/gavel.mjs';
|
|
73
73
|
import GitMergeIcon from 'lucide-react/dist/esm/icons/git-merge.mjs';
|
|
74
74
|
import GlobeIcon from 'lucide-react/dist/esm/icons/globe.mjs';
|
|
75
|
+
import GripVerticalIcon from 'lucide-react/dist/esm/icons/grip-vertical.mjs';
|
|
75
76
|
import HeadphonesIcon from 'lucide-react/dist/esm/icons/headphones.mjs';
|
|
76
77
|
import HeartIcon from 'lucide-react/dist/esm/icons/heart.mjs';
|
|
77
78
|
import HomeIcon from 'lucide-react/dist/esm/icons/home.mjs';
|
|
@@ -256,6 +257,7 @@ export const STATIC_LUCIDE_ICONS = {
|
|
|
256
257
|
'gavel': GavelIcon,
|
|
257
258
|
'git-merge': GitMergeIcon,
|
|
258
259
|
'globe': GlobeIcon,
|
|
260
|
+
'grip-vertical': GripVerticalIcon,
|
|
259
261
|
'headphones': HeadphonesIcon,
|
|
260
262
|
'heart': HeartIcon,
|
|
261
263
|
'home': HomeIcon,
|
|
@@ -324,10 +324,10 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
324
324
|
return Number.isFinite(parsed) ? parsed : 99;
|
|
325
325
|
};
|
|
326
326
|
const groups = new Map();
|
|
327
|
+
/* 표기가 같아도 뜻마다 칸이 하나다 — 판정은 대표로 한 번만 하고 결과에서 뜻을 펼친다 */
|
|
328
|
+
const { 대표들, 뜻들 } = 뜻묶기(words);
|
|
327
329
|
const seenWords = new Set();
|
|
328
|
-
for (const entry of
|
|
329
|
-
if (seenWords.has(entry.word))
|
|
330
|
-
continue;
|
|
330
|
+
for (const entry of 대표들) {
|
|
331
331
|
seenWords.add(entry.word);
|
|
332
332
|
const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
|
|
333
333
|
const syllables = [...cleanPron(entry.pronunciation)].map(decomposeFn);
|
|
@@ -381,15 +381,17 @@ function searchPairsCore(selectedList, words, debug, enforcePosition, phonemic =
|
|
|
381
381
|
const sDiffSection = Object.keys(analysis.diff.diffs)[0];
|
|
382
382
|
const sDiffVals = analysis.diff.diffs[sDiffSection];
|
|
383
383
|
const sDiffHasEmpty = diffHasEmptyPair(sDiffSection, sDiffVals);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
384
|
+
// 뜻마다 한 칸 — '눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍을 이룬다
|
|
385
|
+
for (const f of 뜻들.get(first) ?? [first]) {
|
|
386
|
+
for (const sec of 뜻들.get(second) ?? [second]) {
|
|
387
|
+
results.push({
|
|
388
|
+
id: `${낱말열쇠(f)}|${낱말열쇠(sec)}`,
|
|
389
|
+
diffSection: sDiffSection,
|
|
390
|
+
diffHasEmpty: sDiffHasEmpty,
|
|
391
|
+
words: [낱말칸(f), 낱말칸(sec)],
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
}
|
|
393
395
|
}
|
|
394
396
|
}
|
|
395
397
|
}
|
|
@@ -426,6 +428,41 @@ export function searchPairs(selectedList, words, enforcePosition, phonemic = fal
|
|
|
426
428
|
}
|
|
427
429
|
// ── 포함 검색: 음소를 모두 포함하는 단어의 대립쌍 ─────────────
|
|
428
430
|
// 슬롯 i → 단어의 i번째 음절에 조건 매칭 (순서 보장)
|
|
431
|
+
/** 표기가 같아도 뜻이 다르면 다른 칸 — 칸에 붙는 그림이 뜻마다 다르다(사용자 결정 2026-09-16).
|
|
432
|
+
* 동음이의 번호가 없는 자료는 예전처럼 표기뿐이다. */
|
|
433
|
+
function 낱말열쇠(entry) {
|
|
434
|
+
return entry.homonum != null ? `${entry.word}#${entry.homonum}` : entry.word;
|
|
435
|
+
}
|
|
436
|
+
/** 표기·발음이 같은 뜻들을 한 대표로 묶는다 — 최소대립쌍 판정은 표기와 발음만 보므로 뜻마다 다시 셀 까닭이 없다.
|
|
437
|
+
* (뜻을 모두 후보로 넣으면 묶음이 커져 23,000 낱말에서 검색이 2–4배 느려졌다.) 결과를 만들 때 뜻마다 칸을 펼친다. */
|
|
438
|
+
/** 결과 칸 하나 */
|
|
439
|
+
function 낱말칸(e) {
|
|
440
|
+
return {
|
|
441
|
+
label: e.word, subLabel: e.pronunciation, colloquialLabel: e.colloquialPronunciation, homonum: e.homonum,
|
|
442
|
+
grade: e.grade, syllables: [...e.word].length, pos: e.pos, wordType: e.wordType, isCustom: e.isCustom,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function 뜻묶기(words) {
|
|
446
|
+
const 자리 = new Map();
|
|
447
|
+
const 뜻들 = new Map();
|
|
448
|
+
const 대표들 = [];
|
|
449
|
+
for (const entry of words) {
|
|
450
|
+
const 열쇠 = `${entry.word}\u0000${cleanPron(entry.pronunciation)}`;
|
|
451
|
+
const 대표 = 자리.get(열쇠);
|
|
452
|
+
if (대표 === undefined) {
|
|
453
|
+
자리.set(열쇠, entry);
|
|
454
|
+
대표들.push(entry);
|
|
455
|
+
뜻들.set(entry, [entry]);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const 목록 = 뜻들.get(대표);
|
|
459
|
+
// 같은 번호가 두 번 오면(자료 중복) 한 번만
|
|
460
|
+
if (목록.some(e => 낱말열쇠(e) === 낱말열쇠(entry)))
|
|
461
|
+
continue;
|
|
462
|
+
목록.push(entry);
|
|
463
|
+
}
|
|
464
|
+
return { 대표들, 뜻들 };
|
|
465
|
+
}
|
|
429
466
|
function wordContainsAllPhonemes(syllables, requirements) {
|
|
430
467
|
for (let i = 0; i < requirements.length; i++) {
|
|
431
468
|
const entries = Object.entries(requirements[i]).filter(([k]) => isSectionKey(k));
|
|
@@ -467,10 +504,9 @@ preferAsTarget) {
|
|
|
467
504
|
const makeKey = (syls, pos) => syls.map((s, i) => (i === pos ? '*' : encodeAll(s))).join('|');
|
|
468
505
|
const decomposeFn = phonemic ? decomposePhonemicSyllable : decompose;
|
|
469
506
|
const allWords = [];
|
|
507
|
+
const { 대표들, 뜻들 } = 뜻묶기(words);
|
|
470
508
|
const seenWords = new Set();
|
|
471
|
-
for (const entry of
|
|
472
|
-
if (seenWords.has(entry.word))
|
|
473
|
-
continue;
|
|
509
|
+
for (const entry of 대표들) {
|
|
474
510
|
seenWords.add(entry.word);
|
|
475
511
|
const syls = [...cleanPron(entry.pronunciation)].map(decomposeFn);
|
|
476
512
|
if (syls.some(s => s === null))
|
|
@@ -500,8 +536,8 @@ preferAsTarget) {
|
|
|
500
536
|
groups.get(key).push(w);
|
|
501
537
|
}
|
|
502
538
|
}
|
|
503
|
-
const
|
|
504
|
-
// dedupKey →
|
|
539
|
+
const 쌍들 = [];
|
|
540
|
+
// dedupKey → 쌍들 배열 내 인덱스. 같은 두 단어가 서로 minimal pair이면서
|
|
505
541
|
// 둘 다 독립적으로 targetWords에 속할 수 있어(예: 다른 음절에 요구 음소가 하나 더 있는 경우),
|
|
506
542
|
// 어느 쪽을 target(1번/words[0])으로 채택할지가 순회 순서에 좌우될 수 있다.
|
|
507
543
|
// preferAsTarget이 주어지면(예: "검색 단어만" 필터) 그 조건을 만족하는 방향을 우선한다.
|
|
@@ -537,7 +573,7 @@ preferAsTarget) {
|
|
|
537
573
|
if (existingIdx !== undefined) {
|
|
538
574
|
if (!preferAsTarget)
|
|
539
575
|
continue;
|
|
540
|
-
if (preferAsTarget(
|
|
576
|
+
if (preferAsTarget(낱말칸(쌍들[existingIdx].target)))
|
|
541
577
|
continue;
|
|
542
578
|
if (!preferAsTarget({ grade: target.entry.grade, syllables: [...target.entry.word].length, pos: target.entry.pos, wordType: target.entry.wordType }))
|
|
543
579
|
continue;
|
|
@@ -572,47 +608,32 @@ preferAsTarget) {
|
|
|
572
608
|
const diffSection = Object.keys(analysis.diff.diffs)[0];
|
|
573
609
|
const diffVals = analysis.diff.diffs[diffSection];
|
|
574
610
|
const diffHasEmpty = diffHasEmptyPair(diffSection, diffVals);
|
|
575
|
-
const item = {
|
|
576
|
-
/* 정렬 꼴 그대로 — 기준 방향을 넣으면 음소를 ㄱ→ㄴ 으로 바꿔 찾거나 저장한 표를 다시 열 때 같은 쌍의 id 가 달라져
|
|
577
|
-
바구니(id 로 견줌)에서 선택이 풀리고 두 번 담겼다. 이미 저장된 대립쌍표 id 도 이 꼴이다. */
|
|
578
|
-
id: dedupKey,
|
|
579
|
-
diffSection,
|
|
580
|
-
diffHasEmpty,
|
|
581
|
-
words: [
|
|
582
|
-
{
|
|
583
|
-
label: target.entry.word,
|
|
584
|
-
subLabel: target.entry.pronunciation,
|
|
585
|
-
colloquialLabel: target.entry.colloquialPronunciation,
|
|
586
|
-
homonum: target.entry.homonum,
|
|
587
|
-
grade: target.entry.grade,
|
|
588
|
-
syllables: [...target.entry.word].length,
|
|
589
|
-
pos: target.entry.pos,
|
|
590
|
-
wordType: target.entry.wordType,
|
|
591
|
-
isCustom: target.entry.isCustom,
|
|
592
|
-
},
|
|
593
|
-
{
|
|
594
|
-
label: candidate.entry.word,
|
|
595
|
-
subLabel: candidate.entry.pronunciation,
|
|
596
|
-
colloquialLabel: candidate.entry.colloquialPronunciation,
|
|
597
|
-
homonum: candidate.entry.homonum,
|
|
598
|
-
grade: candidate.entry.grade,
|
|
599
|
-
syllables: [...candidate.entry.word].length,
|
|
600
|
-
pos: candidate.entry.pos,
|
|
601
|
-
wordType: candidate.entry.wordType,
|
|
602
|
-
isCustom: candidate.entry.isCustom,
|
|
603
|
-
},
|
|
604
|
-
],
|
|
605
|
-
};
|
|
611
|
+
const item = { target: target.entry, candidate: candidate.entry, diffSection, diffHasEmpty };
|
|
606
612
|
if (existingIdx !== undefined) {
|
|
607
|
-
|
|
613
|
+
쌍들[existingIdx] = item;
|
|
608
614
|
}
|
|
609
615
|
else {
|
|
610
|
-
seenPairs.set(dedupKey,
|
|
611
|
-
|
|
616
|
+
seenPairs.set(dedupKey, 쌍들.length);
|
|
617
|
+
쌍들.push(item);
|
|
612
618
|
}
|
|
613
619
|
}
|
|
614
620
|
}
|
|
615
621
|
}
|
|
622
|
+
/* 뜻마다 한 칸 — 기준·짝의 뜻을 모두 펼친다('눈'(신체)·'눈'(날씨) 가 각각 '문' 과 쌍). id 는 정렬 꼴에 번호까지. */
|
|
623
|
+
const results = [];
|
|
624
|
+
for (const 것 of 쌍들) {
|
|
625
|
+
for (const t of 뜻들.get(것.target) ?? [것.target]) {
|
|
626
|
+
for (const c of 뜻들.get(것.candidate) ?? [것.candidate]) {
|
|
627
|
+
const kt = 낱말열쇠(t), kc = 낱말열쇠(c);
|
|
628
|
+
results.push({
|
|
629
|
+
id: kt < kc ? `${kt}|${kc}` : `${kc}|${kt}`,
|
|
630
|
+
diffSection: 것.diffSection,
|
|
631
|
+
diffHasEmpty: 것.diffHasEmpty,
|
|
632
|
+
words: [낱말칸(t), 낱말칸(c)],
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
616
637
|
const gradeOrder = (g) => {
|
|
617
638
|
const parsed = Number.parseInt(g ?? '', 10);
|
|
618
639
|
return Number.isFinite(parsed) ? parsed : 99;
|
|
@@ -5,6 +5,15 @@ export interface PlanFeatures {
|
|
|
5
5
|
maxCustomers: number;
|
|
6
6
|
visitWord: boolean;
|
|
7
7
|
visitSentence: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 숙제내주기(쿠폰) — **노출 여부만** 말한다.
|
|
10
|
+
*
|
|
11
|
+
* 하루 몇 번 보낼 수 있는지는 `PLAN_LIMITS[tier].homeworkSendDaily` 가 정본이고,
|
|
12
|
+
* 어드민 요금제 비교표의 '숙제 보내기' 칸도 그 숫자로 그린다. 이 플래그는 그 숫자가
|
|
13
|
+
* 있는 티어에서 켜져 있어야 한다 — 전에는 mgr_free·lite·standard 가 하루 1·10·30회를
|
|
14
|
+
* 받으면서도 false 라, 같은 화면이 한 칸에서는 횟수를 말하고 옆 칸에서는 ✗ 를 말했다
|
|
15
|
+
* (사용자 결정 2026-09-16). 숫자를 고칠 때는 이 플래그도 같이 본다.
|
|
16
|
+
*/
|
|
8
17
|
homework: boolean;
|
|
9
18
|
customerStats: boolean;
|
|
10
19
|
managerCustomerSplit: boolean;
|
|
@@ -32,8 +41,26 @@ export interface PlanFeatures {
|
|
|
32
41
|
sentenceTrainingView: boolean;
|
|
33
42
|
}
|
|
34
43
|
export declare const PLAN_FEATURES: Record<PlanTier, PlanFeatures>;
|
|
35
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* 요금제 티어 순위 — **같은 줄 안에서 견주는 값**이다(클수록 상위 요금제).
|
|
46
|
+
*
|
|
47
|
+
* 줄마다 자리를 갈라 둔다: 개인 줄 0~9, 매니저 줄 10~19. 전에는 한 자리에 섞여 있어
|
|
48
|
+
* (personal_standard 3 < mgr_free 4) 줄이 다른 두 티어의 순위 차가 무슨 뜻인지 읽을 수
|
|
49
|
+
* 없었다(사용자 결정 2026-09-16). 줄이 같은지는 `isManagerTier` 로 본다.
|
|
50
|
+
*
|
|
51
|
+
* 줄 사이의 크기 비교(매니저 줄이 개인 줄보다 큼)는 전과 같은 방향이라, 활성 구독 중
|
|
52
|
+
* 가장 높은 티어를 고르는 코드는 그대로 동작한다 — 다만 그런 비교는 "매니저 줄이
|
|
53
|
+
* 개인 줄보다 상위"라는 뜻이지 기능 수의 많고 적음이 아니다.
|
|
54
|
+
* 레거시 라이트(낱말·문장)는 서로 대등해 같은 자리를 쓴다.
|
|
55
|
+
*/
|
|
36
56
|
export declare const TIER_RANK: Record<PlanTier, number>;
|
|
57
|
+
/**
|
|
58
|
+
* @public 티어가 매니저 줄(말귀매니저)인지. `none` 과 개인 줄(free·personal_standard)은 false.
|
|
59
|
+
*
|
|
60
|
+
* 요금제 비교표처럼 "이 칸은 매니저 플랜에만 있다" 를 그리는 곳이 티어 목록을 손으로
|
|
61
|
+
* 들고 있지 않도록 여기서 내준다 — 티어가 늘면 그 목록만 뒤처진다.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isManagerTier(tier: PlanTier): boolean;
|
|
37
64
|
/**
|
|
38
65
|
* 요금제별 수량/횟수 제한.
|
|
39
66
|
*
|
|
@@ -27,7 +27,7 @@ export const PLAN_FEATURES = {
|
|
|
27
27
|
},
|
|
28
28
|
// ── 매니저 플랜 ─────────────────────────────────────────────────
|
|
29
29
|
mgr_free: {
|
|
30
|
-
maxCustomers: 5, visitWord: true, visitSentence: true, homework:
|
|
30
|
+
maxCustomers: 5, visitWord: true, visitSentence: true, homework: true,
|
|
31
31
|
customerStats: false, managerCustomerSplit: false, managerManagement: false,
|
|
32
32
|
sentenceTrainingSetting: false, wordTrainingSetting: false, myWordList: false,
|
|
33
33
|
wordListShare: false, groupTrainingSetting: false, minimalPairSearch: true,
|
|
@@ -75,7 +75,7 @@ export const PLAN_FEATURES = {
|
|
|
75
75
|
sentenceTrainingView: true,
|
|
76
76
|
},
|
|
77
77
|
lite: {
|
|
78
|
-
maxCustomers: 10, visitWord: true, visitSentence: true, homework:
|
|
78
|
+
maxCustomers: 10, visitWord: true, visitSentence: true, homework: true,
|
|
79
79
|
customerStats: false, managerCustomerSplit: false, managerManagement: false,
|
|
80
80
|
sentenceTrainingSetting: false, wordTrainingSetting: false, myWordList: false,
|
|
81
81
|
wordListShare: false, groupTrainingSetting: false, minimalPairSearch: true,
|
|
@@ -87,7 +87,7 @@ export const PLAN_FEATURES = {
|
|
|
87
87
|
sentenceTrainingView: true,
|
|
88
88
|
},
|
|
89
89
|
standard: {
|
|
90
|
-
maxCustomers: 30, visitWord: true, visitSentence: true, homework:
|
|
90
|
+
maxCustomers: 30, visitWord: true, visitSentence: true, homework: true,
|
|
91
91
|
customerStats: false, managerCustomerSplit: false, managerManagement: false,
|
|
92
92
|
sentenceTrainingSetting: true, wordTrainingSetting: true, myWordList: true,
|
|
93
93
|
wordListShare: true, groupTrainingSetting: false, minimalPairSearch: true,
|
|
@@ -123,14 +123,44 @@ export const PLAN_FEATURES = {
|
|
|
123
123
|
sentenceTrainingView: true,
|
|
124
124
|
},
|
|
125
125
|
};
|
|
126
|
-
/**
|
|
126
|
+
/**
|
|
127
|
+
* 요금제 티어 순위 — **같은 줄 안에서 견주는 값**이다(클수록 상위 요금제).
|
|
128
|
+
*
|
|
129
|
+
* 줄마다 자리를 갈라 둔다: 개인 줄 0~9, 매니저 줄 10~19. 전에는 한 자리에 섞여 있어
|
|
130
|
+
* (personal_standard 3 < mgr_free 4) 줄이 다른 두 티어의 순위 차가 무슨 뜻인지 읽을 수
|
|
131
|
+
* 없었다(사용자 결정 2026-09-16). 줄이 같은지는 `isManagerTier` 로 본다.
|
|
132
|
+
*
|
|
133
|
+
* 줄 사이의 크기 비교(매니저 줄이 개인 줄보다 큼)는 전과 같은 방향이라, 활성 구독 중
|
|
134
|
+
* 가장 높은 티어를 고르는 코드는 그대로 동작한다 — 다만 그런 비교는 "매니저 줄이
|
|
135
|
+
* 개인 줄보다 상위"라는 뜻이지 기능 수의 많고 적음이 아니다.
|
|
136
|
+
* 레거시 라이트(낱말·문장)는 서로 대등해 같은 자리를 쓴다.
|
|
137
|
+
*/
|
|
127
138
|
export const TIER_RANK = {
|
|
139
|
+
// ── 개인 줄 ──
|
|
128
140
|
none: 0,
|
|
129
|
-
free: 1,
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
free: 1,
|
|
142
|
+
personal_standard: 2,
|
|
143
|
+
// ── 매니저 줄 ──
|
|
144
|
+
mgr_free: 10,
|
|
145
|
+
lite_word: 11, lite_sentence: 11,
|
|
146
|
+
lite: 12,
|
|
147
|
+
standard: 13,
|
|
148
|
+
pro: 14,
|
|
149
|
+
enterprise: 15,
|
|
133
150
|
};
|
|
151
|
+
/** 매니저 줄(말귀매니저) 티어들 — TIER_RANK 의 10번대와 같은 집합이다. */
|
|
152
|
+
const MANAGER_TIERS = new Set([
|
|
153
|
+
'mgr_free', 'lite_word', 'lite_sentence', 'lite', 'standard', 'pro', 'enterprise',
|
|
154
|
+
]);
|
|
155
|
+
/**
|
|
156
|
+
* @public 티어가 매니저 줄(말귀매니저)인지. `none` 과 개인 줄(free·personal_standard)은 false.
|
|
157
|
+
*
|
|
158
|
+
* 요금제 비교표처럼 "이 칸은 매니저 플랜에만 있다" 를 그리는 곳이 티어 목록을 손으로
|
|
159
|
+
* 들고 있지 않도록 여기서 내준다 — 티어가 늘면 그 목록만 뒤처진다.
|
|
160
|
+
*/
|
|
161
|
+
export function isManagerTier(tier) {
|
|
162
|
+
return MANAGER_TIERS.has(tier);
|
|
163
|
+
}
|
|
134
164
|
/** @public 요금제 티어별 제한 (표시되지 않는 레거시 티어는 생략) */
|
|
135
165
|
export const PLAN_LIMITS = {
|
|
136
166
|
// ── 개인 ──
|
|
@@ -173,6 +173,25 @@ export function _poolCacheKey(opts) {
|
|
|
173
173
|
// 요청한 보기 수를 무시한다(공개 API 라 앱 밖 호출자가 무엇을 넘길지 알 수 없다).
|
|
174
174
|
return `${opts.difficulty ?? ''}|${chStr}|${compStr}|${opts.excludeNoImage ? '1' : '0'}|${opts.count ?? ''}|x${opts.contrastCount ?? 1}|${opts.fullCount ? 'F' : ''}|s${opts.sameStructureOnly === false ? '0' : '1'}|v${opts.phonemeView === 'pron' ? 'p' : 'c'}|g${gradeStr}|t${opts.targetSyllable ?? ''}|c${opts.choicesPerItem ?? ''}`;
|
|
175
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* 위치 대립 맵 캐시 — 칩 한 번에 **셈(fullCount)과 생성이 같은 맵을 따로** 만들었다.
|
|
178
|
+
*
|
|
179
|
+
* 맵은 재료(이미지 필터를 지난 낱말 전체)와 고른 음소·구조 조건만으로 정해지는 결정적인
|
|
180
|
+
* 값이고, 셈과 생성은 그 둘이 똑같다(fullCount 는 후보 샘플링 캡에만 관여한다). 그래서 둘이
|
|
181
|
+
* 나눠 쓴다 — 23,000 낱말에서 칩 한 번이 256ms → 93ms(사용자 결정 2026-09-16).
|
|
182
|
+
*
|
|
183
|
+
* 열쇠는 풀 캐시 열쇠에서 **맵과 무관한 것**(문제 수·보기 수·fullCount)만 뺀 것이다. 재료를
|
|
184
|
+
* 바꾸는 것(난이도·초성·구성·등급·기준 글자·이미지 제외)은 모두 열쇠에 남아 있어야 한다 —
|
|
185
|
+
* 하나라도 빠지면 다른 설정의 답을 그대로 돌려준다.
|
|
186
|
+
*
|
|
187
|
+
* 이미지 제외를 켠 경우엔 열쇠만으로 모자란다 — '이미지 있음' 캐시는 미리 데우는 동안 풀
|
|
188
|
+
* 참조가 그대로인 채로 자란다(셈과 생성 사이에도 자랄 수 있다). 그래서 맵을 만들 때 쓴
|
|
189
|
+
* 낱말 집합을 함께 들고 있다가, 그 집합이 갈리면 다시 만든다.
|
|
190
|
+
*/
|
|
191
|
+
const _posContrastCache = new Map();
|
|
192
|
+
function _posContrastCacheKey(opts) {
|
|
193
|
+
return _poolCacheKey({ ...opts, count: undefined, choicesPerItem: undefined, fullCount: false });
|
|
194
|
+
}
|
|
176
195
|
/**
|
|
177
196
|
* 기준 글자/난이도/이미지 조건을 모두 통과한 (본 단어 + 유효 distractor) 풀을 만든다.
|
|
178
197
|
* @returns [{ word: 본단어, usable: 이미지 OK + 난이도 매칭 distractor 배열 }]
|
|
@@ -183,6 +202,7 @@ export async function buildUsablePool(opts) {
|
|
|
183
202
|
const idx = getPairIndex();
|
|
184
203
|
if (pool !== _cachePoolRef || idx !== _cacheIdxRef) {
|
|
185
204
|
_poolCache.clear();
|
|
205
|
+
_posContrastCache.clear();
|
|
186
206
|
_cachePoolRef = pool;
|
|
187
207
|
_cacheIdxRef = idx;
|
|
188
208
|
}
|
|
@@ -392,9 +412,17 @@ async function _buildUsablePool(opts) {
|
|
|
392
412
|
const decompOf = createDecompCache(); // 호출당 새 캐시(이 생성 함수 스코프에 한정)
|
|
393
413
|
// 위치 대립: 전체 풀에서 완전 사전계산(마스킹 템플릿 인덱스). 후보당 O(1) 조회.
|
|
394
414
|
// 단일/다중 파트 모두 이 경로로 처리 — pairIndex 는 겹받침 단어(예: 값)를 누락하므로 의존하지 않는다.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
415
|
+
// 셈(fullCount)과 생성이 같은 맵을 쓴다(_posContrastCache) — 읽기만 하므로 공유해도 안전하다.
|
|
416
|
+
let posContrastMap = null;
|
|
417
|
+
if (posContrast) {
|
|
418
|
+
const mapKey = _posContrastCacheKey(opts);
|
|
419
|
+
const 이미지집합 = excludeNoImage ? getImageOkWordSet() : null;
|
|
420
|
+
const 전 = _posContrastCache.get(mapKey);
|
|
421
|
+
posContrastMap = 전 && 전.이미지집합 === 이미지집합
|
|
422
|
+
? 전.map
|
|
423
|
+
: buildMultiPartPosContrast(composition, allOkArr, decompOf, sameStructureOnly, phonemeView, pronOfWord);
|
|
424
|
+
_posContrastCache.set(mapKey, { 이미지집합, map: posContrastMap });
|
|
425
|
+
}
|
|
398
426
|
// candidates 크기 제한 — 이미지 필터 이후이므로 okCandidates 기반
|
|
399
427
|
const RANDOM_CAND_CAP = 500; // random: classifyPairDifficulty 없으나 루프 자체 제한
|
|
400
428
|
const OTHER_CAND_CAP = 1500; // cho/jung/jong: classifyPairDifficulty 호출이 있으므로 더 엄격히
|
|
@@ -53,6 +53,6 @@ export type { WordEntry } from './utils/phonemePairSearch';
|
|
|
53
53
|
export { CAPABILITY, isValidQuestionWord, hasQuestionWordConstraint, isManualOnlyType } from './utils/wordTrainingRuntime';
|
|
54
54
|
export { wordContainsChoseong, wordContainsJungseong, wordContainsJongseong, wordHasNoCoda, wordHasSyllableMatching, syllableLength } from './utils/koreanSyllable';
|
|
55
55
|
export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput, patternToComposition, matchesWordQuery, searchAndRankWords } from './utils/koreanSyllable';
|
|
56
|
-
export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
|
|
56
|
+
export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, type PlanTier, type DataLevel, type PlanFeatures, type PlanLimits, } from './utils/planFeatures';
|
|
57
57
|
export { WORD_SUGGESTION_WEEKLY_QUOTA, WORD_SUGGESTION_ACCOUNT_LIMIT, WORD_SUGGESTION_EXISTING_ERROR, isThisWeekPick, } from './utils/wordSuggestionPolicy';
|
|
58
58
|
export { CORE_VERSION } from './version';
|
|
@@ -39,7 +39,7 @@ export { isSyllablePattern, matchesSyllablePattern, validateSyllableSearchInput,
|
|
|
39
39
|
/* 요금제 정의 — 회원앱과 말귀어드민이 둘 다 본다. 예전에는 회원앱(src/libs) 안에 있었고
|
|
40
40
|
어드민이 '@app/libs/planFeatures' 로 끌어다 썼는데, 그 한 줄 때문에 어드민이 회원앱
|
|
41
41
|
폴더 없이는 빌드되지 않았다. 값과 타입뿐이라 코어에 두는 게 맞다. */
|
|
42
|
-
export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, } from './utils/planFeatures';
|
|
42
|
+
export { PLAN_FEATURES, PLAN_LIMITS, TIER_RANK, getPlanTier, isManagerTier, } from './utils/planFeatures';
|
|
43
43
|
/* 단어 제안 정책 — 요금제와 똑같은 까닭으로 여기 있다. 회원앱(단어 제안)과
|
|
44
44
|
말귀어드민(단어 제안 관리)이 같은 숫자를 봐야 하는데, 한쪽 화면 폴더에 두면
|
|
45
45
|
다른 쪽이 그 폴더 없이는 빌드되지 않는다. 값과 순수 함수뿐이다. */
|