@whiteslove/parsing-lexicon 0.8.4 → 0.9.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/package.json +1 -1
- package/src/housing-money.js +20 -0
- package/src/temporal-calendar-date.js +72 -0
- package/src/temporal-clock.js +46 -0
- package/src/temporal-duration.js +44 -0
- package/src/temporal-relative-date.js +38 -0
- package/src/temporal-schedule.js +65 -0
- package/src/temporal-shared.js +54 -0
- package/src/temporal-weekday.js +17 -0
- package/src/temporal.js +25 -222
package/package.json
CHANGED
package/src/housing-money.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
import { maskPhoneLikeSpans } from './contact.js';
|
|
11
11
|
import { DEPOSIT_TERMS, SELLER_TERMS } from './housing.js';
|
|
12
12
|
import { COUNTRIES, canonicalCountryCode, countryByCode } from './countries.js';
|
|
13
|
+
import { extractTemporalCandidates } from './temporal.js';
|
|
13
14
|
import {
|
|
14
15
|
classifyHousingSingleMSpans,
|
|
15
16
|
HOUSING_NUMERIC_SPAN_TYPES,
|
|
@@ -94,6 +95,21 @@ function parsedMoneyAmount(numberValue, scaleValue) {
|
|
|
94
95
|
|
|
95
96
|
const APPROXIMATE_RE = /около|примерно|~|≈/iu;
|
|
96
97
|
|
|
98
|
+
// A number already claimed by a duration/date/schedule reading ("на 1200
|
|
99
|
+
// дней", "с 2027 года") is not a plausible bare price — it is at least as
|
|
100
|
+
// likely to be that temporal value's own count/year as a price digit that
|
|
101
|
+
// happens to sit near it. This is only used as a last-resort exclusion in
|
|
102
|
+
// the bare-amount fallback below, once every explicit currency/scale/
|
|
103
|
+
// keyword path has already failed to find a price. Only reasonably
|
|
104
|
+
// confident temporal candidates disqualify a span, so an ambiguous bare
|
|
105
|
+
// number elsewhere in the text is unaffected. Calls temporal.js directly
|
|
106
|
+
// rather than semantic-spans.js: that module itself depends on this file's
|
|
107
|
+
// extractHousingMoneyCandidates(), so importing it here would cycle back.
|
|
108
|
+
function overlapsConfidentTemporalSpan(text, start, end) {
|
|
109
|
+
return extractTemporalCandidates(text).some((item) => (Number(item.confidence) || 0) >= 0.5
|
|
110
|
+
&& start < item.end && item.start < end);
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
function perSquareMeterMatches(text, fallbackCurrency = '') {
|
|
98
114
|
const matches = [];
|
|
99
115
|
const seen = new Set();
|
|
@@ -561,6 +577,10 @@ export function parseHousingPrice(value, context = '') {
|
|
|
561
577
|
const hasPriceEvidence = PRICE_KEYWORD_RE.test(window) || Boolean(moneyCurrencyFromText(window, ''));
|
|
562
578
|
if (!hasPriceEvidence) continue;
|
|
563
579
|
}
|
|
580
|
+
// A number already read as a duration/date/schedule value ("\u043D\u0430 1200
|
|
581
|
+
// \u0434\u043D\u0435\u0439") is not a plausible bare price either \u2014 e.g. "\u0441\u0434\u0430\u044E \u043D\u0430 1200
|
|
582
|
+
// \u0434\u043D\u0435\u0439" must not report 1200 as the rent.
|
|
583
|
+
if (overlapsConfidentTemporalSpan(priceText, start, start + raw.length)) continue;
|
|
564
584
|
if (best == null || amount > best) best = amount;
|
|
565
585
|
}
|
|
566
586
|
price = best;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { HIRING_MONTHS } from './hiring-temporal.js';
|
|
2
|
+
import {
|
|
3
|
+
candidate,
|
|
4
|
+
dateEntityType,
|
|
5
|
+
dateValue,
|
|
6
|
+
inferYear,
|
|
7
|
+
inferredDateEvidence,
|
|
8
|
+
referenceDate,
|
|
9
|
+
referenceEntry,
|
|
10
|
+
relationNear,
|
|
11
|
+
temporalContextType,
|
|
12
|
+
validDate,
|
|
13
|
+
} from './temporal-shared.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* CalendarDateParser: absolute calendar dates — numeric ("12.01.2027",
|
|
17
|
+
* "2027-01-12"), month-name ("12 января" / "January 12"), and "end of
|
|
18
|
+
* <month>" phrasing. Produces calendarDate/availabilityDate/
|
|
19
|
+
* availabilityUntil/deadline/publicationDate candidates depending on
|
|
20
|
+
* nearby from/until wording and domain context (see dateEntityType).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const MONTH_NAMES = Object.freeze(Object.keys(HIRING_MONTHS).sort((a, b) => b.length - a.length).join('|'));
|
|
24
|
+
const DATE_WORD_RE = new RegExp(`(?<![\\p{L}\\p{N}])(\\d{1,2})\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
|
|
25
|
+
const DATE_MONTH_FIRST_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${MONTH_NAMES})\\s+(\\d{1,2})(?:,?\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'iu');
|
|
26
|
+
const DATE_NUMERIC_RE = /(?<!\d)(?:(20\d{2})-(\d{1,2})-(\d{1,2})|(\d{1,2})[./](\d{1,2})[./](20\d{2}))(?!\d)/u;
|
|
27
|
+
const DATE_NUMERIC_PARTIAL_RE = /(?<![\d.])(\d{1,2})[./](\d{1,2})(?![\d.])/u;
|
|
28
|
+
const END_OF_MONTH_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:до|until|p[âa]nă\\s+la|gacha|дейін|чейин|available\\s+until)\\s+(?:конца|кінця|sf[âa]rșit(?:ul)?(?:\\s+lunii)?|end\\s+of)\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'giu');
|
|
29
|
+
const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап)\s+(?:начала\s+месяца|початку\s+місяця|începutul\s+lunii|start\s+of\s+(?:the\s+)?month|1(?:-го)?\s+числа|первого\s+числа|1(?:-го)?\s+числа\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
30
|
+
const NEXT_MONTH_RE = /(?<![\p{L}\p{N}])(?:next\s+month|со?\s+следующего\s+месяца|з\s+наступного\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
31
|
+
|
|
32
|
+
function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
|
|
33
|
+
function dateAtNextMonthStart(reference) { const month = reference.getUTCMonth() + 2; const year = reference.getUTCFullYear() + Math.floor((month - 1) / 12); return dateValue(year, ((month - 1) % 12) + 1, 1); }
|
|
34
|
+
|
|
35
|
+
export function extractCalendarDateCandidates(text, context = {}) {
|
|
36
|
+
const candidates = [];
|
|
37
|
+
for (const match of text.matchAll(new RegExp(DATE_NUMERIC_RE, 'gu'))) {
|
|
38
|
+
const date = match[1] ? dateValue(Number(match[1]), Number(match[2]), Number(match[3])) : dateValue(Number(match[6]), Number(match[5]), Number(match[4]));
|
|
39
|
+
if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context), date, match, 'temporal.calendar.numeric', .98, [{ type: 'regex', rule: 'numeric-date' }]));
|
|
40
|
+
}
|
|
41
|
+
for (const match of text.matchAll(new RegExp(DATE_NUMERIC_PARTIAL_RE, 'gu'))) {
|
|
42
|
+
const start = match.index ?? 0; const around = text.slice(Math.max(0, start - 40), start + match[0].length + 40);
|
|
43
|
+
if (!/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?|свобод|доступ|заезд|заезж|available|move[- ]?in|(?<![\p{L}\p{N}])(?:с|до|from|until)(?![\p{L}\p{N}]))/iu.test(around)) continue;
|
|
44
|
+
const relation = relationNear(text, start); const inferred = true;
|
|
45
|
+
const date = dateValue(inferYear(Number(match[2]), Number(match[1]), relation, context), Number(match[2]), Number(match[1]));
|
|
46
|
+
if (validDate(date)) candidates.push(candidate(dateEntityType(text, start, context, relation), date, match, 'temporal.calendar.numeric-partial', .86, [{ type: 'regex', rule: 'numeric-partial-date' }, ...inferredDateEvidence(inferred, context)]));
|
|
47
|
+
}
|
|
48
|
+
for (const match of text.matchAll(new RegExp(DATE_WORD_RE, 'giu'))) {
|
|
49
|
+
const monthIndex = HIRING_MONTHS[match[2].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
50
|
+
const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[1]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[1]));
|
|
51
|
+
if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-name', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[2] }, ...inferredDateEvidence(inferred, context)]));
|
|
52
|
+
}
|
|
53
|
+
for (const match of text.matchAll(new RegExp(DATE_MONTH_FIRST_RE, 'giu'))) {
|
|
54
|
+
const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
55
|
+
const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[2]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[2]));
|
|
56
|
+
if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-first', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, ...inferredDateEvidence(inferred, context)]));
|
|
57
|
+
}
|
|
58
|
+
for (const match of text.matchAll(END_OF_MONTH_RE)) {
|
|
59
|
+
const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
60
|
+
const inferred = !match[2]; const year = inferred ? inferYear(monthIndex + 1, 1, 'until', context) : Number(match[2]);
|
|
61
|
+
candidates.push(candidate('availabilityUntil', dateAtMonthEnd(year, monthIndex + 1), match, 'temporal.calendar.month-end', inferred ? .86 : .94, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, { type: 'relation', value: 'until-month-end' }, ...inferredDateEvidence(inferred, context)]));
|
|
62
|
+
}
|
|
63
|
+
for (const match of text.matchAll(START_OF_MONTH_RE)) {
|
|
64
|
+
const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
|
|
65
|
+
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.month-start', .88, [{ type: 'context', value: 'month-start' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
66
|
+
}
|
|
67
|
+
for (const match of text.matchAll(NEXT_MONTH_RE)) {
|
|
68
|
+
const date = dateAtNextMonthStart(referenceDate(context));
|
|
69
|
+
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.next-month', .88, [{ type: 'context', value: 'next-month' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
70
|
+
}
|
|
71
|
+
return candidates;
|
|
72
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { SCHEDULE_CONTEXT_RE } from './temporal-schedule.js';
|
|
2
|
+
import { candidate } from './temporal-shared.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ClockTimeParser + TimeRangeRefiner: a time-of-day ("07:00", "7 утра")
|
|
6
|
+
* and time ranges ("с 07.00 до 19.00", "22:00-06:00", detecting an
|
|
7
|
+
* overnight range that crosses midnight). A bare clock time is only kept
|
|
8
|
+
* when it carries an am/pm-style suffix or nearby schedule-context
|
|
9
|
+
* wording — otherwise a bare "7" is at least as likely to be a price or
|
|
10
|
+
* an unrelated number, so it stays unresolved.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const TIME_SUFFIX = String.raw`(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)`;
|
|
14
|
+
const TIME_RE = new RegExp(String.raw`(?<![\d.:])(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?(?![\d.:])`, 'iu');
|
|
15
|
+
const TIME_RANGE_RE = new RegExp(String.raw`(?:с|з|from|dan)?\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)\s*(?:до|to|gacha|дейін|чейин|-|–|—)\s*(\d{1,2}(?:[:.]\d{2})?\s*${TIME_SUFFIX}?)`, 'iu');
|
|
16
|
+
|
|
17
|
+
export function parseClock(raw) { const match = String(raw).trim().match(new RegExp(String.raw`^(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?$`, 'iu')); if (!match) return null; let hour = Number(match[1]); const minute = Number(match[2] || 0); const suffix = String(match[3] || '').toLowerCase(); if (suffix === 'pm' && hour < 12) hour += 12; if (suffix === 'am' && hour === 12) hour = 0; if (/(?:вечера|вечора|kechqurun)/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
|
|
18
|
+
|
|
19
|
+
function isTimeRangeContextual(match, text) {
|
|
20
|
+
if (/[.:]|\b(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)\b/iu.test(match[0])) return true;
|
|
21
|
+
if (/^\s*(?:с|from)\b/iu.test(match[0])) return true;
|
|
22
|
+
const start = match.index ?? 0;
|
|
23
|
+
return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
|
|
24
|
+
}
|
|
25
|
+
function isClockContextual(match, text) {
|
|
26
|
+
if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
|
|
27
|
+
const start = match.index ?? 0;
|
|
28
|
+
return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractClockCandidates(text) {
|
|
32
|
+
const candidates = [];
|
|
33
|
+
const timeRangeSpans = [];
|
|
34
|
+
for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
|
|
35
|
+
const start = parseClock(match[1]); const end = parseClock(match[2]);
|
|
36
|
+
if (!start || !end || !isTimeRangeContextual(match, text)) continue;
|
|
37
|
+
timeRangeSpans.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
|
|
38
|
+
candidates.push(candidate('timeRange', Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }), match, 'temporal.time-range', .98, [{ type: 'range', value: 'clock-time' }]));
|
|
39
|
+
}
|
|
40
|
+
for (const match of text.matchAll(new RegExp(TIME_RE, 'giu'))) {
|
|
41
|
+
const start = match.index ?? 0; const end = start + match[0].length;
|
|
42
|
+
if (timeRangeSpans.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd) || !isClockContextual(match, text)) continue;
|
|
43
|
+
const clock = parseClock(match[0]); if (clock) candidates.push(candidate('clockTime', clock, match, 'temporal.clock-time', .94, [{ type: 'clock', value: 'explicit' }]));
|
|
44
|
+
}
|
|
45
|
+
return candidates;
|
|
46
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { candidate } from './temporal-shared.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* DurationParser: a length of time with an optional bound (min/max/exact)
|
|
5
|
+
* — "3 месяца", "от 3х мес.", "минимум 3 месяца", "на полгода". Semantic
|
|
6
|
+
* purpose (rental duration vs probation vs contract vs generic) is
|
|
7
|
+
* resolved by semanticDurationType from surrounding context, not by the
|
|
8
|
+
* raw duration reading itself.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DURATION_PREFIX = String.raw`(?:от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш(?:е)?|at\s+least|до|не\s+более|ko'?pi\s+bilan|көп\s+емес|на|uchun|pe\s+o\s+perioadă\s+de)`;
|
|
12
|
+
const DURATION_UNIT = String.raw`(?:полгода|год(?:а|ов)?|yil(?:ga)?|жыл(?:ға)?|рок(?:и|ів)?|an(?:i)?|месяц(?:а|ев)?|мес\.?|oy(?:ga)?|місяц(?:і|ів|я)?|luni?|недел[ьяи]|hafta(?:ga)?|апта(?:ға)?|тиж(?:день|ні|нів|ня)|săptămân\p{L}*|saptaman\p{L}*|дн(?:я|ей)?|день|kun(?:ga)?|күн(?:ге)?|день|днів|дні|zi(?:le)?|час(?:а|ов)?|soat(?:ga)?|сағат(?:қа)?|годин(?:а|и|у)?|ore?|мин(?:ут[аы]?|\.)?|daqiqa|минут\p{L}*|minute?)`;
|
|
13
|
+
const DURATION_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(` + DURATION_UNIT + String.raw`)(?:\s*)?(\d+(?:[.,]\d+)?)?(?![\p{L}\p{N}])`, 'iu');
|
|
14
|
+
const DURATION_NUMBER_FIRST_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(\d+(?:[.,]\d+)?)(?:-?х)?\s*(` + DURATION_UNIT + String.raw`)(?![\p{L}\p{N}])`, 'iu');
|
|
15
|
+
|
|
16
|
+
function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year|yil|жыл|рок|\ban/i.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month|\boy|місяц|lun/i.test(normalized) ? 'month' : /нед|week|hafta|апта|тиж|săptăm|saptaman/i.test(normalized) ? 'week' : /дн|день|day|\bkun|күн|zi/i.test(normalized) ? 'day' : /мин|minute|daqiqa/i.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш|at\s+least/iu.test(prefix || '') ? 'min' : /до|не\s+более|ko'?pi\s+bilan|көп\s+емес/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
|
|
17
|
+
|
|
18
|
+
function semanticDurationType(text, start, duration) {
|
|
19
|
+
const before = text.slice(Math.max(0, start - 56), start);
|
|
20
|
+
const after = text.slice(start, Math.min(text.length, start + 56));
|
|
21
|
+
if (/(?:испытательн(?:ый)?\s+срок|probation)[^.;\n]{0,30}$/iu.test(before)) return 'probationDuration';
|
|
22
|
+
if (/(?:контракт|contract)[^.;\n]{0,30}$/iu.test(before)) return 'contractDuration';
|
|
23
|
+
if (/(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)/iu.test(after)) {
|
|
24
|
+
if (duration?.bound === 'max') return 'maximumRentalDuration';
|
|
25
|
+
if (duration?.bound === 'exact') return 'fixedRentalDuration';
|
|
26
|
+
return 'minimumRentalDuration';
|
|
27
|
+
}
|
|
28
|
+
return 'duration';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function extractDurationCandidates(text) {
|
|
32
|
+
const candidates = [];
|
|
33
|
+
for (const match of text.matchAll(new RegExp(DURATION_NUMBER_FIRST_RE, 'giu'))) { const value = durationValue(match[1], match[2], match[3]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
|
|
34
|
+
for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
|
|
35
|
+
if (match[3]) continue;
|
|
36
|
+
// Bare genitive "дня" is almost always part of another phrase ("конца
|
|
37
|
+
// дня", "сегодняшнего дня", "через 2 дня") rather than a standalone
|
|
38
|
+
// 1-day duration. Only accept it here when an explicit duration prefix
|
|
39
|
+
// ("на", "минимум", ...) makes the duration reading unambiguous.
|
|
40
|
+
if (!match[1] && /^дня$/iu.test(match[2])) continue;
|
|
41
|
+
const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])]));
|
|
42
|
+
}
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { nextWeekday, DAY_ALIASES, DAY_PATTERN } from './temporal-weekday.js';
|
|
2
|
+
import { addUtcDays, candidate, referenceDate, referenceEntry, temporalContextType } from './temporal-shared.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* RelativeDateParser: dates expressed relative to the reference date —
|
|
6
|
+
* "сегодня"/"завтра"/"через 3 дня", and "next <weekday>". Semantic role
|
|
7
|
+
* (availabilityDate/startDate/plain relativeDate) is assigned by
|
|
8
|
+
* temporalContextType based on nearby wording, not by this parser.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(послезавтрашн\p{L}*\s+дн\p{L}*|сегодняшн\p{L}*\s+дн\p{L}*|завтрашн\p{L}*\s+дн\p{L}*|післязавтрашн\p{L}*\s+дн\p{L}*|сьогоднішн\p{L}*\s+дн\p{L}*|сегодня|завтра|послезавтра|сьогодні|післязавтра|bugun|ertaga|indin|бүгін|ертең|бүрсігүні|бүгүн|эртең|бүрсүгүнү|astăzi|azi|mâine|maine|poimâine|poimaine|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
|
|
12
|
+
const EXTENDED_RELATIVE_RE = /(?<![\p{L}\p{N}])(?:через\s+(\d+)\s+(дні|днів|тижд(?:ень|ні|нів|ня))|peste\s+(\d+)\s+(zile?|săptămân(?:ă|i)|saptaman(?:a|i))|(\d+)\s+(күннен|аптадан|кун(?:дөн|дон)|жумадан)\s+(?:кейін|кийин))(?![\p{L}\p{N}])/giu;
|
|
13
|
+
const NEXT_WEEKDAY_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:со?\\s+следующ(?:его|ей)\\s+|з\\s+наступн(?:ого|ої)\\s+|next\\s+)(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
|
|
14
|
+
|
|
15
|
+
function relativeDays(raw) { const lower = raw.toLowerCase(); if (/сегодня|сьогодні|bugun|бүгін|бүгүн|astăzi|azi|today/u.test(lower)) return 0; if (/послезавтра|післязавтра|indin|бүрсігүні|бүрсүгүнү|poimâine|poimaine|day\s+after/u.test(lower)) return 2; if (/завтра|ertaga|ертең|эртең|mâine|maine|tomorrow/u.test(lower)) return 1; if (/две\s+недели/u.test(lower)) return 14; if (/неделю/u.test(lower)) return 7; const numeric = Number(lower.match(/\d+/u)?.[0]); return Number.isFinite(numeric) ? numeric : null; }
|
|
16
|
+
function relativeDurationDays(amount, unit) {
|
|
17
|
+
const value = Number(amount);
|
|
18
|
+
if (!Number.isFinite(value) || value < 0) return null;
|
|
19
|
+
return /(?:тиж|săptăm|saptaman|апта|жума)/iu.test(unit) ? value * 7 : value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function extractRelativeDateCandidates(text, context = {}) {
|
|
23
|
+
const candidates = [];
|
|
24
|
+
for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
|
|
25
|
+
const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
|
|
26
|
+
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), nextWeekday(referenceDate(context), weekday), match, 'temporal.relative.next-weekday', .9, [{ type: 'dictionary', dictionary: 'weekdays', key: match[1] }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
27
|
+
}
|
|
28
|
+
for (const match of text.matchAll(RELATIVE_RE)) { const days = relativeDays(match[1]); if (days == null) continue; const type = temporalContextType(text, match.index ?? 0, context); candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'reference-date', value: referenceEntry(context).source }])); }
|
|
29
|
+
for (const match of text.matchAll(EXTENDED_RELATIVE_RE)) {
|
|
30
|
+
const amount = match[1] || match[3] || match[5];
|
|
31
|
+
const unit = match[2] || match[4] || match[6];
|
|
32
|
+
const days = relativeDurationDays(amount, unit);
|
|
33
|
+
if (days == null) continue;
|
|
34
|
+
const type = temporalContextType(text, match.index ?? 0, context);
|
|
35
|
+
candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date.extended', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'unit', value: unit }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
36
|
+
}
|
|
37
|
+
return candidates;
|
|
38
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DAY_ALIASES, DAY_PATTERN, WEEKDAYS } from './temporal-weekday.js';
|
|
2
|
+
import { candidate } from './temporal-shared.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ScheduleCycleParser + WeekdayParser + multiple-shifts: work-schedule
|
|
6
|
+
* cycles ("2/2", "два через два"), fixed weekday ranges ("Пн-Пт"), named
|
|
7
|
+
* weekday sets ("по будням"/"только по выходным"), and multi-shift
|
|
8
|
+
* listings ("1 смена 07:00-15:00 ... 2 смена ..."). A bare "N/M" ratio is
|
|
9
|
+
* only read as a schedule when SCHEDULE_CONTEXT_RE finds nearby work-
|
|
10
|
+
* schedule language — otherwise it stays ambiguous (never assumed to be a
|
|
11
|
+
* schedule by default).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const SCHEDULE_CONTEXT_RE = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job|графік|змін[аи]|program(?:ul)?\s+de\s+lucru|ish\s+grafigi|жұмыс\s+кестесі|жумуш\s+графиги)/iu;
|
|
15
|
+
const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
|
|
16
|
+
const SHIFT_RE = /(?<!\d)(\d{1,2})\s*(?:смен[аы]|shift)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:-|–|—|до|to)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/giu;
|
|
17
|
+
|
|
18
|
+
function scheduleDaysOffMode(text) {
|
|
19
|
+
if (/(?:плавающ|floating|flexible\s+days\s+off)/iu.test(text)) return 'floating';
|
|
20
|
+
if (/(?:скользящ|сменн|rotating\s+days\s+off|выходные\s+по\s+графику)/iu.test(text)) return 'rotating';
|
|
21
|
+
return 'fixed';
|
|
22
|
+
}
|
|
23
|
+
function cycleScheduleValue(work, rest, daysOffMode) {
|
|
24
|
+
// Day cycles such as 2/2 and 5/2 are common. Values larger than a week in
|
|
25
|
+
// an explicit schedule context are the conventional shift notation 24/48
|
|
26
|
+
// or 12/24, not a claim of dozens of working days.
|
|
27
|
+
if (Math.max(work, rest) > 7) {
|
|
28
|
+
// An hour cycle has no fixed weekday rest days. Keep an explicitly
|
|
29
|
+
// floating mode, otherwise expose the inherent repeating rotation.
|
|
30
|
+
return Object.freeze({ type: 'cycle', cycleHours: Object.freeze({ work, rest }), daysOffMode: daysOffMode === 'fixed' ? 'rotating' : daysOffMode });
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({ type: 'cycle', workDays: work, restDays: rest, daysOffMode });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractScheduleCandidates(text, parseClock) {
|
|
36
|
+
const candidates = [];
|
|
37
|
+
const scheduleContext = SCHEDULE_CONTEXT_RE;
|
|
38
|
+
for (const match of text.matchAll(/(?<![\d:.])(\d{1,2})\s*(?:\/|\\|через|-)\s*(\d{1,2})(?![\d:.])/giu)) {
|
|
39
|
+
const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
|
|
40
|
+
const daysOffMode = scheduleDaysOffMode(around);
|
|
41
|
+
const work = Number(match[1]); const rest = Number(match[2]);
|
|
42
|
+
candidates.push(candidate('workSchedule', cycleScheduleValue(work, rest, daysOffMode), match, 'temporal.schedule-cycle', .99, [{ type: 'context', value: 'schedule' }, { type: 'range', value: Math.max(work, rest) > 7 ? 'cycle-hours' : 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
|
|
43
|
+
}
|
|
44
|
+
for (const match of text.matchAll(/(?<![\p{L}\p{N}])два\s+через\s+два(?![\p{L}\p{N}])/giu)) {
|
|
45
|
+
const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
|
|
46
|
+
const daysOffMode = scheduleDaysOffMode(around);
|
|
47
|
+
candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: 2, restDays: 2, daysOffMode }), match, 'temporal.schedule-cycle.words', .97, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
|
|
48
|
+
}
|
|
49
|
+
for (const match of text.matchAll(WEEKDAY_RANGE_RE)) {
|
|
50
|
+
const start = DAY_ALIASES[match[1].toLowerCase()]; const end = DAY_ALIASES[match[2].toLowerCase()];
|
|
51
|
+
if (start == null || end == null || end < start) continue;
|
|
52
|
+
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(start, end + 1), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays', .97, [{ type: 'range', value: 'weekday' }]));
|
|
53
|
+
}
|
|
54
|
+
for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:по\s+будням|weekdays?)(?![\p{L}\p{N}])/giu)) {
|
|
55
|
+
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(0, 5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays.named', .96, [{ type: 'context', value: 'weekdays' }]));
|
|
56
|
+
}
|
|
57
|
+
for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:только\s+по\s+выходным|weekends?\s+only)(?![\p{L}\p{N}])/giu)) {
|
|
58
|
+
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekends', .96, [{ type: 'context', value: 'weekends' }]));
|
|
59
|
+
}
|
|
60
|
+
const shifts = [];
|
|
61
|
+
let firstShift = null; let lastShift = null;
|
|
62
|
+
for (const match of text.matchAll(SHIFT_RE)) { const start = parseClock(match[2]); const end = parseClock(match[3]); if (!start || !end) continue; firstShift ||= match; lastShift = match; shifts.push(Object.freeze({ name: `${match[1]} shift`, hours: Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }) })); }
|
|
63
|
+
if (shifts.length) candidates.push(candidate('shifts', Object.freeze(shifts), { 0: text.slice(firstShift.index, (lastShift.index ?? 0) + lastShift[0].length), index: firstShift.index }, 'temporal.multiple-shifts', .98, [{ type: 'context', value: 'shift' }, { type: 'range', value: 'clock-time' }]));
|
|
64
|
+
return candidates;
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createParseCandidate } from './parser-core.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Helpers shared by every temporal sub-parser (calendar dates, relative
|
|
5
|
+
* dates, durations, clock/schedule). Kept in one place so reference-date
|
|
6
|
+
* resolution, candidate construction and from/until relation detection
|
|
7
|
+
* cannot drift between parsers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function referenceEntry(context = {}) {
|
|
11
|
+
for (const [source, value] of [['referenceDate', context.referenceDate], ['publishedAt', context.publishedAt], ['fetchedAt', context.fetchedAt]]) {
|
|
12
|
+
if (value == null || value === '') continue;
|
|
13
|
+
const date = new Date(value);
|
|
14
|
+
if (Number.isFinite(date.getTime())) return Object.freeze({ date, source });
|
|
15
|
+
}
|
|
16
|
+
return Object.freeze({ date: new Date(), source: 'currentDate' });
|
|
17
|
+
}
|
|
18
|
+
export function referenceDate(context = {}) { return referenceEntry(context).date; }
|
|
19
|
+
export function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
|
|
20
|
+
export function validDate(value) { const date = new Date(Date.UTC(value.year, value.month - 1, value.day)); return date.getUTCFullYear() === value.year && date.getUTCMonth() === value.month - 1 && date.getUTCDate() === value.day; }
|
|
21
|
+
export function candidate(entityType, value, match, parser, confidence, evidence) { const start = match.index ?? 0; return createParseCandidate({ id: `${entityType}:${start}`, entityType, value, raw: match[0], start, end: start + match[0].length, parser, confidence, evidence }); }
|
|
22
|
+
|
|
23
|
+
// Bare "с"/"з"/"до" are single Cyrillic letters and match as a substring of
|
|
24
|
+
// countless ordinary words ("Сдаю", "доступна") without a boundary guard —
|
|
25
|
+
// "Сдаю до 15.03" was misread as 'from' off the "С" in "Сдаю" alone, hiding
|
|
26
|
+
// the actual "до" (until) marker that followed.
|
|
27
|
+
const RELATION_FROM_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))(?![\p{L}\p{N}])/iu;
|
|
28
|
+
const RELATION_UNTIL_RE = /(?<![\p{L}\p{N}])(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)(?![\p{L}\p{N}])/iu;
|
|
29
|
+
export function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return RELATION_FROM_RE.test(before) ? 'from' : RELATION_UNTIL_RE.test(before) ? 'until' : 'exact'; }
|
|
30
|
+
|
|
31
|
+
// A year-less date is assumed to refer to its next occurrence when the
|
|
32
|
+
// current year's reading has already passed — not just for explicit
|
|
33
|
+
// "from"/"until" wording. A bare mention with no relation ('exact') is the
|
|
34
|
+
// common case (e.g. "12 января встреча") and would otherwise silently
|
|
35
|
+
// resolve to a date up to a year in the past.
|
|
36
|
+
export function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
|
|
37
|
+
|
|
38
|
+
export function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: referenceEntry(context).source }] : []; }
|
|
39
|
+
|
|
40
|
+
export function dateEntityType(text, start, context, relation = relationNear(text, start)) {
|
|
41
|
+
const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48));
|
|
42
|
+
if (/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)/iu.test(around)) return 'deadline';
|
|
43
|
+
if (/(?:published|publication|опубликован|опублікован)/iu.test(around)) return 'publicationDate';
|
|
44
|
+
// Direction must belong to this date's local left context. Looking ahead
|
|
45
|
+
// across a whole listing makes "available from 12 February, until March"
|
|
46
|
+
// incorrectly classify the February date as an end date.
|
|
47
|
+
if (relation === 'until') return 'availabilityUntil';
|
|
48
|
+
if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|вільн|disponibil|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
|
|
49
|
+
return 'calendarDate';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function temporalContextType(text, start, context) { const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48)); if (/(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|бос|бош|вільн|disponibil|move[- ]?in|available)/iu.test(around)) return 'availabilityDate'; if ((context.domain === 'vacancy' || /(?:ваканс|работ|job|ish\s+grafigi|графік)/iu.test(around)) && /(?:выход|start|приступ|boshlash)/iu.test(around)) return 'startDate'; return 'relativeDate'; }
|
|
53
|
+
|
|
54
|
+
export function addUtcDays(date, days) { const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); return dateValue(copy.getUTCFullYear(), copy.getUTCMonth() + 1, copy.getUTCDate()); }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { addUtcDays } from './temporal-shared.js';
|
|
2
|
+
|
|
3
|
+
/** Weekday vocabulary shared by RelativeDateParser (next-weekday) and ScheduleCycleParser (weekday ranges). */
|
|
4
|
+
|
|
5
|
+
export const WEEKDAYS = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
|
|
6
|
+
export const DAY_ALIASES = Object.freeze({
|
|
7
|
+
пн: 0, понедельник: 0, понедельника: 0, понеділок: 0, понеділка: 0, mon: 0, monday: 0, dushanba: 0, дүйсенбі: 0, дүйшөмбү: 0, luni: 0,
|
|
8
|
+
вт: 1, вторник: 1, вторника: 1, вівторок: 1, вівторка: 1, tue: 1, tuesday: 1, seshanba: 1, сейсенбі: 1, шейшемби: 1, marți: 1, marti: 1,
|
|
9
|
+
ср: 2, среда: 2, среды: 2, середа: 2, середи: 2, wed: 2, wednesday: 2, chorshanba: 2, сәрсенбі: 2, шаршемби: 2, miercuri: 2,
|
|
10
|
+
чт: 3, четверг: 3, четверга: 3, четвер: 3, четверга: 3, thu: 3, thursday: 3, payshanba: 3, бейсенбі: 3, бейшемби: 3, joi: 3,
|
|
11
|
+
пт: 4, пятница: 4, пятницы: 4, "п'ятниця": 4, 'п’ятниця': 4, пятниця: 4, fri: 4, friday: 4, juma: 4, жұма: 4, жума: 4, vineri: 4,
|
|
12
|
+
сб: 5, суббота: 5, субботы: 5, субота: 5, sat: 5, saturday: 5, shanba: 5, сенбі: 5, ишемби: 5, sâmbătă: 5, sambata: 5,
|
|
13
|
+
вс: 6, воскресенье: 6, воскресенья: 6, неділя: 6, неділю: 6, sun: 6, sunday: 6, yakshanba: 6, жексенбі: 6, жекшемби: 6, duminică: 6, duminica: 6,
|
|
14
|
+
});
|
|
15
|
+
export const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
|
|
16
|
+
|
|
17
|
+
export function nextWeekday(date, weekday) { const current = (date.getUTCDay() + 6) % 7; let days = (weekday - current + 7) % 7; if (days === 0) days = 7; return addUtcDays(date, days); }
|
package/src/temporal.js
CHANGED
|
@@ -1,229 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { resolveParseCandidates } from './parser-core.js';
|
|
2
|
+
import { extractCalendarDateCandidates } from './temporal-calendar-date.js';
|
|
3
|
+
import { extractRelativeDateCandidates } from './temporal-relative-date.js';
|
|
4
|
+
import { extractDurationCandidates } from './temporal-duration.js';
|
|
5
|
+
import { extractClockCandidates, parseClock } from './temporal-clock.js';
|
|
6
|
+
import { extractScheduleCandidates } from './temporal-schedule.js';
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const DURATION_NUMBER_FIRST_RE = new RegExp(String.raw`(?<![\p{L}\p{N}])(?:(` + DURATION_PREFIX + String.raw`)(?:\s+на)?\s*)?(\d+(?:[.,]\d+)?)(?:-?х)?\s*(` + DURATION_UNIT + String.raw`)(?![\p{L}\p{N}])`, 'iu');
|
|
16
|
-
const WEEKDAYS = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
|
|
17
|
-
const DAY_ALIASES = Object.freeze({
|
|
18
|
-
пн: 0, понедельник: 0, понедельника: 0, понеділок: 0, понеділка: 0, mon: 0, monday: 0, dushanba: 0, дүйсенбі: 0, дүйшөмбү: 0, luni: 0,
|
|
19
|
-
вт: 1, вторник: 1, вторника: 1, вівторок: 1, вівторка: 1, tue: 1, tuesday: 1, seshanba: 1, сейсенбі: 1, шейшемби: 1, marți: 1, marti: 1,
|
|
20
|
-
ср: 2, среда: 2, среды: 2, середа: 2, середи: 2, wed: 2, wednesday: 2, chorshanba: 2, сәрсенбі: 2, шаршемби: 2, miercuri: 2,
|
|
21
|
-
чт: 3, четверг: 3, четверга: 3, четвер: 3, четверга: 3, thu: 3, thursday: 3, payshanba: 3, бейсенбі: 3, бейшемби: 3, joi: 3,
|
|
22
|
-
пт: 4, пятница: 4, пятницы: 4, "п'ятниця": 4, 'п’ятниця': 4, пятниця: 4, fri: 4, friday: 4, juma: 4, жұма: 4, жума: 4, vineri: 4,
|
|
23
|
-
сб: 5, суббота: 5, субботы: 5, субота: 5, sat: 5, saturday: 5, shanba: 5, сенбі: 5, ишемби: 5, sâmbătă: 5, sambata: 5,
|
|
24
|
-
вс: 6, воскресенье: 6, воскресенья: 6, неділя: 6, неділю: 6, sun: 6, sunday: 6, yakshanba: 6, жексенбі: 6, жекшемби: 6, duminică: 6, duminica: 6,
|
|
25
|
-
});
|
|
26
|
-
const RELATIVE_RE = /(?<![\p{L}\p{N}])(?:с\s+|з\s+|dan\s+)?(послезавтрашн\p{L}*\s+дн\p{L}*|сегодняшн\p{L}*\s+дн\p{L}*|завтрашн\p{L}*\s+дн\p{L}*|післязавтрашн\p{L}*\s+дн\p{L}*|сьогоднішн\p{L}*\s+дн\p{L}*|сегодня|завтра|послезавтра|сьогодні|післязавтра|bugun|ertaga|indin|бүгін|ертең|бүрсігүні|бүгүн|эртең|бүрсүгүнү|astăzi|azi|mâine|maine|poimâine|poimaine|today|tomorrow|day\s+after\s+tomorrow|через\s+(\d+|неделю|две\s+недели)\s*(?:дн(?:я|ей)?|день|недел[ьюи])?)(?![\p{L}\p{N}])/giu;
|
|
27
|
-
const EXTENDED_RELATIVE_RE = /(?<![\p{L}\p{N}])(?:через\s+(\d+)\s+(дні|днів|тижд(?:ень|ні|нів|ня))|peste\s+(\d+)\s+(zile?|săptămân(?:ă|i)|saptaman(?:a|i))|(\d+)\s+(күннен|аптадан|кун(?:дөн|дон)|жумадан)\s+(?:кейін|кийин))(?![\p{L}\p{N}])/giu;
|
|
28
|
-
const DAY_PATTERN = Object.keys(DAY_ALIASES).sort((a, b) => b.length - a.length).join('|');
|
|
29
|
-
const WEEKDAY_RANGE_RE = new RegExp(`(?<![\\p{L}\\p{N}])(${DAY_PATTERN})\\s*(?:-|–|—|до|to|по)\\s*(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
|
|
30
|
-
const NEXT_WEEKDAY_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:со?\\s+следующ(?:его|ей)\\s+|з\\s+наступн(?:ого|ої)\\s+|next\\s+)(${DAY_PATTERN})(?![\\p{L}\\p{N}])`, 'giu');
|
|
31
|
-
const END_OF_MONTH_RE = new RegExp(`(?<![\\p{L}\\p{N}])(?:до|until|p[âa]nă\\s+la|gacha|дейін|чейин|available\\s+until)\\s+(?:конца|кінця|sf[âa]rșit(?:ul)?(?:\\s+lunii)?|end\\s+of)\\s+(${MONTH_NAMES})(?:\\s+(20\\d{2}))?(?![\\p{L}\\p{N}])`, 'giu');
|
|
32
|
-
const START_OF_MONTH_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап)\s+(?:начала\s+месяца|початку\s+місяця|începutul\s+lunii|start\s+of\s+(?:the\s+)?month|1(?:-го)?\s+числа|первого\s+числа|1(?:-го)?\s+числа\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
33
|
-
const NEXT_MONTH_RE = /(?<![\p{L}\p{N}])(?:next\s+month|со?\s+следующего\s+месяца|з\s+наступного\s+місяця)(?![\p{L}\p{N}])/giu;
|
|
34
|
-
const SHIFT_RE = /(?<!\d)(\d{1,2})\s*(?:смен[аы]|shift)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)\s*(?:-|–|—|до|to)\s*(\d{1,2}(?:[:.]\d{2})?\s*(?:am|pm|утра|вечера)?)/giu;
|
|
35
|
-
|
|
36
|
-
function referenceEntry(context = {}) {
|
|
37
|
-
for (const [source, value] of [['referenceDate', context.referenceDate], ['publishedAt', context.publishedAt], ['fetchedAt', context.fetchedAt]]) {
|
|
38
|
-
if (value == null || value === '') continue;
|
|
39
|
-
const date = new Date(value);
|
|
40
|
-
if (Number.isFinite(date.getTime())) return Object.freeze({ date, source });
|
|
41
|
-
}
|
|
42
|
-
return Object.freeze({ date: new Date(), source: 'currentDate' });
|
|
43
|
-
}
|
|
44
|
-
function referenceDate(context = {}) { return referenceEntry(context).date; }
|
|
45
|
-
function dateValue(year, month, day) { return Object.freeze({ year, month, day }); }
|
|
46
|
-
function validDate(value) { const date = new Date(Date.UTC(value.year, value.month - 1, value.day)); return date.getUTCFullYear() === value.year && date.getUTCMonth() === value.month - 1 && date.getUTCDate() === value.day; }
|
|
47
|
-
function candidate(entityType, value, match, parser, confidence, evidence) { const start = match.index ?? 0; return createParseCandidate({ id: `${entityType}:${start}`, entityType, value, raw: match[0], start, end: start + match[0].length, parser, confidence, evidence }); }
|
|
48
|
-
// Bare "с"/"з"/"до" are single Cyrillic letters and match as a substring of
|
|
49
|
-
// countless ordinary words ("Сдаю", "доступна") without a boundary guard —
|
|
50
|
-
// "Сдаю до 15.03" was misread as 'from' off the "С" in "Сдаю" alone, hiding
|
|
51
|
-
// the actual "до" (until) marker that followed.
|
|
52
|
-
const RELATION_FROM_RE = /(?<![\p{L}\p{N}])(?:с|з|from|din|dan|бастап|баштап|доступна\s+(?:с|з)|заезд\s+(?:с|з))(?![\p{L}\p{N}])/iu;
|
|
53
|
-
const RELATION_UNTIL_RE = /(?<![\p{L}\p{N}])(?:до|until|p[âa]nă\s+la|gacha|дейін|чейин|не\s+позднее)(?![\p{L}\p{N}])/iu;
|
|
54
|
-
function relationNear(text, start) { const before = text.slice(Math.max(0, start - 32), start); return RELATION_FROM_RE.test(before) ? 'from' : RELATION_UNTIL_RE.test(before) ? 'until' : 'exact'; }
|
|
55
|
-
// A year-less date is assumed to refer to its next occurrence when the
|
|
56
|
-
// current year's reading has already passed — not just for explicit
|
|
57
|
-
// "from"/"until" wording. A bare mention with no relation ('exact') is the
|
|
58
|
-
// common case (e.g. "12 января встреча") and would otherwise silently
|
|
59
|
-
// resolve to a date up to a year in the past.
|
|
60
|
-
function inferYear(month, day, relation, context) { const reference = referenceDate(context); let year = reference.getUTCFullYear(); const candidateDate = Date.UTC(year, month - 1, day); if (candidateDate < reference.getTime() - 36 * 3_600_000) year += 1; return year; }
|
|
61
|
-
function parseClock(raw) { const match = String(raw).trim().match(new RegExp(String.raw`^(\d{1,2})(?:[:.](\d{2}))?\s*(${TIME_SUFFIX})?$`, 'iu')); if (!match) return null; let hour = Number(match[1]); const minute = Number(match[2] || 0); const suffix = String(match[3] || '').toLowerCase(); if (suffix === 'pm' && hour < 12) hour += 12; if (suffix === 'am' && hour === 12) hour = 0; if (/(?:вечера|вечора|kechqurun)/u.test(suffix) && hour < 12) hour += 12; return hour < 24 && minute < 60 ? Object.freeze({ hour, minute }) : null; }
|
|
62
|
-
function durationValue(prefix, amount, unit) { const normalized = String(unit).toLowerCase(); const value = normalized === 'полгода' ? .5 : Number(amount || 1); const canonicalUnit = /год|year|yil|жыл|рок|\ban/i.test(normalized) || normalized === 'полгода' ? 'year' : /месяц|мес|month|\boy|місяц|lun/i.test(normalized) ? 'month' : /нед|week|hafta|апта|тиж|săptăm|saptaman/i.test(normalized) ? 'week' : /дн|день|day|\bkun|күн|zi/i.test(normalized) ? 'day' : /мин|minute|daqiqa/i.test(normalized) ? 'minute' : 'hour'; const bound = /от|минимум|не\s+менее|kamida|eng\s+kam|кемінде|не\s+менш|at\s+least/iu.test(prefix || '') ? 'min' : /до|не\s+более|ko'?pi\s+bilan|көп\s+емес/iu.test(prefix || '') ? 'max' : 'exact'; return Object.freeze({ value, unit: canonicalUnit, bound }); }
|
|
63
|
-
function semanticDurationType(text, start, duration) {
|
|
64
|
-
const before = text.slice(Math.max(0, start - 56), start);
|
|
65
|
-
const after = text.slice(start, Math.min(text.length, start + 56));
|
|
66
|
-
if (/(?:испытательн(?:ый)?\s+срок|probation)[^.;\n]{0,30}$/iu.test(before)) return 'probationDuration';
|
|
67
|
-
if (/(?:контракт|contract)[^.;\n]{0,30}$/iu.test(before)) return 'contractDuration';
|
|
68
|
-
if (/(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)[^.;\n]{0,45}$/iu.test(before) || /(?:сда[её]т|квартир|аренд|ijara|rent|ijaraga\s+beril|оренд)/iu.test(after)) {
|
|
69
|
-
if (duration?.bound === 'max') return 'maximumRentalDuration';
|
|
70
|
-
if (duration?.bound === 'exact') return 'fixedRentalDuration';
|
|
71
|
-
return 'minimumRentalDuration';
|
|
72
|
-
}
|
|
73
|
-
return 'duration';
|
|
74
|
-
}
|
|
75
|
-
function addUtcDays(date, days) { const copy = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); return dateValue(copy.getUTCFullYear(), copy.getUTCMonth() + 1, copy.getUTCDate()); }
|
|
76
|
-
function relativeDays(raw) { const lower = raw.toLowerCase(); if (/сегодня|сьогодні|bugun|бүгін|бүгүн|astăzi|azi|today/u.test(lower)) return 0; if (/послезавтра|післязавтра|indin|бүрсігүні|бүрсүгүнү|poimâine|poimaine|day\s+after/u.test(lower)) return 2; if (/завтра|ertaga|ертең|эртең|mâine|maine|tomorrow/u.test(lower)) return 1; if (/две\s+недели/u.test(lower)) return 14; if (/неделю/u.test(lower)) return 7; const numeric = Number(lower.match(/\d+/u)?.[0]); return Number.isFinite(numeric) ? numeric : null; }
|
|
77
|
-
function relativeDurationDays(amount, unit) {
|
|
78
|
-
const value = Number(amount);
|
|
79
|
-
if (!Number.isFinite(value) || value < 0) return null;
|
|
80
|
-
return /(?:тиж|săptăm|saptaman|апта|жума)/iu.test(unit) ? value * 7 : value;
|
|
81
|
-
}
|
|
82
|
-
function temporalContextType(text, start, context) { const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48)); if (/(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|бос|бош|вільн|disponibil|move[- ]?in|available)/iu.test(around)) return 'availabilityDate'; if ((context.domain === 'vacancy' || /(?:ваканс|работ|job|ish\s+grafigi|графік)/iu.test(around)) && /(?:выход|start|приступ|boshlash)/iu.test(around)) return 'startDate'; return 'relativeDate'; }
|
|
83
|
-
function dateEntityType(text, start, context, relation = relationNear(text, start)) {
|
|
84
|
-
const around = text.slice(Math.max(0, start - 48), Math.min(text.length, start + 48));
|
|
85
|
-
if (/(?:deadline|apply\s+by|closing\s+date|дедлайн|срок(?:\s+подачи)?|термін(?:\s+подання)?)/iu.test(around)) return 'deadline';
|
|
86
|
-
if (/(?:published|publication|опубликован|опублікован)/iu.test(around)) return 'publicationDate';
|
|
87
|
-
// Direction must belong to this date's local left context. Looking ahead
|
|
88
|
-
// across a whole listing makes "available from 12 February, until March"
|
|
89
|
-
// incorrectly classify the February date as an end date.
|
|
90
|
-
if (relation === 'until') return 'availabilityUntil';
|
|
91
|
-
if (relation === 'from' || (context.domain === 'real-estate' && /(?:свобод|доступ|заезд|заезж|ijara|bo['’`]?sh|вільн|disponibil|move[- ]?in|available)/iu.test(around))) return 'availabilityDate';
|
|
92
|
-
return 'calendarDate';
|
|
93
|
-
}
|
|
94
|
-
function inferredDateEvidence(inferred, context) { return inferred ? [{ type: 'inferred-year', reference: referenceEntry(context).source }] : []; }
|
|
95
|
-
const SCHEDULE_CONTEXT_RE = /(?:график|смен[аы]|режим\s+работы|work\s*schedule|shift|работ[аы]|job|графік|змін[аи]|program(?:ul)?\s+de\s+lucru|ish\s+grafigi|жұмыс\s+кестесі|жумуш\s+графиги)/iu;
|
|
96
|
-
function scheduleDaysOffMode(text) {
|
|
97
|
-
if (/(?:плавающ|floating|flexible\s+days\s+off)/iu.test(text)) return 'floating';
|
|
98
|
-
if (/(?:скользящ|сменн|rotating\s+days\s+off|выходные\s+по\s+графику)/iu.test(text)) return 'rotating';
|
|
99
|
-
return 'fixed';
|
|
100
|
-
}
|
|
101
|
-
function cycleScheduleValue(work, rest, daysOffMode) {
|
|
102
|
-
// Day cycles such as 2/2 and 5/2 are common. Values larger than a week in
|
|
103
|
-
// an explicit schedule context are the conventional shift notation 24/48
|
|
104
|
-
// or 12/24, not a claim of dozens of working days.
|
|
105
|
-
if (Math.max(work, rest) > 7) {
|
|
106
|
-
// An hour cycle has no fixed weekday rest days. Keep an explicitly
|
|
107
|
-
// floating mode, otherwise expose the inherent repeating rotation.
|
|
108
|
-
return Object.freeze({ type: 'cycle', cycleHours: Object.freeze({ work, rest }), daysOffMode: daysOffMode === 'fixed' ? 'rotating' : daysOffMode });
|
|
109
|
-
}
|
|
110
|
-
return Object.freeze({ type: 'cycle', workDays: work, restDays: rest, daysOffMode });
|
|
111
|
-
}
|
|
112
|
-
function isTimeRangeContextual(match, text) {
|
|
113
|
-
if (/[.:]|\b(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)\b/iu.test(match[0])) return true;
|
|
114
|
-
if (/^\s*(?:с|from)\b/iu.test(match[0])) return true;
|
|
115
|
-
const start = match.index ?? 0;
|
|
116
|
-
return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 32), start + match[0].length + 32));
|
|
117
|
-
}
|
|
118
|
-
function isClockContextual(match, text) {
|
|
119
|
-
if (/(?:am|pm|утра|вечера|ранку|вечора|ertalab|kechqurun)/iu.test(match[0])) return true;
|
|
120
|
-
const start = match.index ?? 0;
|
|
121
|
-
return SCHEDULE_CONTEXT_RE.test(text.slice(Math.max(0, start - 24), start + match[0].length + 24));
|
|
122
|
-
}
|
|
123
|
-
function dateAtMonthEnd(year, month) { return dateValue(year, month, new Date(Date.UTC(year, month, 0)).getUTCDate()); }
|
|
124
|
-
function dateAtNextMonthStart(reference) { const month = reference.getUTCMonth() + 2; const year = reference.getUTCFullYear() + Math.floor((month - 1) / 12); return dateValue(year, ((month - 1) % 12) + 1, 1); }
|
|
125
|
-
function nextWeekday(date, weekday) { const current = (date.getUTCDay() + 6) % 7; let days = (weekday - current + 7) % 7; if (days === 0) days = 7; return addUtcDays(date, days); }
|
|
8
|
+
/**
|
|
9
|
+
* Parser Engine V2's temporal subsystem. Each semantic category — calendar
|
|
10
|
+
* dates, relative dates, durations, clock/time-ranges, work schedules — has
|
|
11
|
+
* its own parser module (temporal-calendar-date.js, temporal-relative-
|
|
12
|
+
* date.js, temporal-duration.js, temporal-clock.js, temporal-schedule.js);
|
|
13
|
+
* this file only orchestrates them, resolves cross-category conflicts, and
|
|
14
|
+
* exposes the public parseTemporal()/extractTemporalCandidates() API.
|
|
15
|
+
* Nothing here returns a bare Date — every result is a typed entity
|
|
16
|
+
* (calendarDate/duration/workSchedule/timeRange/clockTime/...) with its own
|
|
17
|
+
* evidence, confidence and span.
|
|
18
|
+
*/
|
|
126
19
|
|
|
127
20
|
/** Extract generic temporal candidates without silently fabricating a Date. */
|
|
128
21
|
export function extractTemporalCandidates(value, context = {}) {
|
|
129
|
-
const text = String(value || '');
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const relation = relationNear(text, start); const inferred = true;
|
|
138
|
-
const date = dateValue(inferYear(Number(match[2]), Number(match[1]), relation, context), Number(match[2]), Number(match[1]));
|
|
139
|
-
if (validDate(date)) candidates.push(candidate(dateEntityType(text, start, context, relation), date, match, 'temporal.calendar.numeric-partial', .86, [{ type: 'regex', rule: 'numeric-partial-date' }, ...inferredDateEvidence(inferred, context)]));
|
|
140
|
-
}
|
|
141
|
-
for (const match of text.matchAll(new RegExp(DATE_WORD_RE, 'giu'))) {
|
|
142
|
-
const monthIndex = HIRING_MONTHS[match[2].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
143
|
-
const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[1]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[1]));
|
|
144
|
-
if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-name', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[2] }, ...inferredDateEvidence(inferred, context)]));
|
|
145
|
-
}
|
|
146
|
-
for (const match of text.matchAll(new RegExp(DATE_MONTH_FIRST_RE, 'giu'))) {
|
|
147
|
-
const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
148
|
-
const relation = relationNear(text, match.index ?? 0); const inferred = !match[3]; const date = dateValue(inferred ? inferYear(monthIndex + 1, Number(match[2]), relation, context) : Number(match[3]), monthIndex + 1, Number(match[2]));
|
|
149
|
-
if (validDate(date)) candidates.push(candidate(dateEntityType(text, match.index ?? 0, context, relation), date, match, 'temporal.calendar.month-first', inferred ? .88 : .96, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, ...inferredDateEvidence(inferred, context)]));
|
|
150
|
-
}
|
|
151
|
-
for (const match of text.matchAll(END_OF_MONTH_RE)) {
|
|
152
|
-
const monthIndex = HIRING_MONTHS[match[1].toLocaleLowerCase('ru')]; if (monthIndex == null) continue;
|
|
153
|
-
const inferred = !match[2]; const year = inferred ? inferYear(monthIndex + 1, 1, 'until', context) : Number(match[2]);
|
|
154
|
-
candidates.push(candidate('availabilityUntil', dateAtMonthEnd(year, monthIndex + 1), match, 'temporal.calendar.month-end', inferred ? .86 : .94, [{ type: 'dictionary', dictionary: 'months', key: match[1] }, { type: 'relation', value: 'until-month-end' }, ...inferredDateEvidence(inferred, context)]));
|
|
155
|
-
}
|
|
156
|
-
for (const match of text.matchAll(START_OF_MONTH_RE)) {
|
|
157
|
-
const reference = referenceDate(context); const date = dateValue(reference.getUTCFullYear(), reference.getUTCMonth() + 1, 1);
|
|
158
|
-
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.month-start', .88, [{ type: 'context', value: 'month-start' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
159
|
-
}
|
|
160
|
-
for (const match of text.matchAll(NEXT_MONTH_RE)) {
|
|
161
|
-
const date = dateAtNextMonthStart(referenceDate(context));
|
|
162
|
-
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), date, match, 'temporal.relative.next-month', .88, [{ type: 'context', value: 'next-month' }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
163
|
-
}
|
|
164
|
-
for (const match of text.matchAll(NEXT_WEEKDAY_RE)) {
|
|
165
|
-
const weekday = DAY_ALIASES[match[1].toLowerCase()]; if (weekday == null) continue;
|
|
166
|
-
candidates.push(candidate(temporalContextType(text, match.index ?? 0, context), nextWeekday(referenceDate(context), weekday), match, 'temporal.relative.next-weekday', .9, [{ type: 'dictionary', dictionary: 'weekdays', key: match[1] }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
167
|
-
}
|
|
168
|
-
for (const match of text.matchAll(RELATIVE_RE)) { const days = relativeDays(match[1]); if (days == null) continue; const type = temporalContextType(text, match.index ?? 0, context); candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'reference-date', value: referenceEntry(context).source }])); }
|
|
169
|
-
for (const match of text.matchAll(EXTENDED_RELATIVE_RE)) {
|
|
170
|
-
const amount = match[1] || match[3] || match[5];
|
|
171
|
-
const unit = match[2] || match[4] || match[6];
|
|
172
|
-
const days = relativeDurationDays(amount, unit);
|
|
173
|
-
if (days == null) continue;
|
|
174
|
-
const type = temporalContextType(text, match.index ?? 0, context);
|
|
175
|
-
candidates.push(candidate(type, addUtcDays(referenceDate(context), days), match, 'temporal.relative-date.extended', .91, [{ type: 'context', value: `relative:${days}d` }, { type: 'unit', value: unit }, { type: 'reference-date', value: referenceEntry(context).source }]));
|
|
176
|
-
}
|
|
177
|
-
for (const match of text.matchAll(new RegExp(DURATION_NUMBER_FIRST_RE, 'giu'))) { const value = durationValue(match[1], match[2], match[3]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.number-unit', .95, [{ type: 'unit', value: match[3] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])])); }
|
|
178
|
-
for (const match of text.matchAll(new RegExp(DURATION_RE, 'giu'))) {
|
|
179
|
-
if (match[3]) continue;
|
|
180
|
-
// Bare genitive "дня" is almost always part of another phrase ("конца
|
|
181
|
-
// дня", "сегодняшнего дня", "через 2 дня") rather than a standalone
|
|
182
|
-
// 1-day duration. Only accept it here when an explicit duration prefix
|
|
183
|
-
// ("на", "минимум", ...) makes the duration reading unambiguous.
|
|
184
|
-
if (!match[1] && /^дня$/iu.test(match[2])) continue;
|
|
185
|
-
const value = durationValue(match[1], match[3], match[2]); candidates.push(candidate(semanticDurationType(text, match.index ?? 0, value), value, match, 'temporal.duration.word-unit', .94, [{ type: 'unit', value: match[2] }, ...(match[1] ? [{ type: 'prefix', value: match[1] }] : [])]));
|
|
186
|
-
}
|
|
187
|
-
const timeRangeSpans = [];
|
|
188
|
-
for (const match of text.matchAll(new RegExp(TIME_RANGE_RE, 'giu'))) {
|
|
189
|
-
const start = parseClock(match[1]); const end = parseClock(match[2]);
|
|
190
|
-
if (!start || !end || !isTimeRangeContextual(match, text)) continue;
|
|
191
|
-
timeRangeSpans.push([match.index ?? 0, (match.index ?? 0) + match[0].length]);
|
|
192
|
-
candidates.push(candidate('timeRange', Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }), match, 'temporal.time-range', .98, [{ type: 'range', value: 'clock-time' }]));
|
|
193
|
-
}
|
|
194
|
-
for (const match of text.matchAll(new RegExp(TIME_RE, 'giu'))) {
|
|
195
|
-
const start = match.index ?? 0; const end = start + match[0].length;
|
|
196
|
-
if (timeRangeSpans.some(([rangeStart, rangeEnd]) => start >= rangeStart && end <= rangeEnd) || !isClockContextual(match, text)) continue;
|
|
197
|
-
const clock = parseClock(match[0]); if (clock) candidates.push(candidate('clockTime', clock, match, 'temporal.clock-time', .94, [{ type: 'clock', value: 'explicit' }]));
|
|
198
|
-
}
|
|
199
|
-
const scheduleContext = SCHEDULE_CONTEXT_RE;
|
|
200
|
-
for (const match of text.matchAll(/(?<![\d:.])(\d{1,2})\s*(?:\/|\\|через|-)\s*(\d{1,2})(?![\d:.])/giu)) {
|
|
201
|
-
const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
|
|
202
|
-
const daysOffMode = scheduleDaysOffMode(around);
|
|
203
|
-
const work = Number(match[1]); const rest = Number(match[2]);
|
|
204
|
-
candidates.push(candidate('workSchedule', cycleScheduleValue(work, rest, daysOffMode), match, 'temporal.schedule-cycle', .99, [{ type: 'context', value: 'schedule' }, { type: 'range', value: Math.max(work, rest) > 7 ? 'cycle-hours' : 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
|
|
205
|
-
}
|
|
206
|
-
for (const match of text.matchAll(/(?<![\p{L}\p{N}])два\s+через\s+два(?![\p{L}\p{N}])/giu)) {
|
|
207
|
-
const around = text.slice(Math.max(0, (match.index ?? 0) - 32), (match.index ?? 0) + match[0].length + 32); if (!scheduleContext.test(around)) continue;
|
|
208
|
-
const daysOffMode = scheduleDaysOffMode(around);
|
|
209
|
-
candidates.push(candidate('workSchedule', Object.freeze({ type: 'cycle', workDays: 2, restDays: 2, daysOffMode }), match, 'temporal.schedule-cycle.words', .97, [{ type: 'context', value: 'schedule' }, { type: 'range', value: 'cycle' }, { type: 'days-off-mode', value: daysOffMode }]));
|
|
210
|
-
}
|
|
211
|
-
for (const match of text.matchAll(WEEKDAY_RANGE_RE)) {
|
|
212
|
-
const start = DAY_ALIASES[match[1].toLowerCase()]; const end = DAY_ALIASES[match[2].toLowerCase()];
|
|
213
|
-
if (start == null || end == null || end < start) continue;
|
|
214
|
-
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(start, end + 1), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays', .97, [{ type: 'range', value: 'weekday' }]));
|
|
215
|
-
}
|
|
216
|
-
for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:по\s+будням|weekdays?)(?![\p{L}\p{N}])/giu)) {
|
|
217
|
-
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(0, 5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekdays.named', .96, [{ type: 'context', value: 'weekdays' }]));
|
|
218
|
-
}
|
|
219
|
-
for (const match of text.matchAll(/(?<![\p{L}\p{N}])(?:только\s+по\s+выходным|weekends?\s+only)(?![\p{L}\p{N}])/giu)) {
|
|
220
|
-
candidates.push(candidate('workSchedule', Object.freeze({ type: 'weekdays', workingDays: WEEKDAYS.slice(5), daysOffMode: 'fixed' }), match, 'temporal.schedule-weekends', .96, [{ type: 'context', value: 'weekends' }]));
|
|
221
|
-
}
|
|
222
|
-
const shifts = [];
|
|
223
|
-
let firstShift = null; let lastShift = null;
|
|
224
|
-
for (const match of text.matchAll(SHIFT_RE)) { const start = parseClock(match[2]); const end = parseClock(match[3]); if (!start || !end) continue; firstShift ||= match; lastShift = match; shifts.push(Object.freeze({ name: `${match[1]} shift`, hours: Object.freeze({ start, end, crossesMidnight: start.hour * 60 + start.minute > end.hour * 60 + end.minute }) })); }
|
|
225
|
-
if (shifts.length) candidates.push(candidate('shifts', Object.freeze(shifts), { 0: text.slice(firstShift.index, (lastShift.index ?? 0) + lastShift[0].length), index: firstShift.index }, 'temporal.multiple-shifts', .98, [{ type: 'context', value: 'shift' }, { type: 'range', value: 'clock-time' }]));
|
|
226
|
-
return Object.freeze(candidates);
|
|
22
|
+
const text = String(value || '');
|
|
23
|
+
return Object.freeze([
|
|
24
|
+
...extractCalendarDateCandidates(text, context),
|
|
25
|
+
...extractRelativeDateCandidates(text, context),
|
|
26
|
+
...extractDurationCandidates(text),
|
|
27
|
+
...extractClockCandidates(text),
|
|
28
|
+
...extractScheduleCandidates(text, parseClock),
|
|
29
|
+
]);
|
|
227
30
|
}
|
|
228
31
|
|
|
229
32
|
// The default resolver only rejects overlapping candidates of the *same*
|