nl2time 0.1.1 → 0.3.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/README.md +35 -2
- package/dist/corpus/runner.d.ts +3 -0
- package/dist/corpus/runner.js +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/packs/index.d.ts +39 -0
- package/dist/packs/index.js +156 -0
- package/dist/parse/de.d.ts +9 -0
- package/dist/parse/de.js +1301 -0
- package/dist/parse/en.d.ts +9 -0
- package/dist/parse/en.js +55 -30
- package/dist/parse/es.d.ts +9 -0
- package/dist/parse/es.js +3286 -0
- package/dist/parse/fr.d.ts +9 -0
- package/dist/parse/fr.js +2358 -0
- package/dist/parse/index.d.ts +16 -1
- package/dist/parse/index.js +42 -11
- package/dist/parse/ja.d.ts +7 -0
- package/dist/parse/ja.js +1207 -0
- package/dist/parse/languages.d.ts +17 -0
- package/dist/parse/languages.js +25 -0
- package/dist/parse/latin.d.ts +97 -0
- package/dist/parse/latin.js +692 -0
- package/dist/parse/tokenizer.js +30 -1
- package/dist/parse/zh.d.ts +6 -0
- package/dist/parse/zh.js +1360 -0
- package/package.json +3 -3
package/dist/parse/de.js
ADDED
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
import { makeLatinRules } from './latin.js';
|
|
2
|
+
const NOW = { op: 'now' };
|
|
3
|
+
function word(t) {
|
|
4
|
+
return t?.type === 'word' ? t.value : undefined;
|
|
5
|
+
}
|
|
6
|
+
const UNITS = {
|
|
7
|
+
sekunde: 'second', sekunden: 'second', sek: 'second',
|
|
8
|
+
minute: 'minute', minuten: 'minute', min: 'minute',
|
|
9
|
+
stunde: 'hour', stunden: 'hour', std: 'hour',
|
|
10
|
+
tag: 'day', tage: 'day', tagen: 'day', tages: 'day', tg: 'day',
|
|
11
|
+
woche: 'week', wochen: 'week',
|
|
12
|
+
monat: 'month', monate: 'month', monaten: 'month', monats: 'month',
|
|
13
|
+
quartal: 'quarter', quartale: 'quarter', quartalen: 'quarter',
|
|
14
|
+
jahr: 'year', jahre: 'year', jahren: 'year', jahres: 'year',
|
|
15
|
+
};
|
|
16
|
+
const WEEKDAYS = {
|
|
17
|
+
montag: 'mon', dienstag: 'tue', mittwoch: 'wed', donnerstag: 'thu',
|
|
18
|
+
freitag: 'fri', samstag: 'sat', sonnabend: 'sat', sonntag: 'sun',
|
|
19
|
+
};
|
|
20
|
+
const MONTHS = {
|
|
21
|
+
januar: 1, februar: 2, märz: 3, maerz: 3, april: 4, mai: 5, juni: 6,
|
|
22
|
+
juli: 7, august: 8, september: 9, oktober: 10, november: 11, dezember: 12,
|
|
23
|
+
jan: 1, feb: 2, mär: 3, apr: 4, jun: 6, jul: 7, aug: 8, sep: 9, sept: 9,
|
|
24
|
+
okt: 10, nov: 11, dez: 12,
|
|
25
|
+
};
|
|
26
|
+
/** Period nouns and adverbs (morgens, abends, …). "morgen" is ALSO the
|
|
27
|
+
* deictic tomorrow; the factory prefers the deictic for bare uses. */
|
|
28
|
+
const PERIODS = {
|
|
29
|
+
morgen: 'morning', vormittag: 'morning', mittag: 'afternoon',
|
|
30
|
+
nachmittag: 'afternoon', abend: 'evening', nacht: 'night',
|
|
31
|
+
morgens: 'morning', vormittags: 'morning', nachmittags: 'afternoon',
|
|
32
|
+
abends: 'evening', nachts: 'night',
|
|
33
|
+
frühmorgens: 'morning', früh: 'morning',
|
|
34
|
+
spätnachmittag: 'afternoon', spätnachmittags: 'afternoon',
|
|
35
|
+
spätabend: 'evening', spätabends: 'evening',
|
|
36
|
+
};
|
|
37
|
+
/** Period words usable as an AM/PM suffix after a clock reading. */
|
|
38
|
+
const PERIOD_MERIDIEM = {
|
|
39
|
+
morgens: 'am', vormittags: 'am', früh: 'am', frühmorgens: 'am',
|
|
40
|
+
mittags: 'pm', nachmittags: 'pm', abends: 'pm', nachts: 'am',
|
|
41
|
+
spätabends: 'pm', spätnachmittags: 'pm',
|
|
42
|
+
morgen: 'am', vormittag: 'am', nachmittag: 'pm', abend: 'pm', nacht: 'am',
|
|
43
|
+
};
|
|
44
|
+
const CARDINALS = {
|
|
45
|
+
ein: 1, eine: 1, einen: 1, einem: 1, einer: 1, eins: 1,
|
|
46
|
+
zwei: 2, beide: 2, beiden: 2, drei: 3, vier: 4, fünf: 5, fuenf: 5,
|
|
47
|
+
sechs: 6, sieben: 7, acht: 8, neun: 9, zehn: 10, elf: 11, zwölf: 12,
|
|
48
|
+
zwoelf: 12, fünfzehn: 15, zwanzig: 20, dreißig: 30, dreissig: 30,
|
|
49
|
+
};
|
|
50
|
+
/** German ordinal words 1–31 in all adjective inflections. */
|
|
51
|
+
const ORDINALS = (() => {
|
|
52
|
+
const stems = [
|
|
53
|
+
[1, 'erst'], [2, 'zweit'], [3, 'dritt'], [4, 'viert'], [5, 'fünft'],
|
|
54
|
+
[5, 'fuenft'], [6, 'sechst'], [7, 'siebt'], [7, 'siebent'], [8, 'acht'],
|
|
55
|
+
[9, 'neunt'], [10, 'zehnt'], [11, 'elft'], [12, 'zwölft'], [12, 'zwoelft'],
|
|
56
|
+
[13, 'dreizehnt'], [14, 'vierzehnt'], [15, 'fünfzehnt'], [15, 'fuenfzehnt'],
|
|
57
|
+
[16, 'sechzehnt'], [17, 'siebzehnt'], [18, 'achtzehnt'], [19, 'neunzehnt'],
|
|
58
|
+
];
|
|
59
|
+
const ones = ['', 'ein', 'zwei', 'drei', 'vier', 'fünf', 'sechs', 'sieben', 'acht', 'neun'];
|
|
60
|
+
for (let n = 20; n <= 31; n += 1) {
|
|
61
|
+
const one = n % 10;
|
|
62
|
+
const prefix = one ? `${ones[one]}und` : '';
|
|
63
|
+
stems.push([n, `${prefix}zwanzigst`.replace('zwanzigst', n < 30 ? 'zwanzigst' : 'dreißigst')]);
|
|
64
|
+
if (n >= 30)
|
|
65
|
+
stems.push([n, `${prefix}dreissigst`]);
|
|
66
|
+
}
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [n, stem] of stems) {
|
|
69
|
+
for (const suffix of ['e', 'en', 'er', 'es', 'em'])
|
|
70
|
+
out[stem + suffix] = n;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
})();
|
|
74
|
+
export const DE_LEXICON = {
|
|
75
|
+
articles: ['der', 'die', 'das', 'den', 'dem', 'des'],
|
|
76
|
+
units: UNITS,
|
|
77
|
+
weekdays: WEEKDAYS,
|
|
78
|
+
months: MONTHS,
|
|
79
|
+
periods: PERIODS,
|
|
80
|
+
smallNumbers: CARDINALS,
|
|
81
|
+
deictic: { heute: 0, gestern: -1, morgen: 1, übermorgen: 2, uebermorgen: 2, vorgestern: -2 },
|
|
82
|
+
preMods: {
|
|
83
|
+
letzte: -1, letzten: -1, letztes: -1, letzter: -1, letztem: -1,
|
|
84
|
+
vergangene: -1, vergangenen: -1,
|
|
85
|
+
nächste: 1, nächsten: 1, nächstes: 1, nächster: 1, nächstem: 1,
|
|
86
|
+
kommende: 1, kommenden: 1,
|
|
87
|
+
diese: 0, diesen: 0, dieses: 0, dieser: 0, diesem: 0,
|
|
88
|
+
},
|
|
89
|
+
postMods: {},
|
|
90
|
+
agoPrefixes: [['vor']],
|
|
91
|
+
inPrefixes: [['in', 'den'], ['in'], ['innerhalb', 'von'], ['innerhalb']],
|
|
92
|
+
lastNAdjs: ['letzten', 'letzte', 'vergangenen'],
|
|
93
|
+
nextNAdjs: ['nächsten', 'nächste', 'kommenden', 'folgenden'],
|
|
94
|
+
// Clock lead-ins are handled entirely in deExtras ("um 15" alone is too
|
|
95
|
+
// eager: "um 15% gefallen").
|
|
96
|
+
atPhrases: [],
|
|
97
|
+
periodMarkers: [['am'], ['an'], ['gegen'], ['in', 'der'], ['auf'], ['zur'], ['zum']],
|
|
98
|
+
dateSep: [],
|
|
99
|
+
weekendPhrases: [['wochenende']],
|
|
100
|
+
specialPhrases: [
|
|
101
|
+
{ words: ['jetzt'], expr: NOW, role: 'datetime' },
|
|
102
|
+
{ words: ['aktuelle', 'uhrzeit'], expr: NOW, role: 'time' },
|
|
103
|
+
],
|
|
104
|
+
noonWords: ['mittag', 'mittags', 'mittagszeit'],
|
|
105
|
+
midnightWords: ['mitternacht', 'mitternachts'],
|
|
106
|
+
};
|
|
107
|
+
function readHourish(tokens, i) {
|
|
108
|
+
const t = tokens[i];
|
|
109
|
+
if (t?.type === 'number' && !t.ordinal && t.value >= 0 && t.value <= 24) {
|
|
110
|
+
return { value: t.value, consumed: 1 };
|
|
111
|
+
}
|
|
112
|
+
const w = word(t);
|
|
113
|
+
if (w !== undefined && CARDINALS[w] !== undefined && CARDINALS[w] <= 24) {
|
|
114
|
+
return { value: CARDINALS[w], consumed: 1 };
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
function applyMeridiem(time, meridiem) {
|
|
119
|
+
const h = time.hour ?? 0;
|
|
120
|
+
if (h > 12)
|
|
121
|
+
return; // 15 Uhr abends — hour already unambiguous
|
|
122
|
+
time.meridiem = meridiem;
|
|
123
|
+
}
|
|
124
|
+
/** Read AM/PM suffix words ("8 Uhr abends"); hour 12 at night → midnight. */
|
|
125
|
+
function readMeridiemSuffix(tokens, i, time) {
|
|
126
|
+
const w = word(tokens[i]);
|
|
127
|
+
if (w === undefined)
|
|
128
|
+
return 0;
|
|
129
|
+
const m = PERIOD_MERIDIEM[w];
|
|
130
|
+
if (m === undefined)
|
|
131
|
+
return 0;
|
|
132
|
+
if (time.hour === 12 && (w.includes('nacht') || w.includes('abend'))) {
|
|
133
|
+
// "12 Uhr nachts/abends" — midnight, expressed as 12am.
|
|
134
|
+
applyMeridiem(time, 'am');
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
applyMeridiem(time, m);
|
|
138
|
+
}
|
|
139
|
+
return 1;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Core clock reading: <clock> ["uhr"] | N "uhr" [["und"] M] |
|
|
143
|
+
* (lead required) N — each with an optional meridiem suffix word.
|
|
144
|
+
*/
|
|
145
|
+
function readClock(tokens, i, hasLead) {
|
|
146
|
+
const t = tokens[i];
|
|
147
|
+
if (t?.type === 'clock') {
|
|
148
|
+
const time = { hour: t.hour };
|
|
149
|
+
if (t.explicitMinute)
|
|
150
|
+
time.minute = t.minute;
|
|
151
|
+
if (t.second !== undefined)
|
|
152
|
+
time.second = t.second;
|
|
153
|
+
if (t.meridiem)
|
|
154
|
+
time.meridiem = t.meridiem;
|
|
155
|
+
else if (t.hour <= 12)
|
|
156
|
+
time.meridiem = 'unknown';
|
|
157
|
+
let consumed = 1;
|
|
158
|
+
if (word(tokens[i + 1]) === 'uhr')
|
|
159
|
+
consumed += 1;
|
|
160
|
+
consumed += readMeridiemSuffix(tokens, i + consumed, time);
|
|
161
|
+
return { time, consumed, anchored: true };
|
|
162
|
+
}
|
|
163
|
+
// Word tokens like "18.12" followed by "uhr" ("um 18.12 Uhr" = 18:12).
|
|
164
|
+
const wt = word(t);
|
|
165
|
+
if (wt !== undefined && word(tokens[i + 1]) === 'uhr') {
|
|
166
|
+
const m = wt.match(/^(\d{1,2})\.(\d{1,2})$/);
|
|
167
|
+
if (m) {
|
|
168
|
+
const hour = Number(m[1]);
|
|
169
|
+
const minute = Number(m[2]);
|
|
170
|
+
if (hour <= 23 && minute <= 59) {
|
|
171
|
+
const time = { hour, minute };
|
|
172
|
+
if (hour <= 12)
|
|
173
|
+
time.meridiem = 'unknown';
|
|
174
|
+
let consumed = 2;
|
|
175
|
+
consumed += readMeridiemSuffix(tokens, i + consumed, time);
|
|
176
|
+
return { time, consumed, anchored: true };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const h = readHourish(tokens, i);
|
|
181
|
+
if (!h)
|
|
182
|
+
return undefined;
|
|
183
|
+
let consumed = h.consumed;
|
|
184
|
+
const time = { hour: h.value };
|
|
185
|
+
if (h.value <= 12)
|
|
186
|
+
time.meridiem = 'unknown';
|
|
187
|
+
let anchored = false;
|
|
188
|
+
if (word(tokens[i + consumed]) === 'uhr') {
|
|
189
|
+
anchored = true;
|
|
190
|
+
consumed += 1;
|
|
191
|
+
// "18 Uhr 12" / "18 Uhr und 12"
|
|
192
|
+
let mAt = i + consumed;
|
|
193
|
+
let extra = 1;
|
|
194
|
+
if (word(tokens[mAt]) === 'und') {
|
|
195
|
+
mAt += 1;
|
|
196
|
+
extra = 2;
|
|
197
|
+
}
|
|
198
|
+
const mt = tokens[mAt];
|
|
199
|
+
if (mt?.type === 'number' && !mt.ordinal && mt.value <= 59 && word(tokens[mAt + 1]) !== 'uhr') {
|
|
200
|
+
time.minute = mt.value;
|
|
201
|
+
consumed += extra;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const suffix = readMeridiemSuffix(tokens, i + consumed, time);
|
|
205
|
+
if (suffix > 0) {
|
|
206
|
+
anchored = true;
|
|
207
|
+
consumed += suffix;
|
|
208
|
+
}
|
|
209
|
+
if (!anchored && !hasLead)
|
|
210
|
+
return undefined;
|
|
211
|
+
return { time, consumed, anchored };
|
|
212
|
+
}
|
|
213
|
+
const LEADS = ['um', 'gegen'];
|
|
214
|
+
/** um/gegen + clock, bare clock tokens, "N Uhr [M]", meridiem suffixes. */
|
|
215
|
+
const deClock = (tokens, i) => {
|
|
216
|
+
let at = i;
|
|
217
|
+
let hasLead = false;
|
|
218
|
+
if (LEADS.includes(word(tokens[at]) ?? '')) {
|
|
219
|
+
hasLead = true;
|
|
220
|
+
at += 1;
|
|
221
|
+
}
|
|
222
|
+
const read = readClock(tokens, at, hasLead);
|
|
223
|
+
if (!read)
|
|
224
|
+
return undefined;
|
|
225
|
+
// A bare number is never licensed here, even after "um": "um 15% gefallen"
|
|
226
|
+
// must not read as 15:00. An anchor (clock token, Uhr, AM/PM word) is
|
|
227
|
+
// required; leads only widen the consumed span.
|
|
228
|
+
if (!read.anchored)
|
|
229
|
+
return undefined;
|
|
230
|
+
return {
|
|
231
|
+
expr: { op: 'literal', time: read.time },
|
|
232
|
+
consumed: at + read.consumed - i,
|
|
233
|
+
confidence: 0.95,
|
|
234
|
+
role: 'time',
|
|
235
|
+
};
|
|
236
|
+
};
|
|
237
|
+
/** "halb acht" = 7:30, "viertel acht" = 7:15, "dreiviertel acht" = 7:45,
|
|
238
|
+
* "viertel nach/vor acht" = 8:15 / 7:45. */
|
|
239
|
+
const deFractionClock = (tokens, i) => {
|
|
240
|
+
let at = i;
|
|
241
|
+
if (LEADS.includes(word(tokens[at]) ?? ''))
|
|
242
|
+
at += 1;
|
|
243
|
+
const w = word(tokens[at]);
|
|
244
|
+
if (w === undefined)
|
|
245
|
+
return undefined;
|
|
246
|
+
let hourAt = at + 1;
|
|
247
|
+
let minute;
|
|
248
|
+
let hourShift = 0; // −1 → minutes before the *coming* hour
|
|
249
|
+
if (w === 'halb') {
|
|
250
|
+
minute = 30;
|
|
251
|
+
hourShift = -1;
|
|
252
|
+
}
|
|
253
|
+
else if (w === 'dreiviertel') {
|
|
254
|
+
minute = 45;
|
|
255
|
+
hourShift = -1;
|
|
256
|
+
}
|
|
257
|
+
else if (w === 'viertel') {
|
|
258
|
+
const nx = word(tokens[at + 1]);
|
|
259
|
+
if (nx === 'nach') {
|
|
260
|
+
minute = 15;
|
|
261
|
+
hourAt = at + 2;
|
|
262
|
+
}
|
|
263
|
+
else if (nx === 'vor') {
|
|
264
|
+
minute = 45;
|
|
265
|
+
hourShift = -1;
|
|
266
|
+
hourAt = at + 2;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
minute = 15;
|
|
270
|
+
hourShift = -1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const h = readHourish(tokens, hourAt);
|
|
277
|
+
if (!h || h.value < 1 || h.value > 24)
|
|
278
|
+
return undefined;
|
|
279
|
+
let hour = h.value + hourShift;
|
|
280
|
+
if (hour === 0)
|
|
281
|
+
hour = 12;
|
|
282
|
+
const time = { hour, minute };
|
|
283
|
+
if (hour <= 12)
|
|
284
|
+
time.meridiem = 'unknown';
|
|
285
|
+
let consumed = hourAt + h.consumed - i;
|
|
286
|
+
consumed += readMeridiemSuffix(tokens, i + consumed, time);
|
|
287
|
+
return { expr: { op: 'literal', time }, consumed, confidence: 0.95, role: 'time' };
|
|
288
|
+
};
|
|
289
|
+
/** DMY numeric dates: "22.01.2016", "29/2", "27.02.2022". */
|
|
290
|
+
function numericDMY(parts) {
|
|
291
|
+
const [a, b, c] = [parts[0], parts[1], parts[2]];
|
|
292
|
+
if (b === undefined)
|
|
293
|
+
return undefined;
|
|
294
|
+
const valid = (d) => d.month >= 1 && d.month <= 12 && d.day >= 1 && d.day <= 31 ? d : undefined;
|
|
295
|
+
const year = c === undefined ? undefined : c < 100 ? 2000 + c : c;
|
|
296
|
+
const mk = (day, month) => {
|
|
297
|
+
const d = { month, day };
|
|
298
|
+
if (year !== undefined)
|
|
299
|
+
d.year = year;
|
|
300
|
+
return d;
|
|
301
|
+
};
|
|
302
|
+
if (a >= 1000)
|
|
303
|
+
return undefined;
|
|
304
|
+
return valid(mk(a, b)) ?? valid(mk(b, a));
|
|
305
|
+
}
|
|
306
|
+
const deNumDate = (tokens, i) => {
|
|
307
|
+
const t = tokens[i];
|
|
308
|
+
if (t?.type !== 'numdate')
|
|
309
|
+
return undefined;
|
|
310
|
+
const date = numericDMY(t.parts);
|
|
311
|
+
if (!date)
|
|
312
|
+
return undefined;
|
|
313
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.9, role: 'date' };
|
|
314
|
+
};
|
|
315
|
+
/** Single word tokens "22.04" (day.month) and "28.oktober" (day.Month). */
|
|
316
|
+
const deWordDate = (tokens, i) => {
|
|
317
|
+
const w = word(tokens[i]);
|
|
318
|
+
if (w === undefined)
|
|
319
|
+
return undefined;
|
|
320
|
+
// "18.12 Uhr" is a time; deClock owns that shape.
|
|
321
|
+
if (word(tokens[i + 1]) === 'uhr')
|
|
322
|
+
return undefined;
|
|
323
|
+
let m = w.match(/^(\d{1,2})\.(\d{1,2})$/);
|
|
324
|
+
if (m) {
|
|
325
|
+
const date = numericDMY([Number(m[1]), Number(m[2])]);
|
|
326
|
+
if (!date)
|
|
327
|
+
return undefined;
|
|
328
|
+
return { expr: { op: 'literal', date }, consumed: 1, confidence: 0.85, role: 'date' };
|
|
329
|
+
}
|
|
330
|
+
m = w.match(/^(\d{1,2})\.([a-zäöüß]+)$/);
|
|
331
|
+
if (m) {
|
|
332
|
+
const month = MONTHS[m[2]];
|
|
333
|
+
const day = Number(m[1]);
|
|
334
|
+
if (month === undefined || day < 1 || day > 31)
|
|
335
|
+
return undefined;
|
|
336
|
+
return { expr: { op: 'literal', date: { month, day } }, consumed: 1, confidence: 0.95, role: 'date' };
|
|
337
|
+
}
|
|
338
|
+
return undefined;
|
|
339
|
+
};
|
|
340
|
+
function readDayish(tokens, i) {
|
|
341
|
+
const t = tokens[i];
|
|
342
|
+
if (t?.type === 'number' && t.value >= 1 && t.value <= 31)
|
|
343
|
+
return { value: t.value, consumed: 1 };
|
|
344
|
+
const w = word(t);
|
|
345
|
+
if (w !== undefined && ORDINALS[w] !== undefined)
|
|
346
|
+
return { value: ORDINALS[w], consumed: 1 };
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
function readMonthish(tokens, i) {
|
|
350
|
+
const w = word(tokens[i]);
|
|
351
|
+
if (w !== undefined) {
|
|
352
|
+
if (MONTHS[w] !== undefined)
|
|
353
|
+
return { value: MONTHS[w], consumed: 1, named: true };
|
|
354
|
+
if (ORDINALS[w] !== undefined && ORDINALS[w] <= 12) {
|
|
355
|
+
return { value: ORDINALS[w], consumed: 1, named: true };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const t = tokens[i];
|
|
359
|
+
if (t?.type === 'number' && t.value >= 1 && t.value <= 12) {
|
|
360
|
+
return { value: t.value, consumed: 1, named: false };
|
|
361
|
+
}
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Day + month in German shapes the factory doesn't know: ordinal words
|
|
366
|
+
* ("neunundzwanzigsten Mai", "siebten vierten"), ordinal + numeric month
|
|
367
|
+
* ("siebter 4."), numeric + ordinal ("7. vierter"), "27. 11.".
|
|
368
|
+
*/
|
|
369
|
+
const deDayMonth = (tokens, i) => {
|
|
370
|
+
const d = readDayish(tokens, i);
|
|
371
|
+
if (!d)
|
|
372
|
+
return undefined;
|
|
373
|
+
const mAt = i + d.consumed;
|
|
374
|
+
const m = readMonthish(tokens, mAt);
|
|
375
|
+
if (!m)
|
|
376
|
+
return undefined;
|
|
377
|
+
const dayNamed = tokens[i]?.type === 'word';
|
|
378
|
+
if (!dayNamed && !m.named) {
|
|
379
|
+
// Two plain numbers: only read as a date if nothing time/unit-like follows
|
|
380
|
+
// ("3 5 Jahre" from "3,5 Jahre" must not become May 3).
|
|
381
|
+
const after = word(tokens[mAt + m.consumed]);
|
|
382
|
+
if (after !== undefined && (UNITS[after] !== undefined || after === 'uhr' || CARDINALS[after] !== undefined)) {
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const date = { month: m.value, day: d.value };
|
|
387
|
+
let consumed = d.consumed + m.consumed;
|
|
388
|
+
const yt = tokens[mAt + m.consumed];
|
|
389
|
+
if (yt?.type === 'number' && !yt.ordinal && yt.value >= 1000 && yt.value <= 9999) {
|
|
390
|
+
date.year = yt.value;
|
|
391
|
+
consumed += 1;
|
|
392
|
+
}
|
|
393
|
+
return {
|
|
394
|
+
expr: { op: 'literal', date },
|
|
395
|
+
consumed,
|
|
396
|
+
confidence: dayNamed || m.named ? 0.95 : 0.7,
|
|
397
|
+
role: 'date',
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
/** "übernächste Woche" (+2), "vorletztes Jahr" (−2). */
|
|
401
|
+
const deTwoStep = (tokens, i) => {
|
|
402
|
+
let at = i;
|
|
403
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
404
|
+
at += 1;
|
|
405
|
+
const w = word(tokens[at]);
|
|
406
|
+
if (w === undefined)
|
|
407
|
+
return undefined;
|
|
408
|
+
let step;
|
|
409
|
+
if (/^übernächste[nrms]?$/.test(w) || /^uebernaechste[nrms]?$/.test(w))
|
|
410
|
+
step = 2;
|
|
411
|
+
else if (/^vorletzte[nrms]?$/.test(w) || /^vorvergangene[nrms]?$/.test(w))
|
|
412
|
+
step = -2;
|
|
413
|
+
if (step === undefined)
|
|
414
|
+
return undefined;
|
|
415
|
+
let nounAt = at + 1;
|
|
416
|
+
while (DE_LEXICON.articles.includes(word(tokens[nounAt]) ?? ''))
|
|
417
|
+
nounAt += 1;
|
|
418
|
+
const noun = word(tokens[nounAt]);
|
|
419
|
+
if (noun === undefined)
|
|
420
|
+
return undefined;
|
|
421
|
+
const unit = UNITS[noun];
|
|
422
|
+
if (unit !== undefined) {
|
|
423
|
+
return {
|
|
424
|
+
expr: { op: 'snap', base: { op: 'offset', base: NOW, amount: step, unit }, unit },
|
|
425
|
+
consumed: nounAt + 1 - i,
|
|
426
|
+
confidence: 1,
|
|
427
|
+
role: 'date',
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
const weekday = WEEKDAYS[noun];
|
|
431
|
+
if (weekday !== undefined && step === 2) {
|
|
432
|
+
return {
|
|
433
|
+
expr: { op: 'seek', base: NOW, dir: 'next', target: { kind: 'weekday', weekday }, n: 2 },
|
|
434
|
+
consumed: nounAt + 1 - i,
|
|
435
|
+
confidence: 1,
|
|
436
|
+
role: 'date',
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
return undefined;
|
|
440
|
+
};
|
|
441
|
+
/** "Dienstag Morgen", "Montagnachmittag", "sonntag spätabend". */
|
|
442
|
+
const deWeekdayPeriod = (tokens, i) => {
|
|
443
|
+
const w = word(tokens[i]);
|
|
444
|
+
if (w === undefined)
|
|
445
|
+
return undefined;
|
|
446
|
+
const build = (weekday, period, consumed) => ({
|
|
447
|
+
expr: {
|
|
448
|
+
op: 'intersect',
|
|
449
|
+
parts: [
|
|
450
|
+
{ op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday } },
|
|
451
|
+
{ op: 'literal', dayPeriod: period },
|
|
452
|
+
],
|
|
453
|
+
},
|
|
454
|
+
consumed,
|
|
455
|
+
confidence: 0.95,
|
|
456
|
+
role: 'datetime',
|
|
457
|
+
});
|
|
458
|
+
const wd = WEEKDAYS[w];
|
|
459
|
+
if (wd !== undefined) {
|
|
460
|
+
const p = word(tokens[i + 1]);
|
|
461
|
+
const period = p !== undefined ? PERIODS[p] : undefined;
|
|
462
|
+
if (period !== undefined)
|
|
463
|
+
return build(wd, period, 2);
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
// Single-token compound: weekday name + period suffix.
|
|
467
|
+
for (const [name, weekday] of Object.entries(WEEKDAYS)) {
|
|
468
|
+
if (w.startsWith(name)) {
|
|
469
|
+
const rest = w.slice(name.length).replace(/^s/, '');
|
|
470
|
+
const period = PERIODS[rest] ?? (rest === '' ? undefined : undefined);
|
|
471
|
+
if (period !== undefined)
|
|
472
|
+
return build(weekday, period, 1);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return undefined;
|
|
476
|
+
};
|
|
477
|
+
/** Bare period adverbs/nouns without an article ("morgens", "nachmittag"). */
|
|
478
|
+
const deBarePeriod = (tokens, i) => {
|
|
479
|
+
const w = word(tokens[i]);
|
|
480
|
+
if (w === undefined)
|
|
481
|
+
return undefined;
|
|
482
|
+
if (DE_LEXICON.deictic[w] !== undefined)
|
|
483
|
+
return undefined; // "morgen" = tomorrow
|
|
484
|
+
const period = PERIODS[w];
|
|
485
|
+
if (period === undefined)
|
|
486
|
+
return undefined;
|
|
487
|
+
return { expr: { op: 'literal', dayPeriod: period }, consumed: 1, confidence: 0.55, role: 'time' };
|
|
488
|
+
};
|
|
489
|
+
/** "[im/aus dem/für das] Jahr 2009" → that calendar year. */
|
|
490
|
+
const deYearNoun = (tokens, i) => {
|
|
491
|
+
let at = i;
|
|
492
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
493
|
+
at += 1;
|
|
494
|
+
const w = word(tokens[at]);
|
|
495
|
+
if (w !== 'jahr' && w !== 'jahre' && w !== 'jahres')
|
|
496
|
+
return undefined;
|
|
497
|
+
const yt = tokens[at + 1];
|
|
498
|
+
if (yt?.type !== 'number' || yt.ordinal || yt.value < 1000 || yt.value > 9999)
|
|
499
|
+
return undefined;
|
|
500
|
+
return {
|
|
501
|
+
expr: { op: 'literal', date: { year: yt.value } },
|
|
502
|
+
consumed: at + 2 - i,
|
|
503
|
+
confidence: 0.95,
|
|
504
|
+
role: 'date',
|
|
505
|
+
};
|
|
506
|
+
};
|
|
507
|
+
/** Durations: "3 Stunden", "eine Viertelstunde", "anderthalb Stunden". */
|
|
508
|
+
const deDuration = (tokens, i) => {
|
|
509
|
+
const w0 = word(tokens[i]);
|
|
510
|
+
const special = (name) => name === 'viertelstunde' ? 15 : name === 'dreiviertelstunde' ? 45 : name === 'halbstunde' ? 30 : undefined;
|
|
511
|
+
// [ein/eine] Viertelstunde / Dreiviertelstunde
|
|
512
|
+
if (w0 !== undefined) {
|
|
513
|
+
const minutes = special(w0);
|
|
514
|
+
if (minutes !== undefined) {
|
|
515
|
+
return { expr: { op: 'duration', iso: `PT${minutes}M` }, consumed: 1, confidence: 0.9, role: 'duration' };
|
|
516
|
+
}
|
|
517
|
+
// "eine halbe Stunde"
|
|
518
|
+
if ((w0 === 'halbe' || w0 === 'halben') && UNITS[word(tokens[i + 1]) ?? ''] === 'hour') {
|
|
519
|
+
return { expr: { op: 'duration', iso: 'PT30M' }, consumed: 2, confidence: 0.9, role: 'duration' };
|
|
520
|
+
}
|
|
521
|
+
// anderthalb / zweieinhalb / fünfeinhalb Stunden
|
|
522
|
+
const halfMatch = w0 === 'anderthalb' ? 1 : /^([a-zäöü]+)einhalb$/.exec(w0)?.[1];
|
|
523
|
+
if (halfMatch !== undefined) {
|
|
524
|
+
const base = halfMatch === 1 ? 1 : CARDINALS[halfMatch];
|
|
525
|
+
if (base !== undefined && UNITS[word(tokens[i + 1]) ?? ''] === 'hour') {
|
|
526
|
+
const minutes = base * 60 + 30;
|
|
527
|
+
// "Spule anderthalb Stunden vor" — the elapsed stretch ending now.
|
|
528
|
+
if (word(tokens[i + 2]) === 'vor') {
|
|
529
|
+
return {
|
|
530
|
+
expr: { op: 'span', anchor: NOW, amount: { minutes: -minutes } },
|
|
531
|
+
consumed: 3,
|
|
532
|
+
confidence: 0.9,
|
|
533
|
+
role: 'datetime',
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
return {
|
|
537
|
+
expr: { op: 'duration', iso: `PT${minutes}M` },
|
|
538
|
+
consumed: 2,
|
|
539
|
+
confidence: 0.9,
|
|
540
|
+
role: 'duration',
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// "den ganzen Tag" — a whole unit as a duration.
|
|
545
|
+
if ((w0 === 'ganzen' || w0 === 'ganze') && UNITS[word(tokens[i + 1]) ?? ''] !== undefined) {
|
|
546
|
+
const unit = UNITS[word(tokens[i + 1]) ?? ''];
|
|
547
|
+
const amount = unit === 'quarter' ? { months: 3 } : { [`${unit}s`]: 1 };
|
|
548
|
+
return { expr: { op: 'amount', amount }, consumed: 2, confidence: 0.9, role: 'duration' };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// N + unit ("3 Stunden", "drei Minuten")
|
|
552
|
+
const t = tokens[i];
|
|
553
|
+
let n;
|
|
554
|
+
if (t?.type === 'number' && !t.ordinal)
|
|
555
|
+
n = t.value;
|
|
556
|
+
else if (w0 !== undefined && CARDINALS[w0] !== undefined)
|
|
557
|
+
n = CARDINALS[w0];
|
|
558
|
+
if (n === undefined)
|
|
559
|
+
return undefined;
|
|
560
|
+
const unit = UNITS[word(tokens[i + 1]) ?? ''];
|
|
561
|
+
if (unit === undefined)
|
|
562
|
+
return undefined;
|
|
563
|
+
const amount = unit === 'quarter' ? { months: 3 * n } : { [`${unit}s`]: n };
|
|
564
|
+
return { expr: { op: 'amount', amount }, consumed: 2, confidence: 0.7, role: 'duration' };
|
|
565
|
+
};
|
|
566
|
+
function readEndpoint(tokens, i) {
|
|
567
|
+
let at = i;
|
|
568
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
569
|
+
at += 1;
|
|
570
|
+
// Optional weekday prefix ("von Montag 7 Uhr …").
|
|
571
|
+
let weekday;
|
|
572
|
+
const w = word(tokens[at]);
|
|
573
|
+
if (w !== undefined && WEEKDAYS[w] !== undefined) {
|
|
574
|
+
weekday = WEEKDAYS[w];
|
|
575
|
+
at += 1;
|
|
576
|
+
}
|
|
577
|
+
// Date shapes: numdate token, "dd.mm" word, day + Month [year].
|
|
578
|
+
const t = tokens[at];
|
|
579
|
+
if (t?.type === 'numdate') {
|
|
580
|
+
const date = numericDMY(t.parts);
|
|
581
|
+
if (date) {
|
|
582
|
+
return {
|
|
583
|
+
kind: 'date', expr: { op: 'literal', date }, date,
|
|
584
|
+
consumed: at + 1 - i, anchored: true,
|
|
585
|
+
...(weekday !== undefined ? { weekday } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const wt = word(t);
|
|
590
|
+
if (wt !== undefined && !weekday) {
|
|
591
|
+
const m = wt.match(/^(\d{1,2})\.(\d{1,2})$/);
|
|
592
|
+
if (m) {
|
|
593
|
+
const date = numericDMY([Number(m[1]), Number(m[2])]);
|
|
594
|
+
if (date) {
|
|
595
|
+
return { kind: 'date', expr: { op: 'literal', date }, date, consumed: at + 1 - i, anchored: true };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
const d = readDayish(tokens, at);
|
|
600
|
+
if (d !== undefined && !weekday) {
|
|
601
|
+
const m = readMonthish(tokens, at + d.consumed);
|
|
602
|
+
if (m?.named) {
|
|
603
|
+
const date = { month: m.value, day: d.value };
|
|
604
|
+
let consumed = at + d.consumed + m.consumed - i;
|
|
605
|
+
const yt = tokens[at + d.consumed + m.consumed];
|
|
606
|
+
if (yt?.type === 'number' && !yt.ordinal && yt.value >= 1000 && yt.value <= 9999) {
|
|
607
|
+
date.year = yt.value;
|
|
608
|
+
consumed += 1;
|
|
609
|
+
}
|
|
610
|
+
return { kind: 'date', expr: { op: 'literal', date }, date, consumed, anchored: true };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Time shape (also absorbs bare numbers — classification happens later).
|
|
614
|
+
const time = readClock(tokens, at, true);
|
|
615
|
+
if (time) {
|
|
616
|
+
return {
|
|
617
|
+
kind: 'time',
|
|
618
|
+
expr: { op: 'literal', time: time.time },
|
|
619
|
+
time: time.time,
|
|
620
|
+
consumed: at + time.consumed - i,
|
|
621
|
+
anchored: time.anchored,
|
|
622
|
+
...(weekday !== undefined ? { weekday } : {}),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
if (d !== undefined && !weekday) {
|
|
626
|
+
// Bare day number ("vom 4. bis zum 23.").
|
|
627
|
+
return {
|
|
628
|
+
kind: 'date',
|
|
629
|
+
expr: { op: 'literal', date: { day: d.value } },
|
|
630
|
+
date: { day: d.value },
|
|
631
|
+
consumed: at + d.consumed - i,
|
|
632
|
+
anchored: false,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
function endpointExpr(e) {
|
|
638
|
+
if (e.weekday === undefined)
|
|
639
|
+
return e.expr;
|
|
640
|
+
const seek = {
|
|
641
|
+
op: 'seek', base: NOW, dir: 'nearest', target: { kind: 'weekday', weekday: e.weekday },
|
|
642
|
+
};
|
|
643
|
+
if (e.kind === 'time')
|
|
644
|
+
return { op: 'intersect', parts: [seek, e.expr] };
|
|
645
|
+
return seek;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* "von X bis Y" / "zwischen X und Y" ranges — the factory has no range
|
|
649
|
+
* grammar, so German builds `between` here for the common date & time shapes.
|
|
650
|
+
*/
|
|
651
|
+
const deRange = (tokens, i) => {
|
|
652
|
+
const lead = word(tokens[i]);
|
|
653
|
+
if (lead !== 'von' && lead !== 'vom' && lead !== 'zwischen')
|
|
654
|
+
return undefined;
|
|
655
|
+
const a = readEndpoint(tokens, i + 1);
|
|
656
|
+
if (!a)
|
|
657
|
+
return undefined;
|
|
658
|
+
let mid = i + 1 + a.consumed;
|
|
659
|
+
const midWord = word(tokens[mid]);
|
|
660
|
+
if (midWord !== 'bis' && midWord !== 'und')
|
|
661
|
+
return undefined;
|
|
662
|
+
mid += 1;
|
|
663
|
+
while (['zum', 'zur', 'zu', 'dem', 'den', 'der'].includes(word(tokens[mid]) ?? ''))
|
|
664
|
+
mid += 1;
|
|
665
|
+
const b = readEndpoint(tokens, mid);
|
|
666
|
+
if (!b)
|
|
667
|
+
return undefined;
|
|
668
|
+
const consumed = mid + b.consumed - i;
|
|
669
|
+
// Classify: any anchored-time endpoint or weekday+time → time range;
|
|
670
|
+
// month/named date → date range; "vom"/"zwischen dem" default to dates,
|
|
671
|
+
// plain "von" defaults to times.
|
|
672
|
+
const timeish = (a.kind === 'time' && a.anchored) || (b.kind === 'time' && b.anchored) ||
|
|
673
|
+
a.weekday !== undefined || b.weekday !== undefined;
|
|
674
|
+
const dateish = (a.kind === 'date' && (a.anchored || a.date?.month !== undefined)) ||
|
|
675
|
+
(b.kind === 'date' && (b.anchored || b.date?.month !== undefined));
|
|
676
|
+
if (!timeish && (dateish || lead === 'vom' || (lead === 'zwischen' && a.kind === 'date'))) {
|
|
677
|
+
// Date range; borrow month/year from the other endpoint when missing.
|
|
678
|
+
const asDate = (e) => {
|
|
679
|
+
if (e.date)
|
|
680
|
+
return e.date;
|
|
681
|
+
if (e.kind === 'time' && e.time?.hour !== undefined && e.time.minute === undefined) {
|
|
682
|
+
return { day: e.time.hour };
|
|
683
|
+
}
|
|
684
|
+
return undefined;
|
|
685
|
+
};
|
|
686
|
+
const da = asDate(a);
|
|
687
|
+
const db = asDate(b);
|
|
688
|
+
if (!da?.day || !db?.day)
|
|
689
|
+
return undefined;
|
|
690
|
+
if (da.month === undefined && db.month !== undefined)
|
|
691
|
+
da.month = db.month;
|
|
692
|
+
if (da.year === undefined && db.year !== undefined && da.month === db.month)
|
|
693
|
+
da.year = db.year;
|
|
694
|
+
const clean = (d) => {
|
|
695
|
+
const out = { day: d.day };
|
|
696
|
+
if (d.month !== undefined)
|
|
697
|
+
out.month = d.month;
|
|
698
|
+
if (d.year !== undefined)
|
|
699
|
+
out.year = d.year;
|
|
700
|
+
return out;
|
|
701
|
+
};
|
|
702
|
+
return {
|
|
703
|
+
expr: {
|
|
704
|
+
op: 'between',
|
|
705
|
+
start: { op: 'literal', date: clean(da) },
|
|
706
|
+
end: { op: 'literal', date: clean(db) },
|
|
707
|
+
},
|
|
708
|
+
consumed,
|
|
709
|
+
confidence: 0.9,
|
|
710
|
+
role: 'date',
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
if (a.kind === 'time' && b.kind === 'time' && a.time && b.time) {
|
|
714
|
+
// Propagate a trailing meridiem backwards ("von 5 bis 6 Uhr Nachmittags").
|
|
715
|
+
if (b.time.meridiem !== undefined && b.time.meridiem !== 'unknown' && a.time.meridiem === 'unknown') {
|
|
716
|
+
a.time.meridiem = b.time.meridiem;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
expr: { op: 'between', start: endpointExpr(a), end: endpointExpr(b) },
|
|
720
|
+
consumed,
|
|
721
|
+
confidence: 0.9,
|
|
722
|
+
role: a.weekday !== undefined ? 'datetime' : 'time',
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
return undefined;
|
|
726
|
+
};
|
|
727
|
+
/**
|
|
728
|
+
* Dash-joined date component sequences from the tokenizer's split
|
|
729
|
+
* ("Sep-23-2020", "23-2020-September"): a month name, a day, and optionally a
|
|
730
|
+
* year in any order, joined by '-' word tokens.
|
|
731
|
+
*/
|
|
732
|
+
const deDashDate = (tokens, i) => {
|
|
733
|
+
let month;
|
|
734
|
+
let day;
|
|
735
|
+
let year;
|
|
736
|
+
let at = i;
|
|
737
|
+
let parts = 0;
|
|
738
|
+
while (parts < 3) {
|
|
739
|
+
const t = tokens[at];
|
|
740
|
+
const w = word(t);
|
|
741
|
+
if (w !== undefined && MONTHS[w] !== undefined && month === undefined) {
|
|
742
|
+
month = MONTHS[w];
|
|
743
|
+
}
|
|
744
|
+
else if (t?.type === 'number' && !t.ordinal && t.value >= 1000 && t.value <= 9999 && year === undefined) {
|
|
745
|
+
year = t.value;
|
|
746
|
+
}
|
|
747
|
+
else if (t?.type === 'number' && !t.ordinal && t.value >= 1 && t.value <= 31 && day === undefined) {
|
|
748
|
+
day = t.value;
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
parts += 1;
|
|
754
|
+
at += 1;
|
|
755
|
+
if (word(tokens[at]) === '-')
|
|
756
|
+
at += 1;
|
|
757
|
+
else
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
if (month === undefined || day === undefined || parts < 2)
|
|
761
|
+
return undefined;
|
|
762
|
+
// Require at least one '-' join so plain "23 September" stays with the
|
|
763
|
+
// factory month-day rule (which also reads bare numbers after).
|
|
764
|
+
if (at - i === parts)
|
|
765
|
+
return undefined;
|
|
766
|
+
const date = { month, day };
|
|
767
|
+
if (year !== undefined)
|
|
768
|
+
date.year = year;
|
|
769
|
+
return { expr: { op: 'literal', date }, consumed: at - i, confidence: 0.9, role: 'date' };
|
|
770
|
+
};
|
|
771
|
+
/** "November 19-20" — month + joined numeric day range token. */
|
|
772
|
+
const deMonthDayRange = (tokens, i) => {
|
|
773
|
+
const month = MONTHS[word(tokens[i]) ?? ''];
|
|
774
|
+
if (month === undefined)
|
|
775
|
+
return undefined;
|
|
776
|
+
const w = word(tokens[i + 1]);
|
|
777
|
+
const m = w?.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
778
|
+
if (!m)
|
|
779
|
+
return undefined;
|
|
780
|
+
const d1 = Number(m[1]);
|
|
781
|
+
const d2 = Number(m[2]);
|
|
782
|
+
if (d1 < 1 || d1 > 31 || d2 < 1 || d2 > 31)
|
|
783
|
+
return undefined;
|
|
784
|
+
return {
|
|
785
|
+
expr: {
|
|
786
|
+
op: 'between',
|
|
787
|
+
start: { op: 'literal', date: { month, day: d1 } },
|
|
788
|
+
end: { op: 'literal', date: { month, day: d2 } },
|
|
789
|
+
},
|
|
790
|
+
consumed: 2,
|
|
791
|
+
confidence: 0.9,
|
|
792
|
+
role: 'date',
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
/** Slash-joined single tokens: "23/Sep/2020", "2020/Sep/23", "dritter/11",
|
|
796
|
+
* and slash-date ranges "28/2-1/3", "10/1-11/2/2017". */
|
|
797
|
+
const deSlashDate = (tokens, i) => {
|
|
798
|
+
const w = word(tokens[i]);
|
|
799
|
+
if (w === undefined || !w.includes('/'))
|
|
800
|
+
return undefined;
|
|
801
|
+
// "28/2-1/3" / "10/1-11/2/2017" — DMY on both sides.
|
|
802
|
+
let m = w.match(/^(\d{1,2})\/(\d{1,2})-(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
|
803
|
+
if (m) {
|
|
804
|
+
const year = m[5] !== undefined ? (Number(m[5]) < 100 ? 2000 + Number(m[5]) : Number(m[5])) : undefined;
|
|
805
|
+
const start = numericDMY(year !== undefined ? [Number(m[1]), Number(m[2]), year] : [Number(m[1]), Number(m[2])]);
|
|
806
|
+
const end = numericDMY(year !== undefined ? [Number(m[3]), Number(m[4]), year] : [Number(m[3]), Number(m[4])]);
|
|
807
|
+
if (start && end) {
|
|
808
|
+
return {
|
|
809
|
+
expr: {
|
|
810
|
+
op: 'between',
|
|
811
|
+
start: { op: 'literal', date: start },
|
|
812
|
+
end: { op: 'literal', date: end },
|
|
813
|
+
},
|
|
814
|
+
consumed: 1,
|
|
815
|
+
confidence: 0.9,
|
|
816
|
+
role: 'date',
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// "dritter/11" — ordinal day / numeric month.
|
|
821
|
+
m = w.match(/^([a-zäöüß]+)\/(\d{1,2})$/);
|
|
822
|
+
if (m && ORDINALS[m[1]] !== undefined) {
|
|
823
|
+
const day = ORDINALS[m[1]];
|
|
824
|
+
const month = Number(m[2]);
|
|
825
|
+
if (month >= 1 && month <= 12) {
|
|
826
|
+
return { expr: { op: 'literal', date: { month, day } }, consumed: 1, confidence: 0.9, role: 'date' };
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
// Three mixed segments with a month name: "2020/23/Sep", "23/Sep/2020".
|
|
830
|
+
const segs = w.split('/');
|
|
831
|
+
if (segs.length === 3) {
|
|
832
|
+
let month;
|
|
833
|
+
let day;
|
|
834
|
+
let year;
|
|
835
|
+
for (const seg of segs) {
|
|
836
|
+
if (MONTHS[seg] !== undefined && month === undefined)
|
|
837
|
+
month = MONTHS[seg];
|
|
838
|
+
else if (/^\d{4}$/.test(seg) && year === undefined)
|
|
839
|
+
year = Number(seg);
|
|
840
|
+
else if (/^\d{1,2}$/.test(seg) && day === undefined && Number(seg) >= 1 && Number(seg) <= 31)
|
|
841
|
+
day = Number(seg);
|
|
842
|
+
else
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
if (month !== undefined && day !== undefined && year !== undefined) {
|
|
846
|
+
return {
|
|
847
|
+
expr: { op: 'literal', date: { month, day, year } },
|
|
848
|
+
consumed: 1,
|
|
849
|
+
confidence: 0.9,
|
|
850
|
+
role: 'date',
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return undefined;
|
|
855
|
+
};
|
|
856
|
+
/** "nächsten Donnerstag bis Samstag" → between(seek, seek). */
|
|
857
|
+
const deWeekdayRange = (tokens, i) => {
|
|
858
|
+
const readWd = (at) => {
|
|
859
|
+
let cursor = at;
|
|
860
|
+
while (DE_LEXICON.articles.includes(word(tokens[cursor]) ?? ''))
|
|
861
|
+
cursor += 1;
|
|
862
|
+
let dir = 'nearest';
|
|
863
|
+
const pm = word(tokens[cursor]);
|
|
864
|
+
if (pm !== undefined && DE_LEXICON.preMods[pm] !== undefined) {
|
|
865
|
+
const delta = DE_LEXICON.preMods[pm];
|
|
866
|
+
dir = delta === 1 ? 'next' : delta === -1 ? 'prev' : 'nearest';
|
|
867
|
+
cursor += 1;
|
|
868
|
+
}
|
|
869
|
+
const wd = WEEKDAYS[word(tokens[cursor]) ?? ''];
|
|
870
|
+
if (wd === undefined)
|
|
871
|
+
return undefined;
|
|
872
|
+
return { dir, weekday: wd, end: cursor + 1 };
|
|
873
|
+
};
|
|
874
|
+
const a = readWd(i);
|
|
875
|
+
if (!a)
|
|
876
|
+
return undefined;
|
|
877
|
+
if (word(tokens[a.end]) !== 'bis')
|
|
878
|
+
return undefined;
|
|
879
|
+
const b = readWd(a.end + 1);
|
|
880
|
+
if (!b)
|
|
881
|
+
return undefined;
|
|
882
|
+
return {
|
|
883
|
+
expr: {
|
|
884
|
+
op: 'between',
|
|
885
|
+
start: { op: 'seek', base: NOW, dir: a.dir, target: { kind: 'weekday', weekday: a.weekday } },
|
|
886
|
+
end: { op: 'seek', base: NOW, dir: 'next', target: { kind: 'weekday', weekday: b.weekday } },
|
|
887
|
+
},
|
|
888
|
+
consumed: b.end - i,
|
|
889
|
+
confidence: 0.95,
|
|
890
|
+
role: 'date',
|
|
891
|
+
};
|
|
892
|
+
};
|
|
893
|
+
/** "unter der Woche" / "nächste Arbeitswoche" / "letzte werktags" → Mon–Sat. */
|
|
894
|
+
const deWorkweek = (tokens, i) => {
|
|
895
|
+
let at = i;
|
|
896
|
+
let delta = 0;
|
|
897
|
+
if (word(tokens[at]) === 'unter') {
|
|
898
|
+
at += 1;
|
|
899
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
900
|
+
at += 1;
|
|
901
|
+
if (word(tokens[at]) !== 'woche')
|
|
902
|
+
return undefined;
|
|
903
|
+
at += 1;
|
|
904
|
+
}
|
|
905
|
+
else {
|
|
906
|
+
const pm = word(tokens[at]);
|
|
907
|
+
if (pm !== undefined && DE_LEXICON.preMods[pm] !== undefined) {
|
|
908
|
+
delta = DE_LEXICON.preMods[pm];
|
|
909
|
+
at += 1;
|
|
910
|
+
}
|
|
911
|
+
const noun = word(tokens[at]);
|
|
912
|
+
if (noun !== 'arbeitswoche' && noun !== 'werktags' && noun !== 'werktage' && noun !== 'werktagen') {
|
|
913
|
+
return undefined;
|
|
914
|
+
}
|
|
915
|
+
at += 1;
|
|
916
|
+
}
|
|
917
|
+
const week = delta === 0
|
|
918
|
+
? { op: 'snap', base: NOW, unit: 'week' }
|
|
919
|
+
: { op: 'snap', base: { op: 'offset', base: NOW, amount: delta, unit: 'week' }, unit: 'week' };
|
|
920
|
+
return {
|
|
921
|
+
expr: { op: 'span', anchor: week, amount: { days: 5 } },
|
|
922
|
+
consumed: at - i,
|
|
923
|
+
confidence: 0.95,
|
|
924
|
+
role: 'date',
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
/** Half-period adverbs: "Spätabends" 18–20, "Spätnachmittags" 14–16. */
|
|
928
|
+
const PERIOD_MODS = {
|
|
929
|
+
frühmorgens: 'start', früh: 'start',
|
|
930
|
+
spätabend: 'end', spätabends: 'end',
|
|
931
|
+
spätnachmittag: 'end', spätnachmittags: 'end',
|
|
932
|
+
};
|
|
933
|
+
const MOD_PERIOD_MARKERS = ['am', 'an', 'auf', 'gegen', 'zur', 'zum', 'in', 'der'];
|
|
934
|
+
const deModPeriod = (tokens, i) => {
|
|
935
|
+
let at = i;
|
|
936
|
+
if (MOD_PERIOD_MARKERS.includes(word(tokens[at]) ?? ''))
|
|
937
|
+
at += 1;
|
|
938
|
+
const w = word(tokens[at]);
|
|
939
|
+
if (w === undefined)
|
|
940
|
+
return undefined;
|
|
941
|
+
const mod = PERIOD_MODS[w];
|
|
942
|
+
const period = PERIODS[w];
|
|
943
|
+
if (mod === undefined || period === undefined)
|
|
944
|
+
return undefined;
|
|
945
|
+
return {
|
|
946
|
+
expr: { op: 'literal', dayPeriod: period, mod },
|
|
947
|
+
consumed: at + 1 - i,
|
|
948
|
+
confidence: 0.95,
|
|
949
|
+
role: 'time',
|
|
950
|
+
};
|
|
951
|
+
};
|
|
952
|
+
/** Plain half-unit amounts (mod start/end would tighten to the reference
|
|
953
|
+
* day, but the imported corpus expects the fixed half). */
|
|
954
|
+
const HALF_AMOUNT = {
|
|
955
|
+
year: { months: 6 },
|
|
956
|
+
quarter: { days: 45 },
|
|
957
|
+
month: { days: 15 },
|
|
958
|
+
week: { days: 3 },
|
|
959
|
+
day: { hours: 12 },
|
|
960
|
+
};
|
|
961
|
+
function unitHalf(unit, delta, half) {
|
|
962
|
+
const amount = HALF_AMOUNT[unit];
|
|
963
|
+
if (!amount)
|
|
964
|
+
return undefined;
|
|
965
|
+
const shifted = delta === 0 ? NOW : { op: 'offset', base: NOW, amount: delta, unit };
|
|
966
|
+
if (half === 'first') {
|
|
967
|
+
return { op: 'span', anchor: { op: 'snap', base: shifted, unit }, amount };
|
|
968
|
+
}
|
|
969
|
+
const neg = Object.fromEntries(Object.entries(amount).map(([k, v]) => [k, -v]));
|
|
970
|
+
return { op: 'span', anchor: { op: 'snap', base: shifted, unit, edge: 'end' }, amount: neg };
|
|
971
|
+
}
|
|
972
|
+
/** "Anfang/Mitte/Ende des Monats" → first/middle/last stretch of the unit. */
|
|
973
|
+
const deEdge = (tokens, i) => {
|
|
974
|
+
const w = word(tokens[i]);
|
|
975
|
+
if (w !== 'anfang' && w !== 'mitte' && w !== 'ende')
|
|
976
|
+
return undefined;
|
|
977
|
+
let at = i + 1;
|
|
978
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? '') || word(tokens[at]) === 'dieses')
|
|
979
|
+
at += 1;
|
|
980
|
+
const unit = UNITS[word(tokens[at]) ?? ''];
|
|
981
|
+
if (unit === undefined)
|
|
982
|
+
return undefined;
|
|
983
|
+
const expr = w === 'mitte'
|
|
984
|
+
? { op: 'snap', base: NOW, unit, mod: 'mid' }
|
|
985
|
+
: unitHalf(unit, 0, w === 'anfang' ? 'first' : 'second');
|
|
986
|
+
if (!expr)
|
|
987
|
+
return undefined;
|
|
988
|
+
return { expr, consumed: at + 1 - i, confidence: 0.95, role: 'date' };
|
|
989
|
+
};
|
|
990
|
+
/** "dieses Jahr später/früher" → the year's second/first half. */
|
|
991
|
+
const deModAfter = (tokens, i) => {
|
|
992
|
+
const pm = word(tokens[i]);
|
|
993
|
+
if (pm === undefined || DE_LEXICON.preMods[pm] === undefined)
|
|
994
|
+
return undefined;
|
|
995
|
+
const delta = DE_LEXICON.preMods[pm];
|
|
996
|
+
const unit = UNITS[word(tokens[i + 1]) ?? ''];
|
|
997
|
+
if (unit === undefined)
|
|
998
|
+
return undefined;
|
|
999
|
+
const after = word(tokens[i + 2]);
|
|
1000
|
+
const half = after === 'später' ? 'second' : after === 'früher' ? 'first' : undefined;
|
|
1001
|
+
if (half === undefined)
|
|
1002
|
+
return undefined;
|
|
1003
|
+
const expr = unitHalf(unit, delta, half);
|
|
1004
|
+
if (!expr)
|
|
1005
|
+
return undefined;
|
|
1006
|
+
return { expr, consumed: 3, confidence: 1, role: 'date' };
|
|
1007
|
+
};
|
|
1008
|
+
/** "der 25." — bare day-of-month with a mandatory article. */
|
|
1009
|
+
const deBareDay = (tokens, i) => {
|
|
1010
|
+
let at = i;
|
|
1011
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
1012
|
+
at += 1;
|
|
1013
|
+
if (at === i)
|
|
1014
|
+
return undefined;
|
|
1015
|
+
const t = tokens[at];
|
|
1016
|
+
if (t?.type !== 'number' || t.value < 1 || t.value > 31)
|
|
1017
|
+
return undefined;
|
|
1018
|
+
// Longer date shapes (day + month …) belong to other rules.
|
|
1019
|
+
const next = tokens[at + 1];
|
|
1020
|
+
if (next?.type === 'number')
|
|
1021
|
+
return undefined;
|
|
1022
|
+
const nw = word(next);
|
|
1023
|
+
if (nw !== undefined && (MONTHS[nw] !== undefined || UNITS[nw] !== undefined || nw === 'uhr')) {
|
|
1024
|
+
return undefined;
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
expr: { op: 'literal', date: { day: t.value } },
|
|
1028
|
+
consumed: at + 1 - i,
|
|
1029
|
+
confidence: 0.75,
|
|
1030
|
+
role: 'date',
|
|
1031
|
+
};
|
|
1032
|
+
};
|
|
1033
|
+
/** "in der letzten Woche im Juli" — the last week fully inside the month. */
|
|
1034
|
+
const deLastWeekOfMonth = (tokens, i) => {
|
|
1035
|
+
let at = i;
|
|
1036
|
+
if (word(tokens[at]) === 'in')
|
|
1037
|
+
at += 1;
|
|
1038
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
1039
|
+
at += 1;
|
|
1040
|
+
const pm = word(tokens[at]);
|
|
1041
|
+
if (pm === undefined || DE_LEXICON.preMods[pm] !== -1)
|
|
1042
|
+
return undefined;
|
|
1043
|
+
if (word(tokens[at + 1]) !== 'woche')
|
|
1044
|
+
return undefined;
|
|
1045
|
+
if (word(tokens[at + 2]) !== 'im' && word(tokens[at + 2]) !== 'von')
|
|
1046
|
+
return undefined;
|
|
1047
|
+
const month = MONTHS[word(tokens[at + 3]) ?? ''];
|
|
1048
|
+
if (month === undefined)
|
|
1049
|
+
return undefined;
|
|
1050
|
+
// Last Monday M with M+7 ≤ month end: seek prev Monday from (end − 6 days).
|
|
1051
|
+
return {
|
|
1052
|
+
expr: {
|
|
1053
|
+
op: 'span',
|
|
1054
|
+
anchor: {
|
|
1055
|
+
op: 'seek',
|
|
1056
|
+
base: {
|
|
1057
|
+
op: 'offset',
|
|
1058
|
+
base: { op: 'snap', base: { op: 'literal', date: { month } }, unit: 'month', edge: 'end' },
|
|
1059
|
+
amount: -6,
|
|
1060
|
+
unit: 'day',
|
|
1061
|
+
},
|
|
1062
|
+
dir: 'prev',
|
|
1063
|
+
target: { kind: 'weekday', weekday: 'mon' },
|
|
1064
|
+
n: 1,
|
|
1065
|
+
},
|
|
1066
|
+
amount: { days: 7 },
|
|
1067
|
+
},
|
|
1068
|
+
consumed: at + 4 - i,
|
|
1069
|
+
confidence: 1,
|
|
1070
|
+
role: 'date',
|
|
1071
|
+
};
|
|
1072
|
+
};
|
|
1073
|
+
/** Bare "das Jahr" / "in dem Jahr" / "des Jahres" → the current year. */
|
|
1074
|
+
const deBareYear = (tokens, i) => {
|
|
1075
|
+
let at = i;
|
|
1076
|
+
while (DE_LEXICON.articles.includes(word(tokens[at]) ?? ''))
|
|
1077
|
+
at += 1;
|
|
1078
|
+
if (at === i)
|
|
1079
|
+
return undefined; // require an article: bare "Jahr" is too weak
|
|
1080
|
+
const w = word(tokens[at]);
|
|
1081
|
+
if (w !== 'jahr' && w !== 'jahres')
|
|
1082
|
+
return undefined;
|
|
1083
|
+
const nx = tokens[at + 1];
|
|
1084
|
+
if (nx?.type === 'number')
|
|
1085
|
+
return undefined; // "im Jahr 2020" → deYearNoun
|
|
1086
|
+
return {
|
|
1087
|
+
expr: { op: 'snap', base: NOW, unit: 'year' },
|
|
1088
|
+
consumed: at + 1 - i,
|
|
1089
|
+
confidence: 0.55,
|
|
1090
|
+
role: 'date',
|
|
1091
|
+
};
|
|
1092
|
+
};
|
|
1093
|
+
/** "123,45 sek" / "3,5 Jahre" — the comma splits into two number tokens. */
|
|
1094
|
+
const UNIT_SECONDS = {
|
|
1095
|
+
second: 1, minute: 60, hour: 3600, day: 86400, week: 604800,
|
|
1096
|
+
month: 2592000, quarter: 7776000, year: 31536000,
|
|
1097
|
+
};
|
|
1098
|
+
const deDecimalDuration = (tokens, i) => {
|
|
1099
|
+
const a = tokens[i];
|
|
1100
|
+
const b = tokens[i + 1];
|
|
1101
|
+
if (a?.type !== 'number' || a.ordinal || b?.type !== 'number' || b.ordinal)
|
|
1102
|
+
return undefined;
|
|
1103
|
+
const unit = UNITS[word(tokens[i + 2]) ?? ''];
|
|
1104
|
+
if (unit === undefined)
|
|
1105
|
+
return undefined;
|
|
1106
|
+
const digits = b.end - b.start;
|
|
1107
|
+
if (digits > 2 || b.value >= 100)
|
|
1108
|
+
return undefined;
|
|
1109
|
+
const value = a.value + b.value / 10 ** digits;
|
|
1110
|
+
const seconds = value * UNIT_SECONDS[unit];
|
|
1111
|
+
return {
|
|
1112
|
+
expr: { op: 'duration', iso: `PT${seconds}S` },
|
|
1113
|
+
consumed: 3,
|
|
1114
|
+
confidence: 0.85,
|
|
1115
|
+
role: 'duration',
|
|
1116
|
+
};
|
|
1117
|
+
};
|
|
1118
|
+
// --- Holidays ---------------------------------------------------------------
|
|
1119
|
+
const FIXED_HOLIDAYS = [
|
|
1120
|
+
{ words: ['neujahr'], month: 1, day: 1 },
|
|
1121
|
+
{ words: ['heilige', 'drei', 'könige'], month: 1, day: 6 },
|
|
1122
|
+
{ words: ['weltkindertag'], month: 6, day: 1 },
|
|
1123
|
+
{ words: ['johannistag'], month: 6, day: 24 },
|
|
1124
|
+
{ words: ['peter', 'und', 'paul'], month: 6, day: 29 },
|
|
1125
|
+
{ words: ['augsburger', 'friedensfest'], month: 8, day: 8 },
|
|
1126
|
+
{ words: ['herbstanfang'], month: 9, day: 22 },
|
|
1127
|
+
{ words: ['tag', 'der', 'deutschen', 'einheit'], month: 10, day: 3 },
|
|
1128
|
+
{ words: ['allerheiligen'], month: 11, day: 1 },
|
|
1129
|
+
{ words: ['allerseelen'], month: 11, day: 2 },
|
|
1130
|
+
{ words: ['barbaratag'], month: 12, day: 4 },
|
|
1131
|
+
{ words: ['nikolaus'], month: 12, day: 6 },
|
|
1132
|
+
{ words: ['heiligabend'], month: 12, day: 24 },
|
|
1133
|
+
{ words: ['silvester'], month: 12, day: 31 },
|
|
1134
|
+
];
|
|
1135
|
+
/** Easter-anchored days as day offsets from Easter Sunday. */
|
|
1136
|
+
const EASTER_HOLIDAYS = [
|
|
1137
|
+
{ words: ['rosenmontag'], offset: -48 },
|
|
1138
|
+
{ words: ['fastnachtssamstag'], offset: -50 },
|
|
1139
|
+
{ words: ['fastnachtssonntag'], offset: -49 },
|
|
1140
|
+
{ words: ['aschermittwoch'], offset: -46 },
|
|
1141
|
+
{ words: ['palmsonntag'], offset: -7 },
|
|
1142
|
+
{ words: ['karfreitag'], offset: -2 },
|
|
1143
|
+
{ words: ['karsamstag'], offset: -1 },
|
|
1144
|
+
{ words: ['ostersonntag'], offset: 0 },
|
|
1145
|
+
{ words: ['ostern'], offset: 0 },
|
|
1146
|
+
{ words: ['ostermontag'], offset: 1 },
|
|
1147
|
+
{ words: ['himmelfahrt'], offset: 39 },
|
|
1148
|
+
{ words: ['pfingsten'], offset: 49 },
|
|
1149
|
+
{ words: ['pfingstsonntag'], offset: 49 },
|
|
1150
|
+
{ words: ['pfingstmontag'], offset: 50 },
|
|
1151
|
+
{ words: ['fronleichnam'], offset: 60 },
|
|
1152
|
+
];
|
|
1153
|
+
/** Weekday-computed holidays (built per optional year). */
|
|
1154
|
+
const COMPUTED_HOLIDAYS = [
|
|
1155
|
+
{
|
|
1156
|
+
// First Sunday of October.
|
|
1157
|
+
words: ['erntedankfest'],
|
|
1158
|
+
build: (year) => ({
|
|
1159
|
+
op: 'seek',
|
|
1160
|
+
base: { op: 'literal', date: year !== undefined ? { month: 10, year } : { month: 10 } },
|
|
1161
|
+
dir: 'next',
|
|
1162
|
+
target: { kind: 'weekday', weekday: 'sun' },
|
|
1163
|
+
n: 1,
|
|
1164
|
+
}),
|
|
1165
|
+
},
|
|
1166
|
+
{
|
|
1167
|
+
// Last Sunday before the 1st of Advent (falls on Nov 20–26).
|
|
1168
|
+
words: ['ewigkeitssonntag'],
|
|
1169
|
+
build: (year) => ({
|
|
1170
|
+
op: 'seek',
|
|
1171
|
+
base: { op: 'literal', date: year !== undefined ? { month: 11, day: 26, year } : { month: 11, day: 26 } },
|
|
1172
|
+
dir: 'prev',
|
|
1173
|
+
target: { kind: 'weekday', weekday: 'sun' },
|
|
1174
|
+
}),
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
words: ['totensonntag'],
|
|
1178
|
+
build: (year) => ({
|
|
1179
|
+
op: 'seek',
|
|
1180
|
+
base: { op: 'literal', date: year !== undefined ? { month: 11, day: 26, year } : { month: 11, day: 26 } },
|
|
1181
|
+
dir: 'prev',
|
|
1182
|
+
target: { kind: 'weekday', weekday: 'sun' },
|
|
1183
|
+
}),
|
|
1184
|
+
},
|
|
1185
|
+
];
|
|
1186
|
+
function matchWords(tokens, i, words) {
|
|
1187
|
+
for (const [k, w] of words.entries()) {
|
|
1188
|
+
if (word(tokens[i + k]) !== w)
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
return i + words.length;
|
|
1192
|
+
}
|
|
1193
|
+
/** Optional year qualifier after a holiday name: "2019", "im Jahr 2020",
|
|
1194
|
+
* "letzten Jahres", "im nächsten Jahr". */
|
|
1195
|
+
function readYearQualifier(tokens, i) {
|
|
1196
|
+
let at = i;
|
|
1197
|
+
if (word(tokens[at]) === 'im')
|
|
1198
|
+
at += 1;
|
|
1199
|
+
const pm = word(tokens[at]);
|
|
1200
|
+
if (pm !== undefined && DE_LEXICON.preMods[pm] !== undefined) {
|
|
1201
|
+
const nx = word(tokens[at + 1]);
|
|
1202
|
+
if (nx === 'jahr' || nx === 'jahres' || nx === 'jahre') {
|
|
1203
|
+
return { yearDelta: DE_LEXICON.preMods[pm], consumed: at + 2 - i };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
if (word(tokens[at]) === 'jahr' || word(tokens[at]) === 'jahre')
|
|
1207
|
+
at += 1;
|
|
1208
|
+
const yt = tokens[at];
|
|
1209
|
+
if (yt?.type === 'number' && !yt.ordinal && yt.value >= 1000 && yt.value <= 9999) {
|
|
1210
|
+
return { year: yt.value, consumed: at + 1 - i };
|
|
1211
|
+
}
|
|
1212
|
+
return { consumed: 0 };
|
|
1213
|
+
}
|
|
1214
|
+
const deHoliday = (tokens, i) => {
|
|
1215
|
+
const finish = (core, end, applyYear) => {
|
|
1216
|
+
const q = readYearQualifier(tokens, end);
|
|
1217
|
+
let expr = core;
|
|
1218
|
+
let consumed = end - i;
|
|
1219
|
+
if (q.yearDelta !== undefined) {
|
|
1220
|
+
const yearExpr = q.yearDelta === 0
|
|
1221
|
+
? { op: 'snap', base: NOW, unit: 'year' }
|
|
1222
|
+
: { op: 'snap', base: { op: 'offset', base: NOW, amount: q.yearDelta, unit: 'year' }, unit: 'year' };
|
|
1223
|
+
expr = { op: 'intersect', parts: [yearExpr, core] };
|
|
1224
|
+
consumed += q.consumed;
|
|
1225
|
+
}
|
|
1226
|
+
else if (q.year !== undefined && applyYear) {
|
|
1227
|
+
consumed += q.consumed;
|
|
1228
|
+
}
|
|
1229
|
+
return { expr, consumed, confidence: 1, role: 'date' };
|
|
1230
|
+
};
|
|
1231
|
+
for (const h of FIXED_HOLIDAYS) {
|
|
1232
|
+
const end = matchWords(tokens, i, h.words);
|
|
1233
|
+
if (end === undefined)
|
|
1234
|
+
continue;
|
|
1235
|
+
const q = readYearQualifier(tokens, end);
|
|
1236
|
+
const date = { month: h.month, day: h.day };
|
|
1237
|
+
if (q.year !== undefined)
|
|
1238
|
+
date.year = q.year;
|
|
1239
|
+
const core = { op: 'literal', date };
|
|
1240
|
+
if (q.year !== undefined) {
|
|
1241
|
+
return { expr: core, consumed: end + q.consumed - i, confidence: 1, role: 'date' };
|
|
1242
|
+
}
|
|
1243
|
+
return finish(core, end, false);
|
|
1244
|
+
}
|
|
1245
|
+
for (const h of EASTER_HOLIDAYS) {
|
|
1246
|
+
const end = matchWords(tokens, i, h.words);
|
|
1247
|
+
if (end === undefined)
|
|
1248
|
+
continue;
|
|
1249
|
+
const q = readYearQualifier(tokens, end);
|
|
1250
|
+
const base = q.year !== undefined
|
|
1251
|
+
? { op: 'holiday', name: 'easter', year: q.year }
|
|
1252
|
+
: { op: 'holiday', name: 'easter' };
|
|
1253
|
+
const core = h.offset === 0 ? base : { op: 'offset', base, amount: h.offset, unit: 'day' };
|
|
1254
|
+
if (q.year !== undefined) {
|
|
1255
|
+
return { expr: core, consumed: end + q.consumed - i, confidence: 1, role: 'date' };
|
|
1256
|
+
}
|
|
1257
|
+
return finish(core, end, false);
|
|
1258
|
+
}
|
|
1259
|
+
for (const h of COMPUTED_HOLIDAYS) {
|
|
1260
|
+
const end = matchWords(tokens, i, h.words);
|
|
1261
|
+
if (end === undefined)
|
|
1262
|
+
continue;
|
|
1263
|
+
const q = readYearQualifier(tokens, end);
|
|
1264
|
+
if (q.year !== undefined) {
|
|
1265
|
+
return { expr: h.build(q.year), consumed: end + q.consumed - i, confidence: 1, role: 'date' };
|
|
1266
|
+
}
|
|
1267
|
+
return finish(h.build(), end, false);
|
|
1268
|
+
}
|
|
1269
|
+
return undefined;
|
|
1270
|
+
};
|
|
1271
|
+
const deExtras = [
|
|
1272
|
+
{ name: 'de-range', rule: deRange },
|
|
1273
|
+
{ name: 'de-weekday-range', rule: deWeekdayRange },
|
|
1274
|
+
{ name: 'de-holiday', rule: deHoliday },
|
|
1275
|
+
{ name: 'de-fraction-clock', rule: deFractionClock },
|
|
1276
|
+
{ name: 'de-clock', rule: deClock },
|
|
1277
|
+
{ name: 'de-numdate', rule: deNumDate },
|
|
1278
|
+
{ name: 'de-word-date', rule: deWordDate },
|
|
1279
|
+
{ name: 'de-slash-date', rule: deSlashDate },
|
|
1280
|
+
{ name: 'de-day-month', rule: deDayMonth },
|
|
1281
|
+
{ name: 'de-two-step', rule: deTwoStep },
|
|
1282
|
+
{ name: 'de-mod-after', rule: deModAfter },
|
|
1283
|
+
{ name: 'de-last-week-of-month', rule: deLastWeekOfMonth },
|
|
1284
|
+
{ name: 'de-bare-day', rule: deBareDay },
|
|
1285
|
+
{ name: 'de-workweek', rule: deWorkweek },
|
|
1286
|
+
{ name: 'de-weekday-period', rule: deWeekdayPeriod },
|
|
1287
|
+
{ name: 'de-year-noun', rule: deYearNoun },
|
|
1288
|
+
{ name: 'de-bare-year', rule: deBareYear },
|
|
1289
|
+
{ name: 'de-edge', rule: deEdge },
|
|
1290
|
+
{ name: 'de-decimal-duration', rule: deDecimalDuration },
|
|
1291
|
+
{ name: 'de-duration', rule: deDuration },
|
|
1292
|
+
{ name: 'de-dash-date', rule: deDashDate },
|
|
1293
|
+
{ name: 'de-month-day-range', rule: deMonthDayRange },
|
|
1294
|
+
{ name: 'de-mod-period', rule: deModPeriod },
|
|
1295
|
+
{ name: 'de-bare-period', rule: deBarePeriod },
|
|
1296
|
+
];
|
|
1297
|
+
export const DE_RULE_ENTRIES = makeLatinRules(DE_LEXICON, deExtras);
|
|
1298
|
+
/** Connector words allowed between merged date & time parts. */
|
|
1299
|
+
export const DE_CONNECTORS = [
|
|
1300
|
+
'um', 'am', 'an', 'im', 'gegen', 'der', 'die', 'das', 'den', 'dem', 'zur', 'auf', '-',
|
|
1301
|
+
];
|