@whiteslove/parsing-lexicon 0.2.0 → 0.2.2

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 (94) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +137 -137
  3. package/index.d.ts +374 -374
  4. package/package.json +77 -75
  5. package/src/central-asia-locations.js +211 -211
  6. package/src/central-asia.js +233 -233
  7. package/src/contact.d.ts +44 -44
  8. package/src/contact.js +159 -159
  9. package/src/countries.js +175 -175
  10. package/src/country-context.d.ts +10 -10
  11. package/src/country-context.js +22 -22
  12. package/src/currency.d.ts +14 -14
  13. package/src/currency.js +66 -66
  14. package/src/geo.js +169 -169
  15. package/src/geography-central-asia.js +42 -42
  16. package/src/geography-detection.d.ts +14 -14
  17. package/src/geography-detection.js +64 -64
  18. package/src/geography-display.d.ts +13 -13
  19. package/src/geography-display.js +113 -113
  20. package/src/geography.js +210 -210
  21. package/src/hiring-advanced.js +147 -147
  22. package/src/hiring-candidate-fields.d.ts +9 -9
  23. package/src/hiring-candidate-fields.js +92 -92
  24. package/src/hiring-context.d.ts +64 -64
  25. package/src/hiring-context.js +412 -412
  26. package/src/hiring-language-extensions.d.ts +19 -19
  27. package/src/hiring-language-extensions.js +139 -139
  28. package/src/hiring-languages.js +135 -135
  29. package/src/hiring-location-fields.d.ts +3 -3
  30. package/src/hiring-location-fields.js +53 -53
  31. package/src/hiring-professions-ro.js +191 -191
  32. package/src/hiring-professions.js +437 -437
  33. package/src/hiring-requirements.d.ts +16 -16
  34. package/src/hiring-requirements.js +170 -170
  35. package/src/hiring-salary-context.d.ts +27 -27
  36. package/src/hiring-salary-context.js +76 -76
  37. package/src/hiring-semantics.d.ts +29 -29
  38. package/src/hiring-semantics.js +218 -218
  39. package/src/hiring-skills.d.ts +21 -21
  40. package/src/hiring-skills.js +315 -315
  41. package/src/hiring-source-aliases.d.ts +32 -32
  42. package/src/hiring-source-aliases.js +185 -185
  43. package/src/hiring-source-semantics.d.ts +31 -31
  44. package/src/hiring-source-semantics.js +293 -293
  45. package/src/hiring-temporal.d.ts +15 -15
  46. package/src/hiring-temporal.js +67 -67
  47. package/src/hiring-vacancy-fields.d.ts +5 -5
  48. package/src/hiring-vacancy-fields.js +23 -23
  49. package/src/hiring-work-semantics.d.ts +34 -34
  50. package/src/hiring-work-semantics.js +72 -72
  51. package/src/hiring.js +180 -180
  52. package/src/housing-address.d.ts +18 -18
  53. package/src/housing-address.js +217 -210
  54. package/src/housing-commercial.d.ts +1 -0
  55. package/src/housing-commercial.js +9 -0
  56. package/src/housing-context.d.ts +36 -36
  57. package/src/housing-context.js +183 -183
  58. package/src/housing-features.js +30 -30
  59. package/src/housing-intent.d.ts +9 -9
  60. package/src/housing-intent.js +132 -132
  61. package/src/housing-listing-fields.js +131 -131
  62. package/src/housing-money.d.ts +7 -7
  63. package/src/housing-money.js +102 -102
  64. package/src/housing-source-aliases.d.ts +8 -8
  65. package/src/housing-source-aliases.js +51 -51
  66. package/src/housing-structured.d.ts +55 -55
  67. package/src/housing-structured.js +219 -219
  68. package/src/housing.js +200 -200
  69. package/src/index.js +45 -44
  70. package/src/kz-location-extensions.js +307 -307
  71. package/src/landmarks.js +21 -21
  72. package/src/lexicon-core.js +58 -58
  73. package/src/location-merge.js +106 -106
  74. package/src/locations.js +456 -456
  75. package/src/money-core.d.ts +10 -10
  76. package/src/money-core.js +81 -81
  77. package/src/money-lexicon.d.ts +5 -5
  78. package/src/money-lexicon.js +79 -79
  79. package/src/money.js +154 -154
  80. package/src/normalization.js +384 -384
  81. package/src/odesa-metropolitan.js +144 -144
  82. package/src/romania-geography.js +47 -47
  83. package/src/tashkent-colloquial.js +80 -72
  84. package/src/tashkent-housing-geography.d.ts +38 -38
  85. package/src/tashkent-housing-geography.js +211 -211
  86. package/src/tashkent-pois.js +215 -215
  87. package/src/tashkent-residential-complexes.js +272 -272
  88. package/src/ua-location-extensions-major.js +118 -118
  89. package/src/ua-location-extensions-metro.js +9 -9
  90. package/src/ua-location-extensions-regional.js +246 -246
  91. package/src/ua-secondary-cities.d.ts +2 -2
  92. package/src/ua-secondary-cities.js +57 -57
  93. package/src/ukraine.js +75 -75
  94. package/src/uz-location-extensions.js +259 -259
@@ -1,19 +1,19 @@
1
- export type ExtendedLanguageContextItem = Readonly<{
2
- language: string;
3
- name: string;
4
- relation: string | null;
5
- level: string | null;
6
- cefr: string | null;
7
- start: number;
8
- end: number;
9
- }>;
10
-
11
- export const LANGUAGE_LEVEL_EXTENSIONS: readonly Readonly<{
12
- canonical: string;
13
- aliases: Readonly<Record<string, readonly string[]>>;
14
- }>[];
15
-
16
- export function parseExtendedLanguageContext(
17
- value: unknown,
18
- options?: { mode?: 'candidate' | 'vacancy' | null },
19
- ): readonly ExtendedLanguageContextItem[];
1
+ export type ExtendedLanguageContextItem = Readonly<{
2
+ language: string;
3
+ name: string;
4
+ relation: string | null;
5
+ level: string | null;
6
+ cefr: string | null;
7
+ start: number;
8
+ end: number;
9
+ }>;
10
+
11
+ export const LANGUAGE_LEVEL_EXTENSIONS: readonly Readonly<{
12
+ canonical: string;
13
+ aliases: Readonly<Record<string, readonly string[]>>;
14
+ }>[];
15
+
16
+ export function parseExtendedLanguageContext(
17
+ value: unknown,
18
+ options?: { mode?: 'candidate' | 'vacancy' | null },
19
+ ): readonly ExtendedLanguageContextItem[];
@@ -1,139 +1,139 @@
1
- import { parseLanguageContext } from './hiring-context.js';
2
- import { LANGUAGE_LEVELS } from './hiring-languages.js';
3
- import { findAllCanonical } from './normalization.js';
4
- import { lexiconEntity } from './lexicon-core.js';
5
-
6
- const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
7
-
8
- /**
9
- * Source/feed wording and inflected qualitative levels that are intentionally
10
- * kept out of consuming applications. Canonicals mirror the core language
11
- * level taxonomy so callers receive one stable vocabulary.
12
- */
13
- export const LANGUAGE_LEVEL_EXTENSIONS = Object.freeze([
14
- group('basic', {
15
- ru: ['базовый', 'базовая', 'базовое', 'базового', 'базовой', 'базовом', 'базовым'],
16
- en: ['basic'],
17
- uk: ['базовий', 'базова', 'базове', 'базового'],
18
- uzLatn: ["boshlang'ich", 'boshlang‘ich', 'boshlangʻich'],
19
- uzCyrl: ['бошланғич'],
20
- kk: ['бастапқы'],
21
- }),
22
- group('intermediate', {
23
- ru: ['разговорный', 'разговорная', 'разговорное', 'разговорного', 'средний уровень', 'среднего уровня'],
24
- en: ['intermediate', 'conversational'],
25
- uk: ['розмовний', 'розмовна', 'середній рівень'],
26
- uzLatn: ["o'rta daraja", 'o‘rta daraja', 'oʻrta daraja'],
27
- uzCyrl: ['ўрта даража'],
28
- kk: ['орта деңгей'],
29
- }),
30
- group('fluent', {
31
- ru: ['свободный', 'свободная', 'свободное', 'свободного', 'свободное владение'],
32
- en: ['fluent', 'full professional proficiency'],
33
- uk: ['вільний', 'вільна', 'вільне володіння'],
34
- uzLatn: ['erkin', 'erkin daraja'],
35
- uzCyrl: ['эркин'],
36
- kk: ['еркін'],
37
- }),
38
- group('native', {
39
- ru: ['родной', 'родная', 'родного', 'родным', 'носитель языка'],
40
- en: ['native', 'native speaker'],
41
- uk: ['рідна', 'рідний', 'носій мови'],
42
- uzLatn: ['ona tili'],
43
- uzCyrl: ['она тили'],
44
- kk: ['ана тілі'],
45
- }),
46
- group('professional', {
47
- ru: [
48
- 'профессиональный', 'профессиональная', 'профессиональное', 'профессиональные',
49
- 'профессионального', 'профессиональной', 'профессиональном', 'профессиональным',
50
- 'профессиональное владение', 'профессиональный уровень',
51
- ],
52
- en: ['professional', 'professional proficiency', 'professional working proficiency'],
53
- uk: ['професійний', 'професійна', 'професійне', 'професійного', 'професійне володіння'],
54
- ro: ['profesional', 'nivel profesional'],
55
- uzLatn: ['professional', 'kasbiy daraja'],
56
- uzCyrl: ['профессионал', 'касбий даража'],
57
- kk: ['кәсіби', 'кәсіби деңгей'],
58
- }),
59
- ]);
60
-
61
- const ALL_QUALITATIVE_LEVELS = Object.freeze([...LANGUAGE_LEVELS, ...LANGUAGE_LEVEL_EXTENSIONS]);
62
-
63
- function distanceToItem(match, item) {
64
- const itemStart = Number(item?.start || 0);
65
- const itemEnd = Number(item?.end || itemStart);
66
- if (match.end <= itemStart) return itemStart - match.end;
67
- if (match.start >= itemEnd) return match.start - itemEnd;
68
- return 0;
69
- }
70
-
71
- function cefrMatches(text) {
72
- const out = [];
73
- for (const match of text.matchAll(/(?<![\p{L}\p{N}])([ABC][12])(?![\p{L}\p{N}])/giu)) {
74
- const start = match.index ?? 0;
75
- out.push(Object.freeze({
76
- canonical: match[1].toUpperCase(),
77
- start,
78
- end: start + match[0].length,
79
- kind: 'cefr',
80
- }));
81
- }
82
- return out;
83
- }
84
-
85
- function qualitativeMatches(text) {
86
- const seen = new Set();
87
- return findAllCanonical(text, ALL_QUALITATIVE_LEVELS)
88
- .filter((match) => {
89
- const key = `${match.start}:${match.end}:${match.canonical}`;
90
- if (seen.has(key)) return false;
91
- seen.add(key);
92
- return true;
93
- })
94
- .map((match) => Object.freeze({ ...match, kind: 'level' }));
95
- }
96
-
97
- function nearestLanguageIndex(token, languages) {
98
- let bestIndex = -1;
99
- let bestDistance = Infinity;
100
- for (let index = 0; index < languages.length; index += 1) {
101
- const distance = distanceToItem(token, languages[index]);
102
- if (distance < bestDistance) {
103
- bestIndex = index;
104
- bestDistance = distance;
105
- }
106
- }
107
- return bestDistance <= 45 ? bestIndex : -1;
108
- }
109
-
110
- function bindLevels(text, languages) {
111
- const bound = new Map();
112
- const tokens = [...qualitativeMatches(text), ...cefrMatches(text)];
113
- for (const token of tokens) {
114
- const index = nearestLanguageIndex(token, languages);
115
- if (index < 0) continue;
116
- const item = languages[index];
117
- const distance = distanceToItem(token, item);
118
- const current = bound.get(index);
119
- if (!current || distance < current.distance || (distance === current.distance && token.start < current.token.start)) {
120
- bound.set(index, { token, distance });
121
- }
122
- }
123
- return bound;
124
- }
125
-
126
- export function parseExtendedLanguageContext(value, options = {}) {
127
- const text = String(value || '');
128
- const parsed = parseLanguageContext(text, options);
129
- const bound = bindLevels(text, parsed);
130
- return Object.freeze(parsed.map((item, index) => {
131
- const token = bound.get(index)?.token;
132
- if (!token) return item;
133
- return Object.freeze({
134
- ...item,
135
- level: token.kind === 'level' ? token.canonical : null,
136
- cefr: token.kind === 'cefr' ? token.canonical : null,
137
- });
138
- }));
139
- }
1
+ import { parseLanguageContext } from './hiring-context.js';
2
+ import { LANGUAGE_LEVELS } from './hiring-languages.js';
3
+ import { findAllCanonical } from './normalization.js';
4
+ import { lexiconEntity } from './lexicon-core.js';
5
+
6
+ const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
7
+
8
+ /**
9
+ * Source/feed wording and inflected qualitative levels that are intentionally
10
+ * kept out of consuming applications. Canonicals mirror the core language
11
+ * level taxonomy so callers receive one stable vocabulary.
12
+ */
13
+ export const LANGUAGE_LEVEL_EXTENSIONS = Object.freeze([
14
+ group('basic', {
15
+ ru: ['базовый', 'базовая', 'базовое', 'базового', 'базовой', 'базовом', 'базовым'],
16
+ en: ['basic'],
17
+ uk: ['базовий', 'базова', 'базове', 'базового'],
18
+ uzLatn: ["boshlang'ich", 'boshlang‘ich', 'boshlangʻich'],
19
+ uzCyrl: ['бошланғич'],
20
+ kk: ['бастапқы'],
21
+ }),
22
+ group('intermediate', {
23
+ ru: ['разговорный', 'разговорная', 'разговорное', 'разговорного', 'средний уровень', 'среднего уровня'],
24
+ en: ['intermediate', 'conversational'],
25
+ uk: ['розмовний', 'розмовна', 'середній рівень'],
26
+ uzLatn: ["o'rta daraja", 'o‘rta daraja', 'oʻrta daraja'],
27
+ uzCyrl: ['ўрта даража'],
28
+ kk: ['орта деңгей'],
29
+ }),
30
+ group('fluent', {
31
+ ru: ['свободный', 'свободная', 'свободное', 'свободного', 'свободное владение'],
32
+ en: ['fluent', 'full professional proficiency'],
33
+ uk: ['вільний', 'вільна', 'вільне володіння'],
34
+ uzLatn: ['erkin', 'erkin daraja'],
35
+ uzCyrl: ['эркин'],
36
+ kk: ['еркін'],
37
+ }),
38
+ group('native', {
39
+ ru: ['родной', 'родная', 'родного', 'родным', 'носитель языка'],
40
+ en: ['native', 'native speaker'],
41
+ uk: ['рідна', 'рідний', 'носій мови'],
42
+ uzLatn: ['ona tili'],
43
+ uzCyrl: ['она тили'],
44
+ kk: ['ана тілі'],
45
+ }),
46
+ group('professional', {
47
+ ru: [
48
+ 'профессиональный', 'профессиональная', 'профессиональное', 'профессиональные',
49
+ 'профессионального', 'профессиональной', 'профессиональном', 'профессиональным',
50
+ 'профессиональное владение', 'профессиональный уровень',
51
+ ],
52
+ en: ['professional', 'professional proficiency', 'professional working proficiency'],
53
+ uk: ['професійний', 'професійна', 'професійне', 'професійного', 'професійне володіння'],
54
+ ro: ['profesional', 'nivel profesional'],
55
+ uzLatn: ['professional', 'kasbiy daraja'],
56
+ uzCyrl: ['профессионал', 'касбий даража'],
57
+ kk: ['кәсіби', 'кәсіби деңгей'],
58
+ }),
59
+ ]);
60
+
61
+ const ALL_QUALITATIVE_LEVELS = Object.freeze([...LANGUAGE_LEVELS, ...LANGUAGE_LEVEL_EXTENSIONS]);
62
+
63
+ function distanceToItem(match, item) {
64
+ const itemStart = Number(item?.start || 0);
65
+ const itemEnd = Number(item?.end || itemStart);
66
+ if (match.end <= itemStart) return itemStart - match.end;
67
+ if (match.start >= itemEnd) return match.start - itemEnd;
68
+ return 0;
69
+ }
70
+
71
+ function cefrMatches(text) {
72
+ const out = [];
73
+ for (const match of text.matchAll(/(?<![\p{L}\p{N}])([ABC][12])(?![\p{L}\p{N}])/giu)) {
74
+ const start = match.index ?? 0;
75
+ out.push(Object.freeze({
76
+ canonical: match[1].toUpperCase(),
77
+ start,
78
+ end: start + match[0].length,
79
+ kind: 'cefr',
80
+ }));
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function qualitativeMatches(text) {
86
+ const seen = new Set();
87
+ return findAllCanonical(text, ALL_QUALITATIVE_LEVELS)
88
+ .filter((match) => {
89
+ const key = `${match.start}:${match.end}:${match.canonical}`;
90
+ if (seen.has(key)) return false;
91
+ seen.add(key);
92
+ return true;
93
+ })
94
+ .map((match) => Object.freeze({ ...match, kind: 'level' }));
95
+ }
96
+
97
+ function nearestLanguageIndex(token, languages) {
98
+ let bestIndex = -1;
99
+ let bestDistance = Infinity;
100
+ for (let index = 0; index < languages.length; index += 1) {
101
+ const distance = distanceToItem(token, languages[index]);
102
+ if (distance < bestDistance) {
103
+ bestIndex = index;
104
+ bestDistance = distance;
105
+ }
106
+ }
107
+ return bestDistance <= 45 ? bestIndex : -1;
108
+ }
109
+
110
+ function bindLevels(text, languages) {
111
+ const bound = new Map();
112
+ const tokens = [...qualitativeMatches(text), ...cefrMatches(text)];
113
+ for (const token of tokens) {
114
+ const index = nearestLanguageIndex(token, languages);
115
+ if (index < 0) continue;
116
+ const item = languages[index];
117
+ const distance = distanceToItem(token, item);
118
+ const current = bound.get(index);
119
+ if (!current || distance < current.distance || (distance === current.distance && token.start < current.token.start)) {
120
+ bound.set(index, { token, distance });
121
+ }
122
+ }
123
+ return bound;
124
+ }
125
+
126
+ export function parseExtendedLanguageContext(value, options = {}) {
127
+ const text = String(value || '');
128
+ const parsed = parseLanguageContext(text, options);
129
+ const bound = bindLevels(text, parsed);
130
+ return Object.freeze(parsed.map((item, index) => {
131
+ const token = bound.get(index)?.token;
132
+ if (!token) return item;
133
+ return Object.freeze({
134
+ ...item,
135
+ level: token.kind === 'level' ? token.canonical : null,
136
+ cefr: token.kind === 'cefr' ? token.canonical : null,
137
+ });
138
+ }));
139
+ }
@@ -1,135 +1,135 @@
1
- import { aliasesOf, aliasesToRegex, findCanonical, normalizeUnicode } from './normalization.js';
2
- import { lexiconEntity } from './lexicon-core.js';
3
-
4
- const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
5
-
6
- export const LANGUAGES = Object.freeze([
7
- group('en', { ru: ['английский', 'английского', 'английским', 'англ', 'англ.'], en: ['english'], uk: ['англійська', 'англійський', 'англійської'], ro: ['engleză', 'engleza', 'limba engleză'], uzLatn: ['ingliz tili', 'inglizcha'], uzCyrl: ['инглиз тили', 'инглизча'], kk: ['ағылшын тілі', 'ағылшынша'] }, { name: 'English' }),
8
- group('ru', { ru: ['русский', 'русского', 'русским', 'рус.'], en: ['russian'], uk: ['російська', 'російський'], ro: ['rusă', 'rusa', 'limba rusă'], uzLatn: ['rus tili', 'ruscha'], uzCyrl: ['рус тили', 'русча'], kk: ['орыс тілі', 'орысша'] }, { name: 'Russian' }),
9
- group('uz', { ru: ['узбекский', 'узбекского'], en: ['uzbek'], uk: ['узбецька', 'узбецький'], ro: ['uzbecă', 'uzbeca'], uzLatn: ["o'zbek tili", 'o‘zbek tili', 'ozbek tili', "o'zbekcha", 'o‘zbekcha'], uzCyrl: ['ўзбек тили', 'ўзбекча'], kk: ['өзбек тілі', 'өзбекше'] }, { name: 'Uzbek' }),
10
- group('kk', { ru: ['казахский', 'казахского'], en: ['kazakh'], uk: ['казахська', 'казахський'], ro: ['kazahă', 'kazaha'], uzLatn: ['qozoq tili', 'qozoqcha'], uzCyrl: ['қозоқ тили', 'қозоқча'], kk: ['қазақ тілі', 'қазақша'] }, { name: 'Kazakh' }),
11
- group('uk', { ru: ['украинский', 'украинского'], en: ['ukrainian'], uk: ['українська', 'український', 'українською'], ro: ['ucraineană', 'ucraineana'], uzLatn: ['ukrain tili'], uzCyrl: ['украин тили'], kk: ['украин тілі'] }, { name: 'Ukrainian' }),
12
- group('tr', { ru: ['турецкий', 'турецкого'], en: ['turkish'], uk: ['турецька', 'турецький'], ro: ['turcă', 'turca'], uzLatn: ['turk tili', 'turkcha'], uzCyrl: ['турк тили'], kk: ['түрік тілі', 'түрікше'] }, { name: 'Turkish' }),
13
- group('de', { ru: ['немецкий', 'немецкого'], en: ['german', 'deutsch'], uk: ['німецька', 'німецький'], ro: ['germană', 'germana'], uzLatn: ['nemis tili'], uzCyrl: ['немис тили'], kk: ['неміс тілі'] }, { name: 'German' }),
14
- group('fr', { ru: ['французский', 'французского'], en: ['french', 'français', 'francais'], uk: ['французька', 'французький'], ro: ['franceză', 'franceza'], uzLatn: ['fransuz tili'], uzCyrl: ['француз тили'], kk: ['француз тілі'] }, { name: 'French' }),
15
- group('es', { ru: ['испанский', 'испанского'], en: ['spanish', 'español', 'espanol'], uk: ['іспанська', 'іспанський'], ro: ['spaniolă', 'spaniola'], uzLatn: ['ispan tili'], uzCyrl: ['испан тили'], kk: ['испан тілі'] }, { name: 'Spanish' }),
16
- group('zh', { ru: ['китайский', 'китайского', 'мандарин'], en: ['chinese', 'mandarin'], uk: ['китайська', 'китайський'], ro: ['chineză', 'chineza', 'mandarină'], uzLatn: ['xitoy tili', 'xitoycha'], uzCyrl: ['хитой тили'], kk: ['қытай тілі', 'қытайша'] }, { name: 'Chinese' }),
17
- group('ko', { ru: ['корейский', 'корейского'], en: ['korean'], uk: ['корейська', 'корейський'], ro: ['coreeană', 'coreeana'], uzLatn: ['koreys tili'], uzCyrl: ['корейс тили'], kk: ['корей тілі'] }, { name: 'Korean' }),
18
- group('ja', { ru: ['японский', 'японского'], en: ['japanese'], uk: ['японська', 'японський'], ro: ['japoneză', 'japoneza'], uzLatn: ['yapon tili'], uzCyrl: ['япон тили'], kk: ['жапон тілі'] }, { name: 'Japanese' }),
19
- group('ar', { ru: ['арабский', 'арабского'], en: ['arabic'], uk: ['арабська', 'арабський'], ro: ['arabă', 'araba'], uzLatn: ['arab tili'], uzCyrl: ['араб тили'], kk: ['араб тілі'] }, { name: 'Arabic' }),
20
- group('tg', { ru: ['таджикский', 'таджикского'], en: ['tajik'], uk: ['таджицька'], ro: ['tadjică', 'tadjica'], uzLatn: ['tojik tili', 'tojikcha'], uzCyrl: ['тожик тили'], kk: ['тәжік тілі'] }, { name: 'Tajik' }),
21
- group('ky', { ru: ['кыргызский', 'киргизский', 'кыргызского'], en: ['kyrgyz', 'kirghiz'], uk: ['киргизька'], ro: ['kârgâză', 'kargaza'], uzLatn: ['qirgiz tili', "qirg'iz tili"], uzCyrl: ['қирғиз тили'], kk: ['қырғыз тілі', 'қырғызша'] }, { name: 'Kyrgyz' }),
22
- group('ro', { ru: ['румынский', 'румынского'], en: ['romanian'], uk: ['румунська'], ro: ['română', 'romana', 'limba română'], uzLatn: ['rumin tili'], uzCyrl: ['румин тили'], kk: ['румын тілі'] }, { name: 'Romanian' }),
23
- group('pl', { ru: ['польский', 'польского'], en: ['polish'], uk: ['польська'], ro: ['poloneză', 'poloneza'], uzLatn: ['polyak tili'], uzCyrl: ['поляк тили'], kk: ['поляк тілі'] }, { name: 'Polish' }),
24
- ]);
25
-
26
- export const LANGUAGE_LEVELS = Object.freeze([
27
- group('basic', { ru: ['базовый', 'начальный', 'со словарём', 'со словарем'], en: ['basic', 'beginner'], uk: ['базовий', 'початковий'], ro: ['de bază', 'de baza', 'începător', 'incepator'], uzLatn: ['boshlangich', "boshlang'ich", 'asosiy'], uzCyrl: ['бошланғич', 'асосий'], kk: ['бастапқы', 'негізгі'] }),
28
- group('elementary', { ru: ['элементарный'], en: ['elementary'], uk: ['елементарний'], ro: ['elementar'], uzLatn: ['elementar'], uzCyrl: ['элементар'], kk: ['элементарлық'] }),
29
- group('preIntermediate', { ru: ['ниже среднего', 'pre-intermediate'], en: ['pre-intermediate', 'pre intermediate'], uk: ['нижче середнього'], ro: ['pre-intermediar'], uzLatn: ['pre-intermediate'], uzCyrl: ['pre-intermediate'], kk: ['pre-intermediate'] }),
30
- group('intermediate', { ru: ['средний', 'разговорный', 'разговорный уровень'], en: ['intermediate', 'conversational', 'working knowledge'], uk: ['середній', 'розмовний'], ro: ['intermediar', 'conversațional', 'conversational'], uzLatn: ['orta', "o'rta", 'suhbat darajasi'], uzCyrl: ['ўрта', 'суҳбат даражаси'], kk: ['орта', 'сөйлесу деңгейі'] }),
31
- group('upperIntermediate', { ru: ['выше среднего', 'upper-intermediate'], en: ['upper-intermediate', 'upper intermediate'], uk: ['вище середнього'], ro: ['upper-intermediate'], uzLatn: ['upper-intermediate'], uzCyrl: ['upper-intermediate'], kk: ['upper-intermediate'] }),
32
- group('advanced', { ru: ['продвинутый', 'уверенный'], en: ['advanced', 'proficient'], uk: ['просунутий', 'впевнений'], ro: ['avansat', 'competent'], uzLatn: ['yuqori daraja', 'ishonchli'], uzCyrl: ['юқори даража'], kk: ['жоғары деңгей', 'сенімді'] }),
33
- group('fluent', { ru: ['свободный', 'свободное владение', 'в совершенстве'], en: ['fluent', 'fluency', 'full professional proficiency'], uk: ['вільний', 'вільне володіння', 'досконало'], ro: ['fluent', 'fluență', 'fluenta'], uzLatn: ['erkin', 'erkin gaplashish'], uzCyrl: ['эркин', 'эркин гаплашиш'], kk: ['еркін', 'еркін сөйлейді'] }),
34
- group('native', { ru: ['родной', 'носитель языка'], en: ['native', 'native speaker', 'mother tongue'], uk: ['рідна', 'носій мови'], ro: ['nativ', 'vorbitor nativ', 'limbă maternă'], uzLatn: ['ona tili', 'native speaker'], uzCyrl: ['она тили'], kk: ['ана тілі', 'тіл иесі'] }),
35
- ]);
36
-
37
- export const LANGUAGE_CONTEXT_TERMS = Object.freeze({
38
- required: group('required', {
39
- ru: ['требуется знание языка', 'знание языка обязательно', 'обязательное знание', 'необходимо знание', 'необходим язык', 'требуется владение', 'свободное владение обязательно', 'со знанием английского', 'английский обязателен'],
40
- en: ['language required', 'english required', 'must speak', 'must know', 'fluency required', 'proficiency required', 'required language'],
41
- uk: ['знання мови обов’язкове', "знання мови обов'язкове", 'потрібне знання мови', 'необхідне знання'],
42
- ro: ['limba este obligatorie', 'cunoașterea limbii este obligatorie', 'trebuie să vorbească'],
43
- uzLatn: ['til bilish talab qilinadi', 'tilni bilish shart', 'ingliz tilini bilish kerak', 'tilni yaxshi bilishi kerak'],
44
- uzCyrl: ['тил билиш талаб қилинади', 'тилни билиш шарт', 'инглиз тилини билиш керак'],
45
- kk: ['тіл білу талап етіледі', 'тілді білу міндетті', 'ағылшын тілін білу қажет'],
46
- }),
47
- preferred: group('preferred', {
48
- ru: ['желательно знание', 'знание будет плюсом', 'будет преимуществом', 'приветствуется знание'],
49
- en: ['preferred', 'nice to have', 'language is a plus', 'would be an advantage'],
50
- uk: ['бажане знання', 'знання буде плюсом', 'буде перевагою'],
51
- ro: ['cunoașterea constituie un avantaj', 'de preferat să cunoască'],
52
- uzLatn: ['til bilsa yaxshi', 'til bilishi afzal', 'til plus boladi'],
53
- uzCyrl: ['тил билса яхши', 'тил билиши афзал'],
54
- kk: ['тіл білу артықшылық болады', 'тіл білгені дұрыс'],
55
- }),
56
- notRequired: group('notRequired', {
57
- ru: ['язык не требуется', 'знание языка не обязательно', 'без обязательного знания', 'язык не нужен'],
58
- en: ['language not required', 'english not required', 'language optional', 'no language requirement'],
59
- uk: ['мова не потрібна', 'знання мови не обов’язкове'],
60
- ro: ['limba nu este necesară', 'limba nu este obligatorie'],
61
- uzLatn: ['til shart emas', 'til bilish talab qilinmaydi'],
62
- uzCyrl: ['тил шарт эмас', 'тил билиш талаб қилинмайди'],
63
- kk: ['тіл міндетті емес', 'тіл білу талап етілмейді'],
64
- }),
65
- candidateHas: group('candidateHas', {
66
- ru: ['владею языками', 'знаю английский', 'владею английским', 'свободно говорю', 'разговорный английский', 'английский на уровне'],
67
- en: ['i speak', 'i know', 'fluent in', 'proficient in', 'native speaker', 'working proficiency'],
68
- uk: ['володію мовами', 'знаю англійську', 'вільно говорю'],
69
- ro: ['vorbesc', 'cunosc limba', 'fluent în', 'fluent in'],
70
- uzLatn: ['tillarni bilaman', 'ingliz tilini bilaman', 'erkin gaplashaman'],
71
- uzCyrl: ['тилларни биламан', 'инглиз тилини биламан', 'эркин гаплашаман'],
72
- kk: ['тілдерді білемін', 'ағылшын тілін білемін', 'еркін сөйлеймін'],
73
- }),
74
- });
75
-
76
- const CEFR_RE = /(?:^|[^A-Za-z0-9])(A1|A2|B1|B2|C1|C2)(?![A-Za-z0-9])/i;
77
- const NEGATION_RE = /(?:не\s+(?:требуется|обязател|нужен|нужна)|без\s+обязатель|not\s+required|not\s+mandatory|optional|nu\s+(?:este\s+)?(?:necesar|obligatoriu)|shart\s+emas|talab\s+qilinmaydi|міндетті\s+емес|талап\s+етілмейді)/iu;
78
- const REQUIRED_RE = /(?:обязател|требуется|необходим|required|must\s+(?:speak|know)|mandatory|потріб|необхід|obligatoriu|trebuie|shart|talab\s+qilinadi|міндетті|талап\s+етіледі|қажет)/iu;
79
- const PREFERRED_RE = /(?:желатель|плюс|приветств|preferred|nice\s+to\s+have|advantage|бажан|переваг|avantaj|afzal|bolsa\s+yaxshi|артықшылық)/iu;
80
- const CANDIDATE_RE = /(?:владею|знаю|говорю|мой\s+уровень|i\s+(?:speak|know)|fluent\s+in|proficient\s+in|володію|vorbesc|bilaman|gaplashaman|білемін|сөйлеймін)/iu;
81
-
82
- function relationFromWindow(window, mode) {
83
- if (NEGATION_RE.test(window)) return 'notRequired';
84
- if (PREFERRED_RE.test(window)) return 'preferred';
85
- if (REQUIRED_RE.test(window)) return 'required';
86
- if (CANDIDATE_RE.test(window)) return 'candidateHas';
87
- return mode === 'candidate' ? 'candidateHas' : mode === 'vacancy' ? 'mentioned' : 'mentioned';
88
- }
89
-
90
- function levelFromWindow(window) {
91
- const cefr = window.match(CEFR_RE)?.[1]?.toUpperCase() || null;
92
- if (cefr) return { level: null, cefr };
93
- const qualitative = findCanonical(window, LANGUAGE_LEVELS, { partial: true });
94
- return { level: qualitative?.canonical || null, cefr: null };
95
- }
96
-
97
- export function languageByCode(code) {
98
- return LANGUAGES.find((entry) => entry.canonical === String(code || '').toLowerCase()) || null;
99
- }
100
-
101
- export function parseLanguageMentions(value, { mode = null } = {}) {
102
- const text = normalizeUnicode(value || '');
103
- if (!text) return [];
104
- const results = [];
105
- for (const language of LANGUAGES) {
106
- const re = aliasesToRegex([language.canonical, language.name, ...aliasesOf(language)], 'giu');
107
- for (const match of text.matchAll(re)) {
108
- const index = match.index ?? 0;
109
- const raw = match[0].trim();
110
- const start = Math.max(0, index - 90);
111
- const end = Math.min(text.length, index + match[0].length + 90);
112
- const window = text.slice(start, end);
113
- const levels = levelFromWindow(window);
114
- results.push(Object.freeze({
115
- language: language.canonical,
116
- name: language.name,
117
- relation: relationFromWindow(window, mode),
118
- level: levels.level,
119
- cefr: levels.cefr,
120
- matched: raw,
121
- index,
122
- }));
123
- }
124
- }
125
- results.sort((a, b) => a.index - b.index);
126
- const deduped = [];
127
- const seen = new Set();
128
- for (const result of results) {
129
- const key = `${result.language}:${result.relation}:${result.cefr || result.level || ''}`;
130
- if (seen.has(key)) continue;
131
- seen.add(key);
132
- deduped.push(result);
133
- }
134
- return deduped;
135
- }
1
+ import { aliasesOf, aliasesToRegex, findCanonical, normalizeUnicode } from './normalization.js';
2
+ import { lexiconEntity } from './lexicon-core.js';
3
+
4
+ const group = (canonical, aliases, extra = {}) => lexiconEntity(canonical, aliases, extra);
5
+
6
+ export const LANGUAGES = Object.freeze([
7
+ group('en', { ru: ['английский', 'английского', 'английским', 'англ', 'англ.'], en: ['english'], uk: ['англійська', 'англійський', 'англійської'], ro: ['engleză', 'engleza', 'limba engleză'], uzLatn: ['ingliz tili', 'inglizcha'], uzCyrl: ['инглиз тили', 'инглизча'], kk: ['ағылшын тілі', 'ағылшынша'] }, { name: 'English' }),
8
+ group('ru', { ru: ['русский', 'русского', 'русским', 'рус.'], en: ['russian'], uk: ['російська', 'російський'], ro: ['rusă', 'rusa', 'limba rusă'], uzLatn: ['rus tili', 'ruscha'], uzCyrl: ['рус тили', 'русча'], kk: ['орыс тілі', 'орысша'] }, { name: 'Russian' }),
9
+ group('uz', { ru: ['узбекский', 'узбекского'], en: ['uzbek'], uk: ['узбецька', 'узбецький'], ro: ['uzbecă', 'uzbeca'], uzLatn: ["o'zbek tili", 'o‘zbek tili', 'ozbek tili', "o'zbekcha", 'o‘zbekcha'], uzCyrl: ['ўзбек тили', 'ўзбекча'], kk: ['өзбек тілі', 'өзбекше'] }, { name: 'Uzbek' }),
10
+ group('kk', { ru: ['казахский', 'казахского'], en: ['kazakh'], uk: ['казахська', 'казахський'], ro: ['kazahă', 'kazaha'], uzLatn: ['qozoq tili', 'qozoqcha'], uzCyrl: ['қозоқ тили', 'қозоқча'], kk: ['қазақ тілі', 'қазақша'] }, { name: 'Kazakh' }),
11
+ group('uk', { ru: ['украинский', 'украинского'], en: ['ukrainian'], uk: ['українська', 'український', 'українською'], ro: ['ucraineană', 'ucraineana'], uzLatn: ['ukrain tili'], uzCyrl: ['украин тили'], kk: ['украин тілі'] }, { name: 'Ukrainian' }),
12
+ group('tr', { ru: ['турецкий', 'турецкого'], en: ['turkish'], uk: ['турецька', 'турецький'], ro: ['turcă', 'turca'], uzLatn: ['turk tili', 'turkcha'], uzCyrl: ['турк тили'], kk: ['түрік тілі', 'түрікше'] }, { name: 'Turkish' }),
13
+ group('de', { ru: ['немецкий', 'немецкого'], en: ['german', 'deutsch'], uk: ['німецька', 'німецький'], ro: ['germană', 'germana'], uzLatn: ['nemis tili'], uzCyrl: ['немис тили'], kk: ['неміс тілі'] }, { name: 'German' }),
14
+ group('fr', { ru: ['французский', 'французского'], en: ['french', 'français', 'francais'], uk: ['французька', 'французький'], ro: ['franceză', 'franceza'], uzLatn: ['fransuz tili'], uzCyrl: ['француз тили'], kk: ['француз тілі'] }, { name: 'French' }),
15
+ group('es', { ru: ['испанский', 'испанского'], en: ['spanish', 'español', 'espanol'], uk: ['іспанська', 'іспанський'], ro: ['spaniolă', 'spaniola'], uzLatn: ['ispan tili'], uzCyrl: ['испан тили'], kk: ['испан тілі'] }, { name: 'Spanish' }),
16
+ group('zh', { ru: ['китайский', 'китайского', 'мандарин'], en: ['chinese', 'mandarin'], uk: ['китайська', 'китайський'], ro: ['chineză', 'chineza', 'mandarină'], uzLatn: ['xitoy tili', 'xitoycha'], uzCyrl: ['хитой тили'], kk: ['қытай тілі', 'қытайша'] }, { name: 'Chinese' }),
17
+ group('ko', { ru: ['корейский', 'корейского'], en: ['korean'], uk: ['корейська', 'корейський'], ro: ['coreeană', 'coreeana'], uzLatn: ['koreys tili'], uzCyrl: ['корейс тили'], kk: ['корей тілі'] }, { name: 'Korean' }),
18
+ group('ja', { ru: ['японский', 'японского'], en: ['japanese'], uk: ['японська', 'японський'], ro: ['japoneză', 'japoneza'], uzLatn: ['yapon tili'], uzCyrl: ['япон тили'], kk: ['жапон тілі'] }, { name: 'Japanese' }),
19
+ group('ar', { ru: ['арабский', 'арабского'], en: ['arabic'], uk: ['арабська', 'арабський'], ro: ['arabă', 'araba'], uzLatn: ['arab tili'], uzCyrl: ['араб тили'], kk: ['араб тілі'] }, { name: 'Arabic' }),
20
+ group('tg', { ru: ['таджикский', 'таджикского'], en: ['tajik'], uk: ['таджицька'], ro: ['tadjică', 'tadjica'], uzLatn: ['tojik tili', 'tojikcha'], uzCyrl: ['тожик тили'], kk: ['тәжік тілі'] }, { name: 'Tajik' }),
21
+ group('ky', { ru: ['кыргызский', 'киргизский', 'кыргызского'], en: ['kyrgyz', 'kirghiz'], uk: ['киргизька'], ro: ['kârgâză', 'kargaza'], uzLatn: ['qirgiz tili', "qirg'iz tili"], uzCyrl: ['қирғиз тили'], kk: ['қырғыз тілі', 'қырғызша'] }, { name: 'Kyrgyz' }),
22
+ group('ro', { ru: ['румынский', 'румынского'], en: ['romanian'], uk: ['румунська'], ro: ['română', 'romana', 'limba română'], uzLatn: ['rumin tili'], uzCyrl: ['румин тили'], kk: ['румын тілі'] }, { name: 'Romanian' }),
23
+ group('pl', { ru: ['польский', 'польского'], en: ['polish'], uk: ['польська'], ro: ['poloneză', 'poloneza'], uzLatn: ['polyak tili'], uzCyrl: ['поляк тили'], kk: ['поляк тілі'] }, { name: 'Polish' }),
24
+ ]);
25
+
26
+ export const LANGUAGE_LEVELS = Object.freeze([
27
+ group('basic', { ru: ['базовый', 'начальный', 'со словарём', 'со словарем'], en: ['basic', 'beginner'], uk: ['базовий', 'початковий'], ro: ['de bază', 'de baza', 'începător', 'incepator'], uzLatn: ['boshlangich', "boshlang'ich", 'asosiy'], uzCyrl: ['бошланғич', 'асосий'], kk: ['бастапқы', 'негізгі'] }),
28
+ group('elementary', { ru: ['элементарный'], en: ['elementary'], uk: ['елементарний'], ro: ['elementar'], uzLatn: ['elementar'], uzCyrl: ['элементар'], kk: ['элементарлық'] }),
29
+ group('preIntermediate', { ru: ['ниже среднего', 'pre-intermediate'], en: ['pre-intermediate', 'pre intermediate'], uk: ['нижче середнього'], ro: ['pre-intermediar'], uzLatn: ['pre-intermediate'], uzCyrl: ['pre-intermediate'], kk: ['pre-intermediate'] }),
30
+ group('intermediate', { ru: ['средний', 'разговорный', 'разговорный уровень'], en: ['intermediate', 'conversational', 'working knowledge'], uk: ['середній', 'розмовний'], ro: ['intermediar', 'conversațional', 'conversational'], uzLatn: ['orta', "o'rta", 'suhbat darajasi'], uzCyrl: ['ўрта', 'суҳбат даражаси'], kk: ['орта', 'сөйлесу деңгейі'] }),
31
+ group('upperIntermediate', { ru: ['выше среднего', 'upper-intermediate'], en: ['upper-intermediate', 'upper intermediate'], uk: ['вище середнього'], ro: ['upper-intermediate'], uzLatn: ['upper-intermediate'], uzCyrl: ['upper-intermediate'], kk: ['upper-intermediate'] }),
32
+ group('advanced', { ru: ['продвинутый', 'уверенный'], en: ['advanced', 'proficient'], uk: ['просунутий', 'впевнений'], ro: ['avansat', 'competent'], uzLatn: ['yuqori daraja', 'ishonchli'], uzCyrl: ['юқори даража'], kk: ['жоғары деңгей', 'сенімді'] }),
33
+ group('fluent', { ru: ['свободный', 'свободное владение', 'в совершенстве'], en: ['fluent', 'fluency', 'full professional proficiency'], uk: ['вільний', 'вільне володіння', 'досконало'], ro: ['fluent', 'fluență', 'fluenta'], uzLatn: ['erkin', 'erkin gaplashish'], uzCyrl: ['эркин', 'эркин гаплашиш'], kk: ['еркін', 'еркін сөйлейді'] }),
34
+ group('native', { ru: ['родной', 'носитель языка'], en: ['native', 'native speaker', 'mother tongue'], uk: ['рідна', 'носій мови'], ro: ['nativ', 'vorbitor nativ', 'limbă maternă'], uzLatn: ['ona tili', 'native speaker'], uzCyrl: ['она тили'], kk: ['ана тілі', 'тіл иесі'] }),
35
+ ]);
36
+
37
+ export const LANGUAGE_CONTEXT_TERMS = Object.freeze({
38
+ required: group('required', {
39
+ ru: ['требуется знание языка', 'знание языка обязательно', 'обязательное знание', 'необходимо знание', 'необходим язык', 'требуется владение', 'свободное владение обязательно', 'со знанием английского', 'английский обязателен'],
40
+ en: ['language required', 'english required', 'must speak', 'must know', 'fluency required', 'proficiency required', 'required language'],
41
+ uk: ['знання мови обов’язкове', "знання мови обов'язкове", 'потрібне знання мови', 'необхідне знання'],
42
+ ro: ['limba este obligatorie', 'cunoașterea limbii este obligatorie', 'trebuie să vorbească'],
43
+ uzLatn: ['til bilish talab qilinadi', 'tilni bilish shart', 'ingliz tilini bilish kerak', 'tilni yaxshi bilishi kerak'],
44
+ uzCyrl: ['тил билиш талаб қилинади', 'тилни билиш шарт', 'инглиз тилини билиш керак'],
45
+ kk: ['тіл білу талап етіледі', 'тілді білу міндетті', 'ағылшын тілін білу қажет'],
46
+ }),
47
+ preferred: group('preferred', {
48
+ ru: ['желательно знание', 'знание будет плюсом', 'будет преимуществом', 'приветствуется знание'],
49
+ en: ['preferred', 'nice to have', 'language is a plus', 'would be an advantage'],
50
+ uk: ['бажане знання', 'знання буде плюсом', 'буде перевагою'],
51
+ ro: ['cunoașterea constituie un avantaj', 'de preferat să cunoască'],
52
+ uzLatn: ['til bilsa yaxshi', 'til bilishi afzal', 'til plus boladi'],
53
+ uzCyrl: ['тил билса яхши', 'тил билиши афзал'],
54
+ kk: ['тіл білу артықшылық болады', 'тіл білгені дұрыс'],
55
+ }),
56
+ notRequired: group('notRequired', {
57
+ ru: ['язык не требуется', 'знание языка не обязательно', 'без обязательного знания', 'язык не нужен'],
58
+ en: ['language not required', 'english not required', 'language optional', 'no language requirement'],
59
+ uk: ['мова не потрібна', 'знання мови не обов’язкове'],
60
+ ro: ['limba nu este necesară', 'limba nu este obligatorie'],
61
+ uzLatn: ['til shart emas', 'til bilish talab qilinmaydi'],
62
+ uzCyrl: ['тил шарт эмас', 'тил билиш талаб қилинмайди'],
63
+ kk: ['тіл міндетті емес', 'тіл білу талап етілмейді'],
64
+ }),
65
+ candidateHas: group('candidateHas', {
66
+ ru: ['владею языками', 'знаю английский', 'владею английским', 'свободно говорю', 'разговорный английский', 'английский на уровне'],
67
+ en: ['i speak', 'i know', 'fluent in', 'proficient in', 'native speaker', 'working proficiency'],
68
+ uk: ['володію мовами', 'знаю англійську', 'вільно говорю'],
69
+ ro: ['vorbesc', 'cunosc limba', 'fluent în', 'fluent in'],
70
+ uzLatn: ['tillarni bilaman', 'ingliz tilini bilaman', 'erkin gaplashaman'],
71
+ uzCyrl: ['тилларни биламан', 'инглиз тилини биламан', 'эркин гаплашаман'],
72
+ kk: ['тілдерді білемін', 'ағылшын тілін білемін', 'еркін сөйлеймін'],
73
+ }),
74
+ });
75
+
76
+ const CEFR_RE = /(?:^|[^A-Za-z0-9])(A1|A2|B1|B2|C1|C2)(?![A-Za-z0-9])/i;
77
+ const NEGATION_RE = /(?:не\s+(?:требуется|обязател|нужен|нужна)|без\s+обязатель|not\s+required|not\s+mandatory|optional|nu\s+(?:este\s+)?(?:necesar|obligatoriu)|shart\s+emas|talab\s+qilinmaydi|міндетті\s+емес|талап\s+етілмейді)/iu;
78
+ const REQUIRED_RE = /(?:обязател|требуется|необходим|required|must\s+(?:speak|know)|mandatory|потріб|необхід|obligatoriu|trebuie|shart|talab\s+qilinadi|міндетті|талап\s+етіледі|қажет)/iu;
79
+ const PREFERRED_RE = /(?:желатель|плюс|приветств|preferred|nice\s+to\s+have|advantage|бажан|переваг|avantaj|afzal|bolsa\s+yaxshi|артықшылық)/iu;
80
+ const CANDIDATE_RE = /(?:владею|знаю|говорю|мой\s+уровень|i\s+(?:speak|know)|fluent\s+in|proficient\s+in|володію|vorbesc|bilaman|gaplashaman|білемін|сөйлеймін)/iu;
81
+
82
+ function relationFromWindow(window, mode) {
83
+ if (NEGATION_RE.test(window)) return 'notRequired';
84
+ if (PREFERRED_RE.test(window)) return 'preferred';
85
+ if (REQUIRED_RE.test(window)) return 'required';
86
+ if (CANDIDATE_RE.test(window)) return 'candidateHas';
87
+ return mode === 'candidate' ? 'candidateHas' : mode === 'vacancy' ? 'mentioned' : 'mentioned';
88
+ }
89
+
90
+ function levelFromWindow(window) {
91
+ const cefr = window.match(CEFR_RE)?.[1]?.toUpperCase() || null;
92
+ if (cefr) return { level: null, cefr };
93
+ const qualitative = findCanonical(window, LANGUAGE_LEVELS, { partial: true });
94
+ return { level: qualitative?.canonical || null, cefr: null };
95
+ }
96
+
97
+ export function languageByCode(code) {
98
+ return LANGUAGES.find((entry) => entry.canonical === String(code || '').toLowerCase()) || null;
99
+ }
100
+
101
+ export function parseLanguageMentions(value, { mode = null } = {}) {
102
+ const text = normalizeUnicode(value || '');
103
+ if (!text) return [];
104
+ const results = [];
105
+ for (const language of LANGUAGES) {
106
+ const re = aliasesToRegex([language.canonical, language.name, ...aliasesOf(language)], 'giu');
107
+ for (const match of text.matchAll(re)) {
108
+ const index = match.index ?? 0;
109
+ const raw = match[0].trim();
110
+ const start = Math.max(0, index - 90);
111
+ const end = Math.min(text.length, index + match[0].length + 90);
112
+ const window = text.slice(start, end);
113
+ const levels = levelFromWindow(window);
114
+ results.push(Object.freeze({
115
+ language: language.canonical,
116
+ name: language.name,
117
+ relation: relationFromWindow(window, mode),
118
+ level: levels.level,
119
+ cefr: levels.cefr,
120
+ matched: raw,
121
+ index,
122
+ }));
123
+ }
124
+ }
125
+ results.sort((a, b) => a.index - b.index);
126
+ const deduped = [];
127
+ const seen = new Set();
128
+ for (const result of results) {
129
+ const key = `${result.language}:${result.relation}:${result.cefr || result.level || ''}`;
130
+ if (seen.has(key)) continue;
131
+ seen.add(key);
132
+ deduped.push(result);
133
+ }
134
+ return deduped;
135
+ }