| 1 | /* datetime.js — the natural-language date combobox. A sibling of |
| 2 | rastrillo.js and select.js, on exactly the same terms: first-party, |
| 3 | dependency-free, inert by default, and app-owned from the moment |
| 4 | rastrillo new writes it into static/. |
| 5 | |
| 6 | Type "tomorrow", "next fri 9am", "25 Dec 6pm", "in 2 weeks" or |
| 7 | "14:30" and the combobox resolves it, previews the reading, and |
| 8 | writes it back to the native input on commit. The native input never |
| 9 | leaves the DOM: it keeps its name and its wire value, so every POST |
| 10 | is byte-identical to the un-enhanced form and the server keeps |
| 11 | parsing it with the same code. With scripts off the field is an |
| 12 | ordinary <input type="date">, and the browser's own picker still |
| 13 | opens. |
| 14 | |
| 15 | Vocabulary: |
| 16 | data-rst-date on a date or datetime-local input: arm |
| 17 | the combobox |
| 18 | data-rst-time the same, on a time input — the script |
| 19 | reads a clock rather than a calendar |
| 20 | data-rst-date-words the parser vocabulary, one JSON object |
| 21 | of |-separated spellings (see |
| 22 | ui/funcs.go's dateWords) |
| 23 | data-rst-date-set the commit affordance's label |
| 24 | data-rst-date-hint guidance under the list, {example} |
| 25 | substituted here with a date this |
| 26 | locale's own formatter wrote |
| 27 | data-rst-date-pick label for the native-picker button |
| 28 | data-rst-date-results live-region text, {n} substituted |
| 29 | data-rst-date-result-one live-region text for exactly one |
| 30 | data-rst-date-quick-* the seven quick-pick labels: today, |
| 31 | tomorrow, next-week, plus-1h, plus-2h, |
| 32 | end-of-day, next-day |
| 33 | data-rst-range on a WRAPPER around two armed inputs: |
| 34 | the two pair up by DOM order (first is |
| 35 | the start, second the end). The end |
| 36 | gets quick picks relative to the |
| 37 | start. A value of "session" also seeds |
| 38 | an empty end to start + 1h when the |
| 39 | start is committed. |
| 40 | |
| 41 | ui/partials/field-date.html, field-time.html, field-datetime.html |
| 42 | and field-daterange.html emit all of it. Every string comes from the |
| 43 | framework's base catalog (rastrillo.ui.date_*) and rides out on |
| 44 | attributes, because this markup is built in the browser where the |
| 45 | catalog is out of reach — so this file carries no English |
| 46 | VOCABULARY: not one word the parser matches on is written here. It |
| 47 | does carry English fallbacks for the eleven labels it puts on |
| 48 | screen, exactly as select.js does, so the file still works standing |
| 49 | alone in a page that forgot an attribute. |
| 50 | |
| 51 | The parser is a pure function of (text, vocabulary, locale tables, |
| 52 | now). It knows no month names and no weekday names by heart: it asks |
| 53 | Intl for them, in the page's own language, and folds the digits the |
| 54 | same way, so a field enhanced in Hindi reads Devanagari digits and |
| 55 | Hindi month names without a table anyone has to maintain. The |
| 56 | vocabulary — "tomorrow", "next", "in", "ago" — is the half a machine |
| 57 | cannot derive, and that half arrives translated on the attribute. |
| 58 | |
| 59 | The pure half is exported for Node at the bottom of the file, behind |
| 60 | a guard a browser never takes; ui/datetime_node.mjs drives it |
| 61 | against ui/testdata/datetime/*.json. */ |
| 62 | (function () { |
| 63 | "use strict"; |
| 64 | |
| 65 | /* ── the parser ────────────────────────────────────────────────── |
| 66 | Pure: no document, no state, no strings of its own. */ |
| 67 | |
| 68 | var COMBINING = /[\u0300-\u036f]/g; |
| 69 | |
| 70 | // Scripts that separate words with spaces. A phrase may only match |
| 71 | // between word boundaries in these, so "at" cannot be found inside |
| 72 | // "atlas" — while an affixing script (Japanese 後, Chinese 后) is |
| 73 | // deliberately left free to attach straight onto a quantity. Digits |
| 74 | // are excluded on purpose: "9am" and "25dec" are one run to a |
| 75 | // tokenizer and two words to a person. |
| 76 | var LETTERISH = /[a-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff\u0530-\u058f\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u0980-\u09ff\u0b80-\u0bff\u0e00-\u0e7f]/; |
| 77 | // The punctuation that separates words, and not only the ASCII of |
| 78 | // it: Arabic writes its comma \u060c and its semicolon \u061b, and CJK |
| 79 | // writes \uff0c\u3001\u3002. Left out, those characters fall through to the |
| 80 | // unknown-run branch and refuse a phrase that was punctuated |
| 81 | // correctly in its own script. |
| 82 | var BREAKS = /[\s,;\u060c\u061b\uff0c\u3001\u3002]/; |
| 83 | |
| 84 | function pad(n) { return (n < 10 ? "0" : "") + n; } |
| 85 | |
| 86 | function midnight(d) { |
| 87 | return new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| 88 | } |
| 89 | |
| 90 | function shiftDays(d, n) { |
| 91 | var x = midnight(d); |
| 92 | x.setDate(x.getDate() + n); |
| 93 | return x; |
| 94 | } |
| 95 | |
| 96 | // Strictly the NEXT one: a weekday named on the day it falls means |
| 97 | // the one a week out, never today. Naming today has a word of its |
| 98 | // own in every catalog, so the ambiguity buys nothing. |
| 99 | function comingWeekday(now, target) { |
| 100 | var delta = (target - now.getDay() + 7) % 7; |
| 101 | return shiftDays(now, delta === 0 ? 7 : delta); |
| 102 | } |
| 103 | |
| 104 | function lastWeekday(now, target) { |
| 105 | var delta = (now.getDay() - target + 7) % 7; |
| 106 | return shiftDays(now, -(delta === 0 ? 7 : delta)); |
| 107 | } |
| 108 | |
| 109 | // A day-and-month with no year means the next time it comes round. |
| 110 | function inferYear(now, mo, d) { |
| 111 | var y = now.getFullYear(); |
| 112 | return new Date(y, mo, d) < midnight(now) ? y + 1 : y; |
| 113 | } |
| 114 | |
| 115 | // Checked, never trusted: a 31st of February rolls silently forward |
| 116 | // in the Date constructor, which is how a typo becomes a date nobody |
| 117 | // meant. Built, read back, and refused if it moved. |
| 118 | function makeDate(y, mo, d, h, mi) { |
| 119 | // The carrier's wire format is four digits of year, so a year below |
| 120 | // a thousand writes "500-12-25" — which every date input rejects by |
| 121 | // silently emptying itself. A reading that clears the field is not |
| 122 | // a reading, and the floor lives here rather than beside each |
| 123 | // caller so no path can grow around it: the wire formats, the |
| 124 | // numeric d/m/y, "25 dec 0500" and a bare year all pass through. |
| 125 | if (y < 1000) return null; |
| 126 | if (mo < 0 || mo > 11 || d < 1 || d > 31) return null; |
| 127 | if (h < 0 || h > 23 || mi < 0 || mi > 59) return null; |
| 128 | var dt = new Date(y, mo, d, h, mi, 0, 0); |
| 129 | if (dt.getMonth() !== mo || dt.getDate() !== d) return null; |
| 130 | return dt; |
| 131 | } |
| 132 | |
| 133 | // The numbering systems a person may type in, probed against the |
| 134 | // locale and unioned into the fold. The locale's default is not |
| 135 | // enough on its own: this ICU resolves plain "ar" and plain "hi" to |
| 136 | // latn, so those fields read "25" and refused ٢٥ and २५ — the digits |
| 137 | // their keyboards make. Unioning DIGITS is safe in a way that unioning |
| 138 | // words never is: a digit means the same number in every language, so |
| 139 | // an English field that also takes ٢٥ has guessed nothing. Systems |
| 140 | // that spell a number a word at a time are absent on purpose; they |
| 141 | // cannot be read back a character at a time either. |
| 142 | var NUMBERING = ["arab", "arabext", "beng", "deva"]; |
| 143 | |
| 144 | // One system's ten digits, merged in. False where it adds nothing, or |
| 145 | // cannot be folded at all: more than a character to a digit, a digit |
| 146 | // written twice, or a fold contradicting one already agreed. |
| 147 | function foldDigits(nf, map) { |
| 148 | var i, parts, k, digit, add = {}, differs = false; |
| 149 | for (i = 0; i <= 9; i++) { |
| 150 | parts = nf.formatToParts(i); |
| 151 | digit = ""; |
| 152 | for (k = 0; k < parts.length; k++) if (parts[k].type === "integer") digit = parts[k].value; |
| 153 | if (digit.length !== 1) return false; |
| 154 | if (add[digit] !== undefined) return false; |
| 155 | if (map[digit] !== undefined && map[digit] !== String(i)) return false; |
| 156 | if (digit !== String(i)) differs = true; |
| 157 | add[digit] = String(i); |
| 158 | } |
| 159 | for (digit in add) if (Object.prototype.hasOwnProperty.call(add, digit)) map[digit] = add[digit]; |
| 160 | return differs; |
| 161 | } |
| 162 | |
| 163 | // The ten digits this locale writes, mapped back to ASCII. ar-EG |
| 164 | // counts in "١٢٣", which neither \d nor unary + accepts, and a |
| 165 | // NumberFormat in that locale printing 0-9 IS the table. null where |
| 166 | // there is nothing to fold, and null for a numbering system that |
| 167 | // does not spell a number one digit at a time — it cannot be read |
| 168 | // back one digit at a time either. |
| 169 | function digitFolder(locale) { |
| 170 | var map = {}, differs = false, base, root, i; |
| 171 | try { |
| 172 | base = new Intl.NumberFormat(locale, { useGrouping: false }); |
| 173 | } catch (e) { |
| 174 | return null; |
| 175 | } |
| 176 | if (foldDigits(base, map)) differs = true; |
| 177 | // The tag the probes hang off, with any -u- extension the resolver |
| 178 | // already added trimmed off: a second one is an ill-formed tag. |
| 179 | try { |
| 180 | root = base.resolvedOptions().locale.split("-u-")[0]; |
| 181 | } catch (e) { |
| 182 | root = ""; |
| 183 | } |
| 184 | for (i = 0; root && i < NUMBERING.length; i++) { |
| 185 | try { |
| 186 | if (foldDigits(new Intl.NumberFormat(root + "-u-nu-" + NUMBERING[i], { useGrouping: false }), map)) differs = true; |
| 187 | } catch (e) { |
| 188 | /* a numbering system this engine does not know folds nothing */ |
| 189 | } |
| 190 | } |
| 191 | if (!differs) return null; |
| 192 | return function (s) { |
| 193 | var out = "", j; |
| 194 | for (j = 0; j < s.length; j++) out += map[s.charAt(j)] !== undefined ? map[s.charAt(j)] : s.charAt(j); |
| 195 | return out; |
| 196 | }; |
| 197 | } |
| 198 | |
| 199 | // One normalizer, used on both sides — on the vocabulary and the |
| 200 | // Intl names as the tables are built, and on the typed text as it is |
| 201 | // read — so a person who leaves the accents off still meets the |
| 202 | // words the catalog spells with them. |
| 203 | function makeFold(digits) { |
| 204 | return function (s) { |
| 205 | var out = String(s === null || s === undefined ? "" : s); |
| 206 | if (out.normalize) out = out.normalize("NFD").replace(COMBINING, ""); |
| 207 | out = out.toLowerCase(); |
| 208 | if (digits) out = digits(out); |
| 209 | return out.replace(/\s+/g, " ").trim(); |
| 210 | }; |
| 211 | } |
| 212 | |
| 213 | // What this locale calls each month (or weekday), long and short, |
| 214 | // index-aligned with the probe dates — which is what turns a matched |
| 215 | // word back into a number. Asked of Intl rather than tabulated, |
| 216 | // because a table per language is precisely the thing this file |
| 217 | // exists not to keep. |
| 218 | function nameTable(locale, type, dates, fold) { |
| 219 | var widths = ["long", "short"], out = [], i, c, w, opts, fmt, parts, k, value; |
| 220 | // Two shapes of every month name, because a month is not one word |
| 221 | // in every language: a Russian calendar header says декабрь and a |
| 222 | // Russian typing a date writes 25 декабря, and Intl only hands over |
| 223 | // that second form when a DAY is in the format. Asking both ways |
| 224 | // and keeping both answers is how a DERIVED table covers a case the |
| 225 | // grammar decides — no genitive is written down here, and Ukrainian |
| 226 | // and Greek are fixed by the same two lines. |
| 227 | var contexts = type === "month" ? [null, "numeric"] : [null]; |
| 228 | for (i = 0; i < dates.length; i++) out.push([]); |
| 229 | for (c = 0; c < contexts.length; c++) |
| 230 | for (w = 0; w < widths.length; w++) { |
| 231 | opts = { timeZone: "UTC" }; |
| 232 | opts[type] = widths[w]; |
| 233 | if (contexts[c]) opts.day = contexts[c]; |
| 234 | try { |
| 235 | fmt = new Intl.DateTimeFormat(locale, opts); |
| 236 | } catch (e) { |
| 237 | continue; |
| 238 | } |
| 239 | for (i = 0; i < dates.length; i++) { |
| 240 | parts = fmt.formatToParts(dates[i]); |
| 241 | value = ""; |
| 242 | for (k = 0; k < parts.length; k++) if (parts[k].type === type) value = parts[k].value; |
| 243 | value = fold(value); |
| 244 | if (!value) continue; |
| 245 | // A NAME has to be a word. Japanese and Chinese write their |
| 246 | // months as numbers, and Intl reports that as |
| 247 | // {month:"8"}+{literal:"\u6708"} — so the "name" of August comes |
| 248 | // back as the bare digit 8, and a table holding it turns every |
| 249 | // typed number into a month. It did: ja "2025" tokenized as |
| 250 | // month 2 plus 025 and committed 25 February 2027 without a |
| 251 | // word of warning, which is the one thing this parser promises |
| 252 | // never to do. Anything that is only digits and separators is |
| 253 | // refused here rather than trusted; a calendar whose months are |
| 254 | // numbers is one this grammar cannot read, and saying so is the |
| 255 | // honest answer. |
| 256 | if (!/[^\d\s.,\/\-]/.test(value)) continue; |
| 257 | if (out[i].indexOf(value) === -1) out[i].push(value); |
| 258 | // An abbreviation's full stop is dropped as often as it is |
| 259 | // typed ("16 Aug." retyped as "16 Aug"), so both spellings are |
| 260 | // in the table rather than tolerated by one regex somewhere. |
| 261 | if (value.charAt(value.length - 1) === "." && value.length > 1) { |
| 262 | value = value.slice(0, -1); |
| 263 | if (out[i].indexOf(value) === -1) out[i].push(value); |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | return out; |
| 268 | } |
| 269 | |
| 270 | // The options the field's own display formats with. Up here in the |
| 271 | // pure half rather than beside the page code that uses it, because |
| 272 | // the parser has to read that display back: what this field writes, |
| 273 | // it must be able to re-read, and a reading that cannot is how an |
| 274 | // in-place edit turns into a refusal. |
| 275 | function displayOptions(kind) { |
| 276 | var opts = kind === "time" |
| 277 | ? { hour: "numeric", minute: "2-digit" } |
| 278 | : { weekday: "short", day: "numeric", month: "short", year: "numeric" }; |
| 279 | if (kind === "datetime") { |
| 280 | opts.hour = "numeric"; |
| 281 | opts.minute = "2-digit"; |
| 282 | } |
| 283 | return opts; |
| 284 | } |
| 285 | |
| 286 | // What Intl writes that nobody typed: the literals it threads between |
| 287 | // the numbers (ja 年月日, ru's "г.", pt's "de", the brackets round a |
| 288 | // Japanese weekday) and the dayPeriod names it chooses, which are not |
| 289 | // always the ones a catalog spells — Intl writes yue's afternoon as |
| 290 | // 下晝 where the catalog says 下午. Derived from the same options the |
| 291 | // display uses, plus the day-and-month shapes, which is the form a |
| 292 | // person types ("25 de marzo"). Literals come back ignorable and |
| 293 | // dayPeriods come back as am/pm, so the field can read its own |
| 294 | // writing without a per-language table anybody maintains. |
| 295 | function displayTokens(locale, fold) { |
| 296 | var probes = [displayOptions("date"), displayOptions("datetime"), displayOptions("time"), |
| 297 | { day: "numeric", month: "long" }, { day: "numeric", month: "short" }]; |
| 298 | var when = [new Date(Date.UTC(2026, 11, 25, 15, 30)), new Date(Date.UTC(2026, 2, 3, 9, 5))]; |
| 299 | var out = { literal: {}, am: {}, pm: {} }, p, i, fmt, parts, k, v, c, run; |
| 300 | function keep(bag, s) { s = fold(s); if (s) bag[s] = true; } |
| 301 | for (p = 0; p < probes.length; p++) { |
| 302 | probes[p].timeZone = "UTC"; |
| 303 | try { |
| 304 | fmt = new Intl.DateTimeFormat(locale, probes[p]); |
| 305 | } catch (e) { |
| 306 | continue; |
| 307 | } |
| 308 | for (i = 0; i < when.length; i++) { |
| 309 | parts = fmt.formatToParts(when[i]); |
| 310 | for (k = 0; k < parts.length; k++) { |
| 311 | v = parts[k].value; |
| 312 | if (parts[k].type === "dayPeriod") { |
| 313 | keep(when[i].getUTCHours() < 12 ? out.am : out.pm, v); |
| 314 | continue; |
| 315 | } |
| 316 | if (parts[k].type !== "literal") continue; |
| 317 | // One literal is not always one token: ja hands back "日(" in |
| 318 | // a single part. Split into letter runs and single marks so |
| 319 | // the day counter stays a day counter and the bracket becomes |
| 320 | // its own ignorable thing. |
| 321 | for (c = 0, run = ""; c <= v.length; c++) { |
| 322 | if (c < v.length && letterish(v.charAt(c))) { run += v.charAt(c); continue; } |
| 323 | keep(out.literal, run); |
| 324 | run = ""; |
| 325 | if (c < v.length && !BREAKS.test(v.charAt(c))) keep(out.literal, v.charAt(c)); |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | return out; |
| 331 | } |
| 332 | |
| 333 | var TABLES = {}; |
| 334 | |
| 335 | // Everything derived from a locale, built once and cached: the fold, |
| 336 | // the month and weekday names, the wall-clock formatters the field |
| 337 | // shows its value with. |
| 338 | function tables(locale) { |
| 339 | var key = locale || ""; |
| 340 | if (TABLES[key]) return TABLES[key]; |
| 341 | var loc = locale || undefined; |
| 342 | var fold = makeFold(digitFolder(loc)); |
| 343 | var monthDates = [], weekDates = [], i; |
| 344 | for (i = 0; i < 12; i++) monthDates.push(new Date(Date.UTC(2026, i, 15))); |
| 345 | // 1 Feb 2026 is a Sunday, so the seven probes land index-aligned |
| 346 | // with Date.prototype.getDay. |
| 347 | for (i = 0; i < 7; i++) weekDates.push(new Date(Date.UTC(2026, 1, 1 + i))); |
| 348 | var t = { |
| 349 | locale: loc, |
| 350 | fold: fold, |
| 351 | months: nameTable(loc, "month", monthDates, fold), |
| 352 | weekdays: nameTable(loc, "weekday", weekDates, fold), |
| 353 | display: displayTokens(loc, fold) |
| 354 | }; |
| 355 | // Does this locale call its months anything at all? Japanese does |
| 356 | // not — it numbers them, and nameTable will not hold a bare digit |
| 357 | // as a name — so the table is empty and the grammar reads 12月25日 |
| 358 | // another way. Derived: no list of numbering calendars kept here. |
| 359 | t.namedMonths = false; |
| 360 | for (i = 0; i < t.months.length; i++) if (t.months[i].length) t.namedMonths = true; |
| 361 | TABLES[key] = t; |
| 362 | return t; |
| 363 | } |
| 364 | |
| 365 | // The vocabulary and the Intl names as one list, longest phrase |
| 366 | // first — so a plain scan cannot let "m" win over "may", and a |
| 367 | // multi-word entry ("hôm nay", "منتصف الليل", "seo chugainn") is |
| 368 | // matched whole. Splitting is on "|" only: "meio-dia" is one entry |
| 369 | // and stays one. |
| 370 | function index(vocab, tbl) { |
| 371 | var list = [], name, alts, i, m, k; |
| 372 | for (name in vocab) { |
| 373 | if (!Object.prototype.hasOwnProperty.call(vocab, name)) continue; |
| 374 | alts = String(vocab[name]).split("|"); |
| 375 | for (i = 0; i < alts.length; i++) { |
| 376 | var text = tbl.fold(alts[i]); |
| 377 | if (text) list.push({ text: text, role: "word", kind: name, at: -1 }); |
| 378 | } |
| 379 | } |
| 380 | // The display's own vocabulary, on the same list: a literal is a |
| 381 | // word the reading may skip, and a dayPeriod is am or pm however |
| 382 | // Intl chose to spell it. Both are additive — a spelling already |
| 383 | // carrying a kind keeps it and gains this one, because matchAt |
| 384 | // merges every entry of the same length. |
| 385 | var seen = ["literal", "am", "pm"], kind; |
| 386 | for (i = 0; i < seen.length; i++) { |
| 387 | kind = seen[i] === "literal" ? "ignore" : seen[i]; |
| 388 | for (name in tbl.display[seen[i]]) |
| 389 | if (Object.prototype.hasOwnProperty.call(tbl.display[seen[i]], name)) |
| 390 | list.push({ text: name, role: "word", kind: kind, at: -1 }); |
| 391 | } |
| 392 | for (m = 0; m < tbl.months.length; m++) |
| 393 | for (k = 0; k < tbl.months[m].length; k++) |
| 394 | list.push({ text: tbl.months[m][k], role: "month", kind: "", at: m }); |
| 395 | for (m = 0; m < tbl.weekdays.length; m++) |
| 396 | for (k = 0; k < tbl.weekdays[m].length; k++) |
| 397 | list.push({ text: tbl.weekdays[m][k], role: "weekday", kind: "", at: m }); |
| 398 | list.sort(function (a, b) { return b.text.length - a.text.length; }); |
| 399 | return list; |
| 400 | } |
| 401 | |
| 402 | // A separator is never a letter, whatever block it lives in. The |
| 403 | // Arabic comma sits at \u060c, inside the same range as the letters |
| 404 | // either side of it, so a class drawn by range alone reads |
| 405 | // "\u063a\u062f\u0627\u060c 6" as one unbroken word and refuses a phrase that was |
| 406 | // punctuated correctly. |
| 407 | function letterish(c) { |
| 408 | return !!c && !BREAKS.test(c) && LETTERISH.test(c); |
| 409 | } |
| 410 | |
| 411 | function boundaryOK(s, from, to) { |
| 412 | if (from > 0 && letterish(s.charAt(from - 1)) && letterish(s.charAt(from))) return false; |
| 413 | if (to < s.length && letterish(s.charAt(to)) && letterish(s.charAt(to - 1))) return false; |
| 414 | return true; |
| 415 | } |
| 416 | |
| 417 | // The longest phrase starting here, as one token. Every entry of that |
| 418 | // same length is merged in, because one spelling can wear two hats: |
| 419 | // Vietnamese "sau" is both "next" and "in", and which one it means is |
| 420 | // a question the grammar answers, not the scanner. |
| 421 | function matchAt(s, pos, idx) { |
| 422 | var best = -1, tok = null, i, e; |
| 423 | for (i = 0; i < idx.length; i++) { |
| 424 | e = idx[i]; |
| 425 | if (best >= 0 && e.text.length < best) break; |
| 426 | if (s.slice(pos, pos + e.text.length) !== e.text) continue; |
| 427 | if (!boundaryOK(s, pos, pos + e.text.length)) continue; |
| 428 | if (best < 0) { |
| 429 | best = e.text.length; |
| 430 | tok = { t: "word", kinds: {}, month: -1, weekday: -1 }; |
| 431 | } |
| 432 | if (e.role === "word") tok.kinds[e.kind] = true; |
| 433 | else if (e.role === "month") tok.month = e.at; |
| 434 | else tok.weekday = e.at; |
| 435 | } |
| 436 | return tok ? { tok: tok, len: best } : null; |
| 437 | } |
| 438 | |
| 439 | function scan(s, idx) { |
| 440 | var toks = [], pos = 0, n = s.length, m, hit, start, glued = false; |
| 441 | // Whether nothing at all stood between this token and the one |
| 442 | // before it. A counter glued to its number belongs to that number |
| 443 | // (12月, 25日); the same word with a space in front of it is a noun |
| 444 | // being counted ("3 months"). A fact about the typing, not about |
| 445 | // any language, which is why it can be recorded without knowing |
| 446 | // which language was typed. |
| 447 | function push(tok) { |
| 448 | tok.glued = glued; |
| 449 | toks.push(tok); |
| 450 | glued = true; |
| 451 | } |
| 452 | while (pos < n) { |
| 453 | if (BREAKS.test(s.charAt(pos))) { pos++; glued = false; continue; } |
| 454 | m = /^(\d{1,2}):(\d{2})/.exec(s.slice(pos)); |
| 455 | if (m) { |
| 456 | push({ t: "clock", h: +m[1], mi: +m[2] }); |
| 457 | pos += m[0].length; |
| 458 | continue; |
| 459 | } |
| 460 | hit = matchAt(s, pos, idx); |
| 461 | if (hit) { push(hit.tok); pos += hit.len; continue; } |
| 462 | m = /^\d+/.exec(s.slice(pos)); |
| 463 | if (m) { |
| 464 | push({ t: "num", n: +m[0], digits: m[0].length }); |
| 465 | pos += m[0].length; |
| 466 | continue; |
| 467 | } |
| 468 | start = pos; |
| 469 | while (pos < n && !BREAKS.test(s.charAt(pos)) && !/\d/.test(s.charAt(pos))) { |
| 470 | if (pos > start && matchAt(s, pos, idx)) break; |
| 471 | pos++; |
| 472 | } |
| 473 | if (pos === start) pos++; |
| 474 | push({ t: "other", raw: s.slice(start, pos) }); |
| 475 | } |
| 476 | return toks; |
| 477 | } |
| 478 | |
| 479 | // parse(text, vocab, tbl, now, ctx) -> {date, hasDate, hasTime} or |
| 480 | // null. Never a guess: text this grammar does not understand comes |
| 481 | // back null, so nothing is armed and nothing is committed. |
| 482 | // |
| 483 | // ctx.prev is the value this field is already showing; ctx.base is |
| 484 | // the day a wired range's START is on. The two are not |
| 485 | // interchangeable. A phrase naming no date at all ("5pm") lands on |
| 486 | // the START's day — that is what makes a time-only end stay in the |
| 487 | // same session. A phrase naming PART of a date ("2030") keeps the day |
| 488 | // THIS field is showing, because the part it left out is the part it |
| 489 | // means to keep. |
| 490 | function parse(text, vocab, tbl, now, ctx) { |
| 491 | ctx = ctx || {}; |
| 492 | var raw = tbl.fold(text); |
| 493 | if (!raw) return null; |
| 494 | // Cached against the vocabulary itself, not just the locale: a |
| 495 | // page could in principle carry two, and a stale index would parse |
| 496 | // the second field with the first field's words. |
| 497 | var key = ""; |
| 498 | for (var name in vocab) if (Object.prototype.hasOwnProperty.call(vocab, name)) key += name + "=" + vocab[name] + "\u0000"; |
| 499 | if (tbl.indexKey !== key) { |
| 500 | tbl.index = index(vocab, tbl); |
| 501 | tbl.indexKey = key; |
| 502 | } |
| 503 | var idx = tbl.index; |
| 504 | |
| 505 | var prev = ctx.prev || null; |
| 506 | var base = ctx.base || null; |
| 507 | var anchor = midnight(base || prev || now); |
| 508 | var own = midnight(prev || base || now); |
| 509 | var clockH = prev ? prev.getHours() : 9; |
| 510 | var clockM = prev ? prev.getMinutes() : 0; |
| 511 | |
| 512 | var m, d; |
| 513 | |
| 514 | // The wire formats, read straight back: what the native carrier |
| 515 | // holds, and what a person pastes out of one form into another. |
| 516 | m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[t ](\d{1,2}):(\d{2})(?::\d{2})?)?$/.exec(raw); |
| 517 | if (m) { |
| 518 | d = makeDate(+m[1], +m[2] - 1, +m[3], |
| 519 | m[4] === undefined ? clockH : +m[4], |
| 520 | m[4] === undefined ? clockM : +m[5]); |
| 521 | return d ? { date: d, hasDate: true, hasTime: m[4] !== undefined } : null; |
| 522 | } |
| 523 | // Year first, with the separators a keyboard reaches for rather |
| 524 | // than the wire's hyphen. Nothing else in any calendar puts four |
| 525 | // digits in front of a date, so 2026/12/25 and 2026.12.25 need no |
| 526 | // day-or-month guess: it is how ja, zh and yue write one down. |
| 527 | m = /^(\d{4})[\/.](\d{1,2})[\/.](\d{1,2})$/.exec(raw); |
| 528 | if (m) { |
| 529 | d = makeDate(+m[1], +m[2] - 1, +m[3], clockH, clockM); |
| 530 | return d ? { date: d, hasDate: true, hasTime: false } : null; |
| 531 | } |
| 532 | // Numeric d/m[/y], day first — and swapped where only the other |
| 533 | // reading can be true, so a pasted 12/25/2027 is understood rather |
| 534 | // than refused. |
| 535 | m = /^(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?$/.exec(raw); |
| 536 | if (m) { |
| 537 | var day = +m[1], mon = +m[2] - 1, swap; |
| 538 | if (mon > 11 && day <= 12) { swap = day; day = +m[2]; mon = swap - 1; } |
| 539 | var year = m[3] === undefined ? inferYear(now, mon, day) |
| 540 | : (+m[3] < 100 ? 2000 + +m[3] : +m[3]); |
| 541 | d = makeDate(year, mon, day, clockH, clockM); |
| 542 | return d ? { date: d, hasDate: true, hasTime: false } : null; |
| 543 | } |
| 544 | |
| 545 | var toks = scan(raw, idx), i; |
| 546 | if (!toks.length) return null; |
| 547 | for (i = 0; i < toks.length; i++) if (toks[i].t === "other") return null; |
| 548 | |
| 549 | var used = []; |
| 550 | function find(kind) { |
| 551 | for (var k = 0; k < toks.length; k++) |
| 552 | if (!used[k] && toks[k].t === "word" && toks[k].kinds[kind]) return k; |
| 553 | return -1; |
| 554 | } |
| 555 | function findRole(role) { |
| 556 | for (var k = 0; k < toks.length; k++) { |
| 557 | if (used[k] || toks[k].t !== "word" || toks[k][role] < 0) continue; |
| 558 | // A weekday name being counted is not a weekday: nobody writes |
| 559 | // a date by putting a number in front of one. A MONTH name is |
| 560 | // the opposite case and is left alone — "25dec" is how people |
| 561 | // type one. |
| 562 | if (role === "weekday" && k > 0 && toks[k].glued && isCounter(k, k - 1)) continue; |
| 563 | return k; |
| 564 | } |
| 565 | return -1; |
| 566 | } |
| 567 | function findKind(t) { |
| 568 | for (var k = 0; k < toks.length; k++) if (!used[k] && toks[k].t === t) return k; |
| 569 | return -1; |
| 570 | } |
| 571 | function after(k) { |
| 572 | return k + 1 < toks.length && !used[k + 1] ? k + 1 : -1; |
| 573 | } |
| 574 | function before(k) { |
| 575 | return k > 0 && !used[k - 1] ? k - 1 : -1; |
| 576 | } |
| 577 | function unit() { |
| 578 | var names = ["minute", "hour", "day", "week", "month"], k, at; |
| 579 | for (k = 0; k < names.length; k++) { |
| 580 | at = find(names[k]); |
| 581 | if (at >= 0) return { name: names[k], at: at }; |
| 582 | } |
| 583 | return null; |
| 584 | } |
| 585 | // Is the name at k being counted rather than named? A number in |
| 586 | // front of it says so, and so does a next/last particle written |
| 587 | // straight onto a word that is a unit as well as a name: 来月 is |
| 588 | // next MONTH and 翌日 is the next DAY, and both asked for a weekday |
| 589 | // until this line, because 月 and 日 are also how Japanese |
| 590 | // abbreviates Monday and Sunday. |
| 591 | function isCounter(k, p) { |
| 592 | if (toks[p].t === "num") return true; |
| 593 | if (toks[p].t !== "word") return false; |
| 594 | if (!toks[p].kinds.next && !toks[p].kinds.last) return false; |
| 595 | return !!(toks[k].kinds.day || toks[k].kinds.week || toks[k].kinds.month); |
| 596 | } |
| 597 | function isWord(k, kind) { |
| 598 | return k >= 0 && toks[k].t === "word" && !!toks[k].kinds[kind]; |
| 599 | } |
| 600 | // The counter glued to a number already claimed — the 日 of 25日, |
| 601 | // the 号 of 25号. It belongs to that number, and leaving it behind |
| 602 | // failed the leftover check on a date written correctly. Glued is |
| 603 | // the whole test: "3 days" has a space and stays a duration. |
| 604 | function claimCounter(at, kind) { |
| 605 | var nx = at >= 0 ? after(at) : -1; |
| 606 | if (nx < 0 || toks[nx].t !== "word" || !toks[nx].glued) return false; |
| 607 | if (!toks[nx].kinds[kind]) return false; |
| 608 | used[nx] = true; |
| 609 | return true; |
| 610 | } |
| 611 | // A number glued to the month counter where months have no names: |
| 612 | // the 12 of 12月25日. Gated on the empty table so no language that |
| 613 | // CALLS its months something reaches here — without it, English |
| 614 | // "3months" would read as March. |
| 615 | function counterMonth() { |
| 616 | if (tbl.namedMonths) return -1; |
| 617 | for (var k = 0; k < toks.length; k++) { |
| 618 | if (used[k] || toks[k].t !== "num" || toks[k].digits > 2) continue; |
| 619 | var nx = after(k); |
| 620 | if (nx >= 0 && toks[nx].t === "word" && toks[nx].glued && toks[nx].kinds.month) return k; |
| 621 | } |
| 622 | return -1; |
| 623 | } |
| 624 | |
| 625 | var date = null, hasDate = false, hasTime = false, h = 0, mi = 0; |
| 626 | |
| 627 | // A quantity is what tells "in"/"ago" apart from "next"/"last" |
| 628 | // where one spelling carries both — Vietnamese "sau" is "next" |
| 629 | // with nothing counted and "in" with a number beside it. |
| 630 | var quantified = findKind("num") >= 0; |
| 631 | var into = find("in"), back = find("ago"); |
| 632 | // Both directions at once is not a phrase, it is a contradiction: |
| 633 | // "in 2 weeks ago" and "через 2 недели назад" name no instant, and |
| 634 | // the sign was being picked by whichever branch ran second — ago |
| 635 | // won, silently, and the reading looked confident. Two DISTINCT |
| 636 | // tokens is the test, because one spelling is allowed to carry |
| 637 | // both kinds (Vietnamese "sau" does) and a single word wearing two |
| 638 | // hats is still one word. |
| 639 | if (into >= 0 && back >= 0 && into !== back) return null; |
| 640 | if (quantified && (into >= 0 || back >= 0)) { |
| 641 | var u = unit(); |
| 642 | if (u) { |
| 643 | var qi = findKind("num"); |
| 644 | var qty = toks[qi].n, sign = back >= 0 ? -1 : 1; |
| 645 | used[qi] = true; |
| 646 | used[u.at] = true; |
| 647 | if (into >= 0) used[into] = true; |
| 648 | if (back >= 0) used[back] = true; |
| 649 | if (u.name === "minute" || u.name === "hour") { |
| 650 | // An offset counted in clock units moves the clock, so it |
| 651 | // names a time as well as a day. |
| 652 | date = new Date(now.getTime() + sign * qty * (u.name === "hour" ? 3600000 : 60000)); |
| 653 | hasDate = true; |
| 654 | hasTime = true; |
| 655 | h = date.getHours(); |
| 656 | mi = date.getMinutes(); |
| 657 | } else { |
| 658 | date = midnight(now); |
| 659 | if (u.name === "day") date.setDate(date.getDate() + sign * qty); |
| 660 | else if (u.name === "week") date.setDate(date.getDate() + sign * qty * 7); |
| 661 | else date.setMonth(date.getMonth() + sign * qty); |
| 662 | hasDate = true; |
| 663 | } |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | // The clock. Claimed before the calendar so "25 dec 6pm" cannot |
| 668 | // read its 6 as a second day number. |
| 669 | if (!hasTime) { |
| 670 | var noon = find("noon"), mid = find("midnight"); |
| 671 | if (noon >= 0) { h = 12; mi = 0; hasTime = true; used[noon] = true; } |
| 672 | else if (mid >= 0) { h = 0; mi = 0; hasTime = true; used[mid] = true; } |
| 673 | else { |
| 674 | var ci = findKind("clock"); |
| 675 | if (ci >= 0) { |
| 676 | h = toks[ci].h; |
| 677 | mi = toks[ci].mi; |
| 678 | used[ci] = true; |
| 679 | hasTime = true; |
| 680 | } else { |
| 681 | for (i = 0; i < toks.length && !hasTime; i++) { |
| 682 | if (used[i] || toks[i].t !== "num") continue; |
| 683 | var nx = after(i), pv = before(i); |
| 684 | if (isWord(nx, "am") || isWord(nx, "pm") || isWord(pv, "am") || isWord(pv, "pm")) { |
| 685 | h = toks[i].n; |
| 686 | mi = 0; |
| 687 | used[i] = true; |
| 688 | hasTime = true; |
| 689 | // A half-of-the-day marker and a clock marker can both |
| 690 | // sit on the same hour: \u5348\u5f8c6\u6642 and \u4e0b\u53486\u70b9 are "6 in the |
| 691 | // afternoon" with the hour word still attached. Claiming |
| 692 | // the number and leaving the \u6642 behind failed the |
| 693 | // leftover check and refused a perfectly ordinary phrase. |
| 694 | if (isWord(nx, "hour")) { |
| 695 | used[nx] = true; |
| 696 | var mnx = after(nx); |
| 697 | if (mnx >= 0 && toks[mnx].t === "num" && (after(mnx) < 0 || isWord(after(mnx), "minute"))) { |
| 698 | mi = toks[mnx].n; |
| 699 | used[mnx] = true; |
| 700 | if (after(mnx) >= 0) used[after(mnx)] = true; |
| 701 | } |
| 702 | } |
| 703 | } else if (isWord(nx, "hour")) { |
| 704 | h = toks[i].n; |
| 705 | mi = 0; |
| 706 | used[i] = true; |
| 707 | used[nx] = true; |
| 708 | hasTime = true; |
| 709 | var mn = after(nx); |
| 710 | if (mn >= 0 && toks[mn].t === "num" && (after(mn) < 0 || isWord(after(mn), "minute"))) { |
| 711 | mi = toks[mn].n; |
| 712 | used[mn] = true; |
| 713 | if (after(mn) >= 0) used[after(mn)] = true; |
| 714 | } |
| 715 | } else if (isWord(pv, "at") || isWord(nx, "at")) { |
| 716 | // The particle sits on either side of the hour: English |
| 717 | // and Spanish put it in front ("at 6", "a las 6") and |
| 718 | // Hindi, Bengali and Japanese put it behind (6 बजे, ৬ টায়, |
| 719 | // 6時に). Reading only the front one refused half the |
| 720 | // world's ordinary way of naming a time. |
| 721 | h = toks[i].n; |
| 722 | mi = 0; |
| 723 | used[i] = true; |
| 724 | used[isWord(pv, "at") ? pv : nx] = true; |
| 725 | hasTime = true; |
| 726 | } |
| 727 | } |
| 728 | } |
| 729 | if (hasTime) { |
| 730 | var pm = find("pm"), am = find("am"); |
| 731 | // A half-of-the-day marker says the clock beside it is a |
| 732 | // 12-hour one, and a 12-hour clock has twelve hours on it. |
| 733 | // The fold below is modular, so without this line every |
| 734 | // impossible hour came back as a plausible one: "25pm" was |
| 735 | // 13:00 and "99am" was 03:00 — a guess dressed as a reading, |
| 736 | // and the leftover check could not see it because every token |
| 737 | // had been claimed. |
| 738 | // |
| 739 | // Zero is one of the twelve. Japanese and Chinese write |
| 740 | // midnight and noon as 午前0時 and 午後0時, 上午0点 and 下午0点 — |
| 741 | // ordinary usage, not a typo — and the modular fold already |
| 742 | // says the right thing about them: 0 with a morning marker is |
| 743 | // midnight, 0 with an afternoon one is noon, exactly as 12 |
| 744 | // reads the other way round. That also lets English "0am" |
| 745 | // through, which is coherent (it IS midnight) and much |
| 746 | // simpler than a rule that would have to know which |
| 747 | // languages say it. |
| 748 | if (pm >= 0 || am >= 0) { |
| 749 | if (h < 0 || h > 12) return null; |
| 750 | if (pm >= 0) { used[pm] = true; h = (h % 12) + 12; } |
| 751 | else { used[am] = true; h = h % 12; } |
| 752 | } |
| 753 | } |
| 754 | } |
| 755 | if (hasTime && (h > 23 || mi > 59)) return null; |
| 756 | } |
| 757 | |
| 758 | // The calendar. |
| 759 | if (!hasDate) { |
| 760 | var tod = find("today"), tom = find("tomorrow"), yes = find("yesterday"); |
| 761 | if (tom >= 0) { date = shiftDays(now, 1); used[tom] = true; hasDate = true; } |
| 762 | else if (yes >= 0) { date = shiftDays(now, -1); used[yes] = true; hasDate = true; } |
| 763 | else if (tod >= 0) { date = midnight(now); used[tod] = true; hasDate = true; } |
| 764 | } |
| 765 | if (!hasDate) { |
| 766 | var next = find("next"), last = find("last"), dup; |
| 767 | var cm = counterMonth(); |
| 768 | var wi = findRole("weekday"); |
| 769 | var mo = findRole("month"); |
| 770 | if (wi >= 0 && mo >= 0) { |
| 771 | if (wi === mo) { |
| 772 | // ONE spelling wearing both hats: Spanish "mar" is March and |
| 773 | // it is Tuesday, and the scanner merges same-length matches |
| 774 | // rather than picking for you. Twice in a row is this field's |
| 775 | // own writing — "mar, 3 mar 2026" is Tuesday and then March — |
| 776 | // so a second month-naming token settles it and the first is |
| 777 | // the decoration. Otherwise a day number beside it settles it |
| 778 | // ("25 mar" is a date and nothing else), and with no number |
| 779 | // to count, a weekday is what a person means. |
| 780 | for (dup = wi + 1; dup < toks.length; dup++) |
| 781 | if (!used[dup] && toks[dup].t === "word" && toks[dup].month >= 0) { mo = dup; break; } |
| 782 | if (mo !== wi) { used[wi] = true; wi = -1; } |
| 783 | else if (findKind("num") >= 0) wi = -1; |
| 784 | else mo = -1; |
| 785 | } else { |
| 786 | // A weekday sitting beside a month and a day is decoration, |
| 787 | // not the date: it is how this field writes its own value |
| 788 | // back ("Fri, 28 Aug 2026"), and the only way to retype a |
| 789 | // year in place is for the weekday beside it to be consumed |
| 790 | // rather than obeyed. Obeying it would move the date to the |
| 791 | // coming Friday and silently throw the typed year away. |
| 792 | used[wi] = true; |
| 793 | wi = -1; |
| 794 | } |
| 795 | } else if (wi >= 0 && cm >= 0) { |
| 796 | // Same rule where the months are numbered rather than named: |
| 797 | // 2026年12月25日(金) and 2026年12月25日周五 are a full date with |
| 798 | // the weekday written beside it, and the weekday is the part |
| 799 | // that follows from the rest. |
| 800 | used[wi] = true; |
| 801 | wi = -1; |
| 802 | } |
| 803 | if (wi >= 0) { |
| 804 | used[wi] = true; |
| 805 | if (last >= 0) { used[last] = true; date = lastWeekday(now, toks[wi].weekday); } |
| 806 | else { |
| 807 | if (next >= 0) used[next] = true; |
| 808 | date = comingWeekday(now, toks[wi].weekday); |
| 809 | } |
| 810 | hasDate = true; |
| 811 | } else { |
| 812 | if (mo >= 0) { |
| 813 | used[mo] = true; |
| 814 | var dnum = -1, ynum = -1, dat = -1, k; |
| 815 | for (k = 0; k < toks.length; k++) { |
| 816 | if (used[k] || toks[k].t !== "num") continue; |
| 817 | if (toks[k].digits === 4) { |
| 818 | if (ynum >= 0) break; |
| 819 | ynum = toks[k].n; |
| 820 | used[k] = true; |
| 821 | } else if (dnum < 0) { |
| 822 | dnum = toks[k].n; |
| 823 | dat = k; |
| 824 | used[k] = true; |
| 825 | } else if (ynum < 0) { |
| 826 | ynum = toks[k].n < 100 ? 2000 + toks[k].n : toks[k].n; |
| 827 | used[k] = true; |
| 828 | } else break; |
| 829 | } |
| 830 | if (dnum < 0) return null; |
| 831 | claimCounter(dat, "day"); |
| 832 | date = makeDate(ynum >= 0 ? ynum : inferYear(now, toks[mo].month, dnum), |
| 833 | toks[mo].month, dnum, 0, 0); |
| 834 | if (!date) return null; |
| 835 | hasDate = true; |
| 836 | } else if (cm >= 0) { |
| 837 | // A calendar that numbers its months writes the number with |
| 838 | // its counter, and the counter is doing the work a name does |
| 839 | // everywhere else. The day beside it is written the same way, |
| 840 | // with or without its own counter. |
| 841 | var cmon = toks[cm].n - 1, cday = -1, cyear = -1, ck; |
| 842 | used[cm] = true; |
| 843 | used[cm + 1] = true; |
| 844 | ck = after(cm + 1); |
| 845 | if (ck >= 0 && toks[ck].t === "num" && toks[ck].digits <= 2) { |
| 846 | cday = toks[ck].n; |
| 847 | used[ck] = true; |
| 848 | claimCounter(ck, "day"); |
| 849 | } |
| 850 | if (cday < 0) return null; |
| 851 | // The year goes in front here (2026年12月25日), so it is |
| 852 | // whatever four digits are still unspoken for. Without this |
| 853 | // the field could write a full date it could not read back. |
| 854 | for (ck = 0; ck < toks.length && cyear < 0; ck++) { |
| 855 | if (used[ck] || toks[ck].t !== "num" || toks[ck].digits !== 4) continue; |
| 856 | cyear = toks[ck].n; |
| 857 | used[ck] = true; |
| 858 | } |
| 859 | date = makeDate(cyear >= 0 ? cyear : inferYear(now, cmon, cday), cmon, cday, 0, 0); |
| 860 | if (!date) return null; |
| 861 | hasDate = true; |
| 862 | } else if (next >= 0 || last >= 0) { |
| 863 | var u2 = unit(); |
| 864 | if (u2 && (u2.name === "day" || u2.name === "week" || u2.name === "month")) { |
| 865 | used[u2.at] = true; |
| 866 | var dir = last >= 0 ? -1 : 1; |
| 867 | used[last >= 0 ? last : next] = true; |
| 868 | date = midnight(now); |
| 869 | if (u2.name === "day") date.setDate(date.getDate() + dir); |
| 870 | else if (u2.name === "week") date.setDate(date.getDate() + 7 * dir); |
| 871 | else date.setMonth(date.getMonth() + dir); |
| 872 | hasDate = true; |
| 873 | } |
| 874 | } |
| 875 | } |
| 876 | } |
| 877 | if (!hasDate) { |
| 878 | // Four digits on their own move the year and nothing else — the |
| 879 | // same bargain a lone time strikes with the date. Focus selects |
| 880 | // the whole value, so "2030" is not a fragment someone left |
| 881 | // behind: it is the entire gesture of changing a year. |
| 882 | var only = -1, count = 0; |
| 883 | for (i = 0; i < toks.length; i++) { |
| 884 | if (used[i] || toks[i].t !== "num") continue; |
| 885 | count++; |
| 886 | if (toks[i].digits === 4) only = i; |
| 887 | } |
| 888 | // Four digits, and a year makeDate's floor will accept — checked |
| 889 | // here too so a refused year never claims its token and never |
| 890 | // reads as a date at all. |
| 891 | if (count === 1 && only >= 0 && toks[only].n >= 1000) { |
| 892 | used[only] = true; |
| 893 | date = new Date(own.getFullYear(), own.getMonth(), own.getDate()); |
| 894 | date.setFullYear(toks[only].n); |
| 895 | hasDate = true; |
| 896 | } |
| 897 | } |
| 898 | |
| 899 | // Anything left over is a word this reading did not account for, |
| 900 | // and a reading that ignores half the sentence is a guess. Two |
| 901 | // exceptions: the "at" particle, which is optional everywhere it |
| 902 | // appears and required nowhere; and a literal the display itself |
| 903 | // writes, which nobody typed on purpose and which carries no |
| 904 | // meaning to drop. |
| 905 | for (i = 0; i < toks.length; i++) { |
| 906 | if (used[i]) continue; |
| 907 | if (toks[i].t !== "word") return null; |
| 908 | if (toks[i].month >= 0 || toks[i].weekday >= 0) return null; |
| 909 | for (var kind in toks[i].kinds) if (kind !== "at" && kind !== "ignore") return null; |
| 910 | } |
| 911 | if (!hasDate && !hasTime) return null; |
| 912 | |
| 913 | var on = hasDate ? date : anchor; |
| 914 | var out = makeDate(on.getFullYear(), on.getMonth(), on.getDate(), |
| 915 | hasTime ? h : clockH, hasTime ? mi : clockM); |
| 916 | return out ? { date: out, hasDate: hasDate, hasTime: hasTime } : null; |
| 917 | } |
| 918 | |
| 919 | /* ── the page ──────────────────────────────────────────────────── |
| 920 | Everything below is behind the document guard, so Node loads the |
| 921 | parser above and nothing else. */ |
| 922 | |
| 923 | if (typeof document !== "undefined") { |
| 924 | |
| 925 | var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| 926 | 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + |
| 927 | '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'; |
| 928 | |
| 929 | var seq = 0; |
| 930 | |
| 931 | function toWire(kind, d) { |
| 932 | var day = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()); |
| 933 | var clock = pad(d.getHours()) + ":" + pad(d.getMinutes()); |
| 934 | if (kind === "date") return day; |
| 935 | if (kind === "time") return clock; |
| 936 | return day + "T" + clock; |
| 937 | } |
| 938 | |
| 939 | function fromWire(kind, value, now) { |
| 940 | var s = String(value || ""), m; |
| 941 | if (kind === "time") { |
| 942 | m = /^(\d{2}):(\d{2})/.exec(s); |
| 943 | return m ? new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]) : null; |
| 944 | } |
| 945 | m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(s); |
| 946 | if (!m) return null; |
| 947 | return new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); |
| 948 | } |
| 949 | |
| 950 | // displayOptions is up in the pure half, because the parser reads |
| 951 | // these back: one set of options, formatted here and derived there. |
| 952 | function formatter(locale, kind) { |
| 953 | try { |
| 954 | return new Intl.DateTimeFormat(locale, displayOptions(kind)); |
| 955 | } catch (e) { |
| 956 | return null; |
| 957 | } |
| 958 | } |
| 959 | |
| 960 | // Does this browser open a picker for this input? showPicker cannot |
| 961 | // be asked: HTML has it RETURN silently — no error, no event — |
| 962 | // where the browser has no picker for the type, which is exactly |
| 963 | // how a button wired to it ends up looking like a control and doing |
| 964 | // nothing. So the presence of the method is the only signal a page |
| 965 | // gets, and every field that has it gets the button. |
| 966 | // |
| 967 | // Recorded rather than guarded, because it will be rediscovered |
| 968 | // otherwise: measured on Firefox 152 (macOS), a date and a |
| 969 | // datetime-local both open the panel and a TIME opens nothing. The |
| 970 | // button is offered there anyway, on the same reasoning that lets a |
| 971 | // time field share the calendar's label — one affordance, in one |
| 972 | // place, on every date field. If a dead clock is reported, this |
| 973 | // paragraph is where the fix starts. |
| 974 | function canPick(el) { return !!el.showPicker; } |
| 975 | |
| 976 | function attr(el, name, fallback) { |
| 977 | var v = el.getAttribute(name); |
| 978 | return v === null || v === "" ? fallback : v; |
| 979 | } |
| 980 | |
| 981 | function enhance(native) { |
| 982 | if (native.dataset.rstEnhanced) return null; // idempotent: safe to re-scan |
| 983 | native.dataset.rstEnhanced = "true"; |
| 984 | |
| 985 | var kind = native.type === "time" ? "time" : (native.type === "date" ? "date" : "datetime"); |
| 986 | var locale = document.documentElement.getAttribute("lang") || undefined; |
| 987 | var tbl = tables(locale); |
| 988 | var fmt = formatter(locale, kind); |
| 989 | var vocab = {}; |
| 990 | try { |
| 991 | vocab = JSON.parse(attr(native, "data-rst-date-words", "{}")) || {}; |
| 992 | } catch (e) { |
| 993 | vocab = {}; |
| 994 | } |
| 995 | |
| 996 | var id = native.id || "rst-dtp-" + ++seq; |
| 997 | var listId = id + "-listbox"; |
| 998 | var setLabel = attr(native, "data-rst-date-set", "Set"); |
| 999 | var hintText = attr(native, "data-rst-date-hint", ""); |
| 1000 | var pickLabel = attr(native, "data-rst-date-pick", "Open the calendar"); |
| 1001 | var manyFmt = attr(native, "data-rst-date-results", "{n} suggestions"); |
| 1002 | var oneFmt = attr(native, "data-rst-date-result-one", "1 suggestion"); |
| 1003 | var quick = { |
| 1004 | today: attr(native, "data-rst-date-quick-today", "Today"), |
| 1005 | tomorrow: attr(native, "data-rst-date-quick-tomorrow", "Tomorrow"), |
| 1006 | nextWeek: attr(native, "data-rst-date-quick-next-week", "In a week"), |
| 1007 | plus1h: attr(native, "data-rst-date-quick-plus-1h", "An hour later"), |
| 1008 | plus2h: attr(native, "data-rst-date-quick-plus-2h", "Two hours later"), |
| 1009 | endOfDay: attr(native, "data-rst-date-quick-end-of-day", "End of that day"), |
| 1010 | nextDay: attr(native, "data-rst-date-quick-next-day", "Same time next day") |
| 1011 | }; |
| 1012 | |
| 1013 | var wrap = document.createElement("div"); |
| 1014 | wrap.setAttribute("rst-dtp", ""); |
| 1015 | |
| 1016 | var input = document.createElement("input"); |
| 1017 | input.type = "text"; |
| 1018 | input.setAttribute("rst-input", ""); |
| 1019 | input.setAttribute("rst-dtp-input", ""); |
| 1020 | input.id = id + "-combo"; |
| 1021 | input.autocomplete = "off"; |
| 1022 | input.spellcheck = false; |
| 1023 | input.setAttribute("role", "combobox"); |
| 1024 | input.setAttribute("aria-expanded", "false"); |
| 1025 | input.setAttribute("aria-controls", listId); |
| 1026 | input.setAttribute("aria-autocomplete", "list"); |
| 1027 | |
| 1028 | var list = document.createElement("ul"); |
| 1029 | list.setAttribute("rst-dtp-list", ""); |
| 1030 | list.id = listId; |
| 1031 | list.hidden = true; |
| 1032 | list.setAttribute("role", "listbox"); |
| 1033 | |
| 1034 | var status = document.createElement("p"); |
| 1035 | status.className = "rst-sr-only"; |
| 1036 | status.setAttribute("role", "status"); |
| 1037 | status.setAttribute("aria-live", "polite"); |
| 1038 | |
| 1039 | native.parentNode.insertBefore(wrap, native); |
| 1040 | wrap.appendChild(input); |
| 1041 | wrap.appendChild(list); |
| 1042 | wrap.appendChild(status); |
| 1043 | wrap.appendChild(native); |
| 1044 | |
| 1045 | // The label named the native input; it now names the control the |
| 1046 | // user actually types into. Without this the combobox has no |
| 1047 | // accessible name at all. |
| 1048 | var label = native.id ? document.querySelector('label[for="' + native.id + '"]') : null; |
| 1049 | if (label) label.setAttribute("for", input.id); |
| 1050 | else if (native.getAttribute("aria-label")) input.setAttribute("aria-label", native.getAttribute("aria-label")); |
| 1051 | if (native.getAttribute("aria-describedby")) |
| 1052 | input.setAttribute("aria-describedby", native.getAttribute("aria-describedby")); |
| 1053 | |
| 1054 | // Hidden, never removed: it is what the form submits. |
| 1055 | native.removeAttribute("rst-input"); |
| 1056 | native.classList.add("rst-sr-only"); |
| 1057 | native.setAttribute("tabindex", "-1"); |
| 1058 | native.setAttribute("aria-hidden", "true"); |
| 1059 | |
| 1060 | var pick = null; |
| 1061 | if (canPick(native)) { |
| 1062 | pick = document.createElement("button"); |
| 1063 | pick.type = "button"; |
| 1064 | pick.setAttribute("rst-dtp-pick", ""); |
| 1065 | pick.setAttribute("aria-label", pickLabel); |
| 1066 | pick.innerHTML = CAL_SVG; |
| 1067 | wrap.insertBefore(pick, list); |
| 1068 | } |
| 1069 | |
| 1070 | var rows = []; // [{li, date, hasTime}] — selectable rows only |
| 1071 | var active = -1; |
| 1072 | var baseOf = null; // range end: () => Date|null, the start's value |
| 1073 | var onCommit = null; |
| 1074 | var isEnd = false; |
| 1075 | |
| 1076 | function current() { return fromWire(kind, native.value, new Date()); } |
| 1077 | function show(d) { return fmt ? fmt.format(d) : toWire(kind, d); } |
| 1078 | function paint() { |
| 1079 | var d = current(); |
| 1080 | input.value = d ? show(d) : ""; |
| 1081 | } |
| 1082 | |
| 1083 | function setActive(i) { |
| 1084 | active = i; |
| 1085 | for (var k = 0; k < rows.length; k++) rows[k].li.classList.toggle("is-active", k === i); |
| 1086 | if (i >= 0) input.setAttribute("aria-activedescendant", rows[i].li.id); |
| 1087 | else input.removeAttribute("aria-activedescendant"); |
| 1088 | } |
| 1089 | |
| 1090 | // extra is the row's variant ("set") or a second attribute |
| 1091 | // ("rst-dtp-quick"): the vocabulary spells a variant in the |
| 1092 | // value slot and a kind as an attribute of its own, so one |
| 1093 | // parameter cannot be both, and the caller says which. |
| 1094 | function addRow(label, meta, date, hasTime, variant, extra) { |
| 1095 | var li = document.createElement("li"); |
| 1096 | li.setAttribute("rst-dtp-row", variant || ""); |
| 1097 | if (extra) li.setAttribute(extra, ""); |
| 1098 | li.id = listId + "-" + rows.length; |
| 1099 | li.setAttribute("role", "option"); |
| 1100 | // The row that IS the committed value says so in the |
| 1101 | // accessibility tree, which is what aria-selected means on a |
| 1102 | // listbox option — the highlight below is a separate thing, |
| 1103 | // moved by the arrow keys. |
| 1104 | li.setAttribute("aria-selected", toWire(kind, date) === native.value ? "true" : "false"); |
| 1105 | var main = document.createElement("span"); |
| 1106 | main.setAttribute("rst-dtp-label", ""); |
| 1107 | main.textContent = label; |
| 1108 | li.appendChild(main); |
| 1109 | if (meta) { |
| 1110 | var m = document.createElement("span"); |
| 1111 | m.setAttribute("rst-dtp-set", ""); |
| 1112 | m.textContent = meta; |
| 1113 | li.appendChild(m); |
| 1114 | } |
| 1115 | li.addEventListener("mousedown", function (e) { |
| 1116 | e.preventDefault(); // keep focus; blur would undo the pick |
| 1117 | commit(date, hasTime); |
| 1118 | }); |
| 1119 | list.appendChild(li); |
| 1120 | rows.push({ li: li, date: date, hasTime: hasTime }); |
| 1121 | } |
| 1122 | |
| 1123 | function picks(now) { |
| 1124 | var start = baseOf ? baseOf() : null; |
| 1125 | var out = [], at; |
| 1126 | if (isEnd && start && kind !== "date") { |
| 1127 | return [ |
| 1128 | { label: quick.plus1h, date: new Date(start.getTime() + 3600000) }, |
| 1129 | { label: quick.plus2h, date: new Date(start.getTime() + 7200000) }, |
| 1130 | { label: quick.endOfDay, date: new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59) }, |
| 1131 | { label: quick.nextDay, date: new Date(start.getTime() + 86400000) } |
| 1132 | ]; |
| 1133 | } |
| 1134 | if (kind === "time") { |
| 1135 | var from = current() || now; |
| 1136 | return [ |
| 1137 | { label: quick.plus1h, date: new Date(from.getTime() + 3600000) }, |
| 1138 | { label: quick.plus2h, date: new Date(from.getTime() + 7200000) }, |
| 1139 | { label: quick.endOfDay, date: new Date(from.getFullYear(), from.getMonth(), from.getDate(), 23, 59) } |
| 1140 | ]; |
| 1141 | } |
| 1142 | at = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 9, 0); }; |
| 1143 | out.push({ label: quick.today, date: at(now) }); |
| 1144 | out.push({ label: quick.tomorrow, date: at(shiftDays(now, 1)) }); |
| 1145 | out.push({ label: quick.nextWeek, date: at(shiftDays(now, 7)) }); |
| 1146 | return out; |
| 1147 | } |
| 1148 | |
| 1149 | function ctx() { |
| 1150 | return { prev: current(), base: baseOf ? baseOf() : null }; |
| 1151 | } |
| 1152 | |
| 1153 | function rebuild(query) { |
| 1154 | var now = new Date(); |
| 1155 | list.textContent = ""; |
| 1156 | rows = []; |
| 1157 | var text = String(query || "").trim(); |
| 1158 | var read = text ? parse(text, vocab, tbl, now, ctx()) : null; |
| 1159 | if (read) addRow(show(read.date), setLabel, read.date, read.hasTime, "set"); |
| 1160 | var quickPicks = picks(now), i, skip = read ? toWire(kind, read.date) : ""; |
| 1161 | for (i = 0; i < quickPicks.length; i++) { |
| 1162 | if (toWire(kind, quickPicks[i].date) === skip) continue; |
| 1163 | addRow(quickPicks[i].label, show(quickPicks[i].date), quickPicks[i].date, kind !== "date", "", "rst-dtp-quick"); |
| 1164 | } |
| 1165 | if (hintText) { |
| 1166 | var hint = document.createElement("li"); |
| 1167 | hint.setAttribute("rst-dtp-hint", ""); |
| 1168 | hint.setAttribute("aria-hidden", "true"); |
| 1169 | hint.textContent = hintText.replace("{example}", show(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0))); |
| 1170 | list.appendChild(hint); |
| 1171 | } |
| 1172 | status.textContent = rows.length === 1 ? oneFmt : manyFmt.replace("{n}", String(rows.length)); |
| 1173 | // Text that failed to parse leaves NOTHING armed: a quick pick |
| 1174 | // sitting under the cursor would make Enter on a date the |
| 1175 | // grammar did not understand commit today instead of refusing. |
| 1176 | setActive(rows.length && (read || !text) ? 0 : -1); |
| 1177 | } |
| 1178 | |
| 1179 | function open() { |
| 1180 | list.hidden = false; |
| 1181 | input.setAttribute("aria-expanded", "true"); |
| 1182 | rebuild(input.value); |
| 1183 | } |
| 1184 | |
| 1185 | function close() { |
| 1186 | list.hidden = true; |
| 1187 | input.setAttribute("aria-expanded", "false"); |
| 1188 | input.removeAttribute("aria-activedescendant"); |
| 1189 | active = -1; |
| 1190 | } |
| 1191 | |
| 1192 | function commit(date, hasTime) { |
| 1193 | native.value = toWire(kind, date); |
| 1194 | native.dispatchEvent(new Event("change", { bubbles: true })); |
| 1195 | input.value = show(date); |
| 1196 | close(); |
| 1197 | if (onCommit) onCommit(date, hasTime); |
| 1198 | } |
| 1199 | |
| 1200 | function commitText() { |
| 1201 | var text = input.value.trim(); |
| 1202 | if (!text) { |
| 1203 | // Cleared on purpose: an emptied field empties the carrier, |
| 1204 | // and a required native then fails validation the way it |
| 1205 | // would with no script at all. |
| 1206 | input.value = ""; |
| 1207 | if (native.value) { |
| 1208 | native.value = ""; |
| 1209 | native.dispatchEvent(new Event("change", { bubbles: true })); |
| 1210 | } |
| 1211 | return; |
| 1212 | } |
| 1213 | var read = parse(text, vocab, tbl, new Date(), ctx()); |
| 1214 | if (read) commit(read.date, read.hasTime); |
| 1215 | else paint(); // unreadable text is not a value: put the old one back |
| 1216 | } |
| 1217 | |
| 1218 | input.addEventListener("focus", function () { |
| 1219 | input.select(); |
| 1220 | open(); |
| 1221 | }); |
| 1222 | input.addEventListener("click", open); |
| 1223 | input.addEventListener("input", function () { open(); }); |
| 1224 | input.addEventListener("keydown", function (e) { |
| 1225 | switch (e.key) { |
| 1226 | case "ArrowDown": |
| 1227 | e.preventDefault(); |
| 1228 | if (list.hidden) return open(); |
| 1229 | if (rows.length) setActive(Math.min(active + 1, rows.length - 1)); |
| 1230 | break; |
| 1231 | case "ArrowUp": |
| 1232 | e.preventDefault(); |
| 1233 | if (list.hidden) return open(); |
| 1234 | if (rows.length) setActive(Math.max(active - 1, 0)); |
| 1235 | break; |
| 1236 | case "Enter": |
| 1237 | e.preventDefault(); |
| 1238 | if (!list.hidden && active >= 0) commit(rows[active].date, rows[active].hasTime); |
| 1239 | else { commitText(); close(); } |
| 1240 | break; |
| 1241 | case "Escape": |
| 1242 | e.preventDefault(); |
| 1243 | paint(); |
| 1244 | close(); |
| 1245 | break; |
| 1246 | } |
| 1247 | }); |
| 1248 | |
| 1249 | // Enter belongs to the combobox, never to the form: a commit that |
| 1250 | // also submits the page throws the commit away and posts a value |
| 1251 | // nobody finished choosing. The keydown handler above already |
| 1252 | // calls preventDefault, which normally stops this event being |
| 1253 | // generated at all — it is stopped here as well because a |
| 1254 | // SYNTHETIC key injection (CDP, and every browser drive built on |
| 1255 | // it) delivers keydown and keypress as two independent events, |
| 1256 | // so the second one arrives with the first one's refusal |
| 1257 | // discarded. Measured, not guessed: a keypress listener on the |
| 1258 | // page reports defaultPrevented=false for exactly that Enter. |
| 1259 | input.addEventListener("keypress", function (e) { |
| 1260 | if (e.key === "Enter") e.preventDefault(); |
| 1261 | }); |
| 1262 | |
| 1263 | wrap.addEventListener("focusout", function (e) { |
| 1264 | if (wrap.contains(e.relatedTarget)) return; |
| 1265 | commitText(); |
| 1266 | close(); |
| 1267 | }); |
| 1268 | |
| 1269 | if (pick) { |
| 1270 | pick.addEventListener("click", function () { |
| 1271 | close(); |
| 1272 | try { |
| 1273 | native.showPicker(); |
| 1274 | } catch (err) { |
| 1275 | /* no user gesture, or no picker — nothing to break */ |
| 1276 | } |
| 1277 | }); |
| 1278 | } |
| 1279 | // A pick made in the native panel lands in the carrier; repaint. |
| 1280 | native.addEventListener("change", function () { |
| 1281 | if (document.activeElement !== input) paint(); |
| 1282 | }); |
| 1283 | |
| 1284 | paint(); |
| 1285 | |
| 1286 | return { |
| 1287 | get value() { return current(); }, |
| 1288 | commit: commit, |
| 1289 | follow: function (fn) { baseOf = fn; isEnd = true; }, |
| 1290 | committed: function (fn) { onCommit = fn; } |
| 1291 | }; |
| 1292 | } |
| 1293 | |
| 1294 | // Two armed inputs inside one [data-rst-range] are a range: first |
| 1295 | // is the start, second the end, by DOM order. The end's quick picks |
| 1296 | // are relative to the start's current value, and a time typed into |
| 1297 | // the end with no date lands on the start's day. |
| 1298 | function wireRange(wrapper, start, end) { |
| 1299 | end.follow(function () { return start.value; }); |
| 1300 | if (wrapper.getAttribute("data-rst-range") !== "session") return; |
| 1301 | // "session" ranges (a talk, a meeting) seed an empty or backwards |
| 1302 | // end to an hour later. Server-side nothing is seeded, so a |
| 1303 | // submission with scripts off is exactly what was typed. |
| 1304 | start.committed(function (d) { |
| 1305 | var have = end.value; |
| 1306 | if (!have || have <= d) end.commit(new Date(d.getTime() + 3600000), true); |
| 1307 | }); |
| 1308 | } |
| 1309 | |
| 1310 | function scanPage(root) { |
| 1311 | var armed = root.querySelectorAll("input[data-rst-date], input[data-rst-time]"); |
| 1312 | var made = [], i, k, wrappers, inside, pair; |
| 1313 | for (i = 0; i < armed.length; i++) made.push([armed[i], enhance(armed[i])]); |
| 1314 | wrappers = root.querySelectorAll("[data-rst-range]"); |
| 1315 | for (i = 0; i < wrappers.length; i++) { |
| 1316 | inside = wrappers[i].querySelectorAll("input[data-rst-date], input[data-rst-time]"); |
| 1317 | pair = []; |
| 1318 | for (k = 0; k < inside.length && pair.length < 2; k++) { |
| 1319 | for (var j = 0; j < made.length; j++) |
| 1320 | if (made[j][0] === inside[k] && made[j][1]) pair.push(made[j][1]); |
| 1321 | } |
| 1322 | if (pair.length === 2) wireRange(wrappers[i], pair[0], pair[1]); |
| 1323 | } |
| 1324 | } |
| 1325 | |
| 1326 | if (document.readyState === "loading") { |
| 1327 | document.addEventListener("DOMContentLoaded", function () { scanPage(document); }); |
| 1328 | } else { |
| 1329 | scanPage(document); |
| 1330 | } |
| 1331 | } |
| 1332 | |
| 1333 | // The pure half, for ui/datetime_node.mjs. A browser has no `module` |
| 1334 | // and never takes this branch. |
| 1335 | if (typeof module !== "undefined" && module && module.exports) { |
| 1336 | module.exports = { parse: parse, tables: tables, displayOptions: displayOptions }; |
| 1337 | } |
| 1338 | })(); |
| 1339 | |