| 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 (the table's |
| 22 | date-words, i18n/<lang>.json) |
| 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 calendar button |
| 28 | data-rst-date-pick-time label for a TIME field's button, which |
| 29 | opens a clock rather than a calendar |
| 30 | data-rst-date-calendar the calendar overlay's own name |
| 31 | data-rst-date-prev-month label for the overlay's back chevron |
| 32 | data-rst-date-next-month label for the overlay's forward one |
| 33 | data-rst-date-results live-region text, {n} substituted |
| 34 | data-rst-date-result-one live-region text for exactly one |
| 35 | The seven quick-pick labels, one attribute each: |
| 36 | data-rst-date-quick-today, data-rst-date-quick-tomorrow, |
| 37 | data-rst-date-quick-next-week, data-rst-date-quick-plus-1h, |
| 38 | data-rst-date-quick-plus-2h, data-rst-date-quick-end-of-day, |
| 39 | data-rst-date-quick-next-day |
| 40 | data-rst-range on a WRAPPER around two armed inputs: |
| 41 | the two pair up by DOM order (first is |
| 42 | the start, second the end). The end |
| 43 | gets quick picks relative to the |
| 44 | start. A value of "session" also seeds |
| 45 | an empty end to start + 1h when the |
| 46 | start is committed. |
| 47 | |
| 48 | Every string arrives translated, and is looked up in three places, |
| 49 | in this order: the attribute on the input; the page's table, |
| 50 | self.fiestaStrings[lang], one object per language with these same |
| 51 | keys minus the data-rst- prefix, which i18n/<lang>.js fills and the |
| 52 | document's lang picks (fiestaWord, at the end of the file); then the |
| 53 | English fallback written here. So this file carries no English |
| 54 | VOCABULARY: not one word the parser matches on is written here. It |
| 55 | does carry English fallbacks for the labels it puts on screen, |
| 56 | exactly as select.js does, so the file still works standing alone |
| 57 | in a page that forgot an attribute and links no table. |
| 58 | |
| 59 | The parser is a pure function of (text, vocabulary, locale tables, |
| 60 | now). It knows no month names and no weekday names by heart: it asks |
| 61 | Intl for them, in the page's own language, and folds the digits the |
| 62 | same way, so a field enhanced in Hindi reads Devanagari digits and |
| 63 | Hindi month names without a table anyone has to maintain. The |
| 64 | vocabulary — "tomorrow", "next", "in", "ago" — is the half a machine |
| 65 | cannot derive, and that half arrives translated on the attribute. |
| 66 | |
| 67 | The pure half is exported for Node at the bottom of the file, behind |
| 68 | a guard a browser never takes; check/node/datetime_node.mjs drives |
| 69 | it against check/testdata/datetime/*.json. |
| 70 | |
| 71 | Two things live NEXT to the strict grammar rather than inside it. |
| 72 | parseAll offers a reading where the grammar refuses one, by finishing |
| 73 | a half-typed word into the names this locale actually has: "5j" is |
| 74 | not a date, and 5 January, 5 June and 5 July are three, nearest |
| 75 | first. It never overrules the grammar — an exact reading wins |
| 76 | outright — and every completion is shown with its date written out |
| 77 | before anyone commits it. |
| 78 | |
| 79 | And the calendar itself is calendar.js, a sibling file. This one |
| 80 | reads and writes dates as TEXT; that one draws six weeks of them. |
| 81 | The seam is window.fiestaCalendar, looked up at enhance time, so |
| 82 | an app that ships this file without that one still has a working |
| 83 | field — the button falls back to the browser's own picker, the same |
| 84 | way everything here falls back when an attribute is missing. */ |
| 85 | (function () { |
| 86 | "use strict"; |
| 87 | |
| 88 | /* ── the parser ────────────────────────────────────────────────── |
| 89 | Pure: no document, no state, no strings of its own. */ |
| 90 | |
| 91 | var COMBINING = /[\u0300-\u036f]/g; |
| 92 | |
| 93 | // Scripts that separate words with spaces. A phrase may only match |
| 94 | // between word boundaries in these, so "at" cannot be found inside |
| 95 | // "atlas" — while an affixing script (Japanese 後, Chinese 后) is |
| 96 | // deliberately left free to attach straight onto a quantity. Digits |
| 97 | // are excluded on purpose: "9am" and "25dec" are one run to a |
| 98 | // tokenizer and two words to a person. |
| 99 | var LETTERISH = /[a-z\u00c0-\u024f\u0370-\u03ff\u0400-\u04ff\u0530-\u058f\u0590-\u05ff\u0600-\u06ff\u0900-\u097f\u0980-\u09ff\u0b80-\u0bff\u0e00-\u0e7f]/; |
| 100 | // The punctuation that separates words, and not only the ASCII of |
| 101 | // it: Arabic writes its comma \u060c and its semicolon \u061b, and CJK |
| 102 | // writes \uff0c\u3001\u3002. Left out, those characters fall through to the |
| 103 | // unknown-run branch and refuse a phrase that was punctuated |
| 104 | // correctly in its own script. |
| 105 | var BREAKS = /[\s,;\u060c\u061b\uff0c\u3001\u3002]/; |
| 106 | |
| 107 | function pad(n) { return (n < 10 ? "0" : "") + n; } |
| 108 | |
| 109 | function midnight(d) { |
| 110 | return new Date(d.getFullYear(), d.getMonth(), d.getDate()); |
| 111 | } |
| 112 | |
| 113 | function shiftDays(d, n) { |
| 114 | var x = midnight(d); |
| 115 | x.setDate(x.getDate() + n); |
| 116 | return x; |
| 117 | } |
| 118 | |
| 119 | // Strictly the NEXT one: a weekday named on the day it falls means |
| 120 | // the one a week out, never today. Naming today has a word of its |
| 121 | // own in every catalog, so the ambiguity buys nothing. |
| 122 | function comingWeekday(now, target) { |
| 123 | var delta = (target - now.getDay() + 7) % 7; |
| 124 | return shiftDays(now, delta === 0 ? 7 : delta); |
| 125 | } |
| 126 | |
| 127 | function lastWeekday(now, target) { |
| 128 | var delta = (now.getDay() - target + 7) % 7; |
| 129 | return shiftDays(now, -(delta === 0 ? 7 : delta)); |
| 130 | } |
| 131 | |
| 132 | // A day-and-month with no year means the next time it comes round. |
| 133 | function inferYear(now, mo, d) { |
| 134 | var y = now.getFullYear(); |
| 135 | return new Date(y, mo, d) < midnight(now) ? y + 1 : y; |
| 136 | } |
| 137 | |
| 138 | // Checked, never trusted: a 31st of February rolls silently forward |
| 139 | // in the Date constructor, which is how a typo becomes a date nobody |
| 140 | // meant. Built, read back, and refused if it moved. |
| 141 | function makeDate(y, mo, d, h, mi) { |
| 142 | // The carrier's wire format is four digits of year, so a year below |
| 143 | // a thousand writes "500-12-25" — which every date input rejects by |
| 144 | // silently emptying itself. A reading that clears the field is not |
| 145 | // a reading, and the floor lives here rather than beside each |
| 146 | // caller so no path can grow around it: the wire formats, the |
| 147 | // numeric d/m/y, "25 dec 0500" and a bare year all pass through. |
| 148 | if (y < 1000) return null; |
| 149 | if (mo < 0 || mo > 11 || d < 1 || d > 31) return null; |
| 150 | if (h < 0 || h > 23 || mi < 0 || mi > 59) return null; |
| 151 | var dt = new Date(y, mo, d, h, mi, 0, 0); |
| 152 | if (dt.getMonth() !== mo || dt.getDate() !== d) return null; |
| 153 | return dt; |
| 154 | } |
| 155 | |
| 156 | // The numbering systems a person may type in, probed against the |
| 157 | // locale and unioned into the fold. The locale's default is not |
| 158 | // enough on its own: this ICU resolves plain "ar" and plain "hi" to |
| 159 | // latn, so those fields read "25" and refused ٢٥ and २५ — the digits |
| 160 | // their keyboards make. Unioning DIGITS is safe in a way that unioning |
| 161 | // words never is: a digit means the same number in every language, so |
| 162 | // an English field that also takes ٢٥ has guessed nothing. Systems |
| 163 | // that spell a number a word at a time are absent on purpose; they |
| 164 | // cannot be read back a character at a time either. |
| 165 | var NUMBERING = ["arab", "arabext", "beng", "deva"]; |
| 166 | |
| 167 | // One system's ten digits, merged in. False where it adds nothing, or |
| 168 | // cannot be folded at all: more than a character to a digit, a digit |
| 169 | // written twice, or a fold contradicting one already agreed. |
| 170 | function foldDigits(nf, map) { |
| 171 | var i, parts, k, digit, add = {}, differs = false; |
| 172 | for (i = 0; i <= 9; i++) { |
| 173 | parts = nf.formatToParts(i); |
| 174 | digit = ""; |
| 175 | for (k = 0; k < parts.length; k++) if (parts[k].type === "integer") digit = parts[k].value; |
| 176 | if (digit.length !== 1) return false; |
| 177 | if (add[digit] !== undefined) return false; |
| 178 | if (map[digit] !== undefined && map[digit] !== String(i)) return false; |
| 179 | if (digit !== String(i)) differs = true; |
| 180 | add[digit] = String(i); |
| 181 | } |
| 182 | for (digit in add) if (Object.prototype.hasOwnProperty.call(add, digit)) map[digit] = add[digit]; |
| 183 | return differs; |
| 184 | } |
| 185 | |
| 186 | // The ten digits this locale writes, mapped back to ASCII. ar-EG |
| 187 | // counts in "١٢٣", which neither \d nor unary + accepts, and a |
| 188 | // NumberFormat in that locale printing 0-9 IS the table. null where |
| 189 | // there is nothing to fold, and null for a numbering system that |
| 190 | // does not spell a number one digit at a time — it cannot be read |
| 191 | // back one digit at a time either. |
| 192 | function digitFolder(locale) { |
| 193 | var map = {}, differs = false, base, root, i; |
| 194 | try { |
| 195 | base = new Intl.NumberFormat(locale, { useGrouping: false }); |
| 196 | } catch (e) { |
| 197 | return null; |
| 198 | } |
| 199 | if (foldDigits(base, map)) differs = true; |
| 200 | // The tag the probes hang off, with any -u- extension the resolver |
| 201 | // already added trimmed off: a second one is an ill-formed tag. |
| 202 | try { |
| 203 | root = base.resolvedOptions().locale.split("-u-")[0]; |
| 204 | } catch (e) { |
| 205 | root = ""; |
| 206 | } |
| 207 | for (i = 0; root && i < NUMBERING.length; i++) { |
| 208 | try { |
| 209 | if (foldDigits(new Intl.NumberFormat(root + "-u-nu-" + NUMBERING[i], { useGrouping: false }), map)) differs = true; |
| 210 | } catch (e) { |
| 211 | /* a numbering system this engine does not know folds nothing */ |
| 212 | } |
| 213 | } |
| 214 | if (!differs) return null; |
| 215 | return function (s) { |
| 216 | var out = "", j; |
| 217 | for (j = 0; j < s.length; j++) out += map[s.charAt(j)] !== undefined ? map[s.charAt(j)] : s.charAt(j); |
| 218 | return out; |
| 219 | }; |
| 220 | } |
| 221 | |
| 222 | // One normalizer, used on both sides — on the vocabulary and the |
| 223 | // Intl names as the tables are built, and on the typed text as it is |
| 224 | // read — so a person who leaves the accents off still meets the |
| 225 | // words the catalog spells with them. |
| 226 | function makeFold(digits) { |
| 227 | return function (s) { |
| 228 | var out = String(s === null || s === undefined ? "" : s); |
| 229 | if (out.normalize) out = out.normalize("NFD").replace(COMBINING, ""); |
| 230 | out = out.toLowerCase(); |
| 231 | if (digits) out = digits(out); |
| 232 | return out.replace(/\s+/g, " ").trim(); |
| 233 | }; |
| 234 | } |
| 235 | |
| 236 | // What this locale calls each month (or weekday), long and short, |
| 237 | // index-aligned with the probe dates — which is what turns a matched |
| 238 | // word back into a number. Asked of Intl rather than tabulated, |
| 239 | // because a table per language is precisely the thing this file |
| 240 | // exists not to keep. |
| 241 | function nameTable(locale, type, dates, fold) { |
| 242 | var widths = ["long", "short"], out = [], i, c, w, opts, fmt, parts, k, value; |
| 243 | // Two shapes of every month name, because a month is not one word |
| 244 | // in every language: a Russian calendar header says декабрь and a |
| 245 | // Russian typing a date writes 25 декабря, and Intl only hands over |
| 246 | // that second form when a DAY is in the format. Asking both ways |
| 247 | // and keeping both answers is how a DERIVED table covers a case the |
| 248 | // grammar decides — no genitive is written down here, and Ukrainian |
| 249 | // and Greek are fixed by the same two lines. |
| 250 | var contexts = type === "month" ? [null, "numeric"] : [null]; |
| 251 | for (i = 0; i < dates.length; i++) out.push([]); |
| 252 | for (c = 0; c < contexts.length; c++) |
| 253 | for (w = 0; w < widths.length; w++) { |
| 254 | opts = { timeZone: "UTC" }; |
| 255 | opts[type] = widths[w]; |
| 256 | if (contexts[c]) opts.day = contexts[c]; |
| 257 | try { |
| 258 | fmt = new Intl.DateTimeFormat(locale, opts); |
| 259 | } catch (e) { |
| 260 | continue; |
| 261 | } |
| 262 | for (i = 0; i < dates.length; i++) { |
| 263 | parts = fmt.formatToParts(dates[i]); |
| 264 | value = ""; |
| 265 | for (k = 0; k < parts.length; k++) if (parts[k].type === type) value = parts[k].value; |
| 266 | value = fold(value); |
| 267 | if (!value) continue; |
| 268 | // A NAME has to be a word. Japanese and Chinese write their |
| 269 | // months as numbers, and Intl reports that as |
| 270 | // {month:"8"}+{literal:"\u6708"} — so the "name" of August comes |
| 271 | // back as the bare digit 8, and a table holding it turns every |
| 272 | // typed number into a month. It did: ja "2025" tokenized as |
| 273 | // month 2 plus 025 and committed 25 February 2027 without a |
| 274 | // word of warning, which is the one thing this parser promises |
| 275 | // never to do. Anything that is only digits and separators is |
| 276 | // refused here rather than trusted; a calendar whose months are |
| 277 | // numbers is one this grammar cannot read, and saying so is the |
| 278 | // honest answer. |
| 279 | if (!/[^\d\s.,\/\-]/.test(value)) continue; |
| 280 | if (out[i].indexOf(value) === -1) out[i].push(value); |
| 281 | // An abbreviation's full stop is dropped as often as it is |
| 282 | // typed ("16 Aug." retyped as "16 Aug"), so both spellings are |
| 283 | // in the table rather than tolerated by one regex somewhere. |
| 284 | if (value.charAt(value.length - 1) === "." && value.length > 1) { |
| 285 | value = value.slice(0, -1); |
| 286 | if (out[i].indexOf(value) === -1) out[i].push(value); |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | return out; |
| 291 | } |
| 292 | |
| 293 | // The options the field's own display formats with. Up here in the |
| 294 | // pure half rather than beside the page code that uses it, because |
| 295 | // the parser has to read that display back: what this field writes, |
| 296 | // it must be able to re-read, and a reading that cannot is how an |
| 297 | // in-place edit turns into a refusal. |
| 298 | function displayOptions(kind) { |
| 299 | var opts = kind === "time" |
| 300 | ? { hour: "numeric", minute: "2-digit" } |
| 301 | : { weekday: "short", day: "numeric", month: "short", year: "numeric" }; |
| 302 | if (kind === "datetime") { |
| 303 | opts.hour = "numeric"; |
| 304 | opts.minute = "2-digit"; |
| 305 | } |
| 306 | return opts; |
| 307 | } |
| 308 | |
| 309 | // What Intl writes that nobody typed: the literals it threads between |
| 310 | // the numbers (ja 年月日, ru's "г.", pt's "de", the brackets round a |
| 311 | // Japanese weekday) and the dayPeriod names it chooses, which are not |
| 312 | // always the ones a catalog spells — Intl writes yue's afternoon as |
| 313 | // 下晝 where the catalog says 下午. Derived from the same options the |
| 314 | // display uses, plus the day-and-month shapes, which is the form a |
| 315 | // person types ("25 de marzo"). Literals come back ignorable and |
| 316 | // dayPeriods come back as am/pm, so the field can read its own |
| 317 | // writing without a per-language table anybody maintains. |
| 318 | function displayTokens(locale, fold) { |
| 319 | var probes = [displayOptions("date"), displayOptions("datetime"), displayOptions("time"), |
| 320 | { day: "numeric", month: "long" }, { day: "numeric", month: "short" }]; |
| 321 | var when = [new Date(Date.UTC(2026, 11, 25, 15, 30)), new Date(Date.UTC(2026, 2, 3, 9, 5))]; |
| 322 | var out = { literal: {}, am: {}, pm: {} }, p, i, fmt, parts, k, v, c, run; |
| 323 | function keep(bag, s) { s = fold(s); if (s) bag[s] = true; } |
| 324 | for (p = 0; p < probes.length; p++) { |
| 325 | probes[p].timeZone = "UTC"; |
| 326 | try { |
| 327 | fmt = new Intl.DateTimeFormat(locale, probes[p]); |
| 328 | } catch (e) { |
| 329 | continue; |
| 330 | } |
| 331 | for (i = 0; i < when.length; i++) { |
| 332 | parts = fmt.formatToParts(when[i]); |
| 333 | for (k = 0; k < parts.length; k++) { |
| 334 | v = parts[k].value; |
| 335 | if (parts[k].type === "dayPeriod") { |
| 336 | keep(when[i].getUTCHours() < 12 ? out.am : out.pm, v); |
| 337 | continue; |
| 338 | } |
| 339 | if (parts[k].type !== "literal") continue; |
| 340 | // One literal is not always one token: ja hands back "日(" in |
| 341 | // a single part. Split into letter runs and single marks so |
| 342 | // the day counter stays a day counter and the bracket becomes |
| 343 | // its own ignorable thing. |
| 344 | for (c = 0, run = ""; c <= v.length; c++) { |
| 345 | if (c < v.length && letterish(v.charAt(c))) { run += v.charAt(c); continue; } |
| 346 | keep(out.literal, run); |
| 347 | run = ""; |
| 348 | if (c < v.length && !BREAKS.test(v.charAt(c))) keep(out.literal, v.charAt(c)); |
| 349 | } |
| 350 | } |
| 351 | } |
| 352 | } |
| 353 | return out; |
| 354 | } |
| 355 | |
| 356 | var TABLES = {}; |
| 357 | |
| 358 | // Everything derived from a locale, built once and cached: the fold, |
| 359 | // the month and weekday names, the wall-clock formatters the field |
| 360 | // shows its value with. |
| 361 | function tables(locale) { |
| 362 | var key = locale || ""; |
| 363 | if (TABLES[key]) return TABLES[key]; |
| 364 | var loc = locale || undefined; |
| 365 | var fold = makeFold(digitFolder(loc)); |
| 366 | var monthDates = [], weekDates = [], i; |
| 367 | for (i = 0; i < 12; i++) monthDates.push(new Date(Date.UTC(2026, i, 15))); |
| 368 | // 1 Feb 2026 is a Sunday, so the seven probes land index-aligned |
| 369 | // with Date.prototype.getDay. |
| 370 | for (i = 0; i < 7; i++) weekDates.push(new Date(Date.UTC(2026, 1, 1 + i))); |
| 371 | var t = { |
| 372 | locale: loc, |
| 373 | fold: fold, |
| 374 | months: nameTable(loc, "month", monthDates, fold), |
| 375 | weekdays: nameTable(loc, "weekday", weekDates, fold), |
| 376 | display: displayTokens(loc, fold) |
| 377 | }; |
| 378 | // Does this locale call its months anything at all? Japanese does |
| 379 | // not — it numbers them, and nameTable will not hold a bare digit |
| 380 | // as a name — so the table is empty and the grammar reads 12月25日 |
| 381 | // another way. Derived: no list of numbering calendars kept here. |
| 382 | t.namedMonths = false; |
| 383 | for (i = 0; i < t.months.length; i++) if (t.months[i].length) t.namedMonths = true; |
| 384 | TABLES[key] = t; |
| 385 | return t; |
| 386 | } |
| 387 | |
| 388 | // The vocabulary and the Intl names as one list, longest phrase |
| 389 | // first — so a plain scan cannot let "m" win over "may", and a |
| 390 | // multi-word entry ("hôm nay", "منتصف الليل", "seo chugainn") is |
| 391 | // matched whole. Splitting is on "|" only: "meio-dia" is one entry |
| 392 | // and stays one. |
| 393 | function index(vocab, tbl) { |
| 394 | var list = [], name, alts, i, m, k; |
| 395 | for (name in vocab) { |
| 396 | if (!Object.prototype.hasOwnProperty.call(vocab, name)) continue; |
| 397 | alts = String(vocab[name]).split("|"); |
| 398 | for (i = 0; i < alts.length; i++) { |
| 399 | var text = tbl.fold(alts[i]); |
| 400 | if (text) list.push({ text: text, role: "word", kind: name, at: -1 }); |
| 401 | } |
| 402 | } |
| 403 | // The display's own vocabulary, on the same list: a literal is a |
| 404 | // word the reading may skip, and a dayPeriod is am or pm however |
| 405 | // Intl chose to spell it. Both are additive — a spelling already |
| 406 | // carrying a kind keeps it and gains this one, because matchAt |
| 407 | // merges every entry of the same length. |
| 408 | var seen = ["literal", "am", "pm"], kind; |
| 409 | for (i = 0; i < seen.length; i++) { |
| 410 | kind = seen[i] === "literal" ? "ignore" : seen[i]; |
| 411 | for (name in tbl.display[seen[i]]) |
| 412 | if (Object.prototype.hasOwnProperty.call(tbl.display[seen[i]], name)) |
| 413 | list.push({ text: name, role: "word", kind: kind, at: -1 }); |
| 414 | } |
| 415 | for (m = 0; m < tbl.months.length; m++) |
| 416 | for (k = 0; k < tbl.months[m].length; k++) |
| 417 | list.push({ text: tbl.months[m][k], role: "month", kind: "", at: m }); |
| 418 | for (m = 0; m < tbl.weekdays.length; m++) |
| 419 | for (k = 0; k < tbl.weekdays[m].length; k++) |
| 420 | list.push({ text: tbl.weekdays[m][k], role: "weekday", kind: "", at: m }); |
| 421 | list.sort(function (a, b) { return b.text.length - a.text.length; }); |
| 422 | return list; |
| 423 | } |
| 424 | |
| 425 | // The index for this table, built once and kept on it. Cached |
| 426 | // against the vocabulary itself, not just the locale: a page could |
| 427 | // in principle carry two, and a stale index would parse the second |
| 428 | // field with the first field's words. |
| 429 | function indexFor(vocab, tbl) { |
| 430 | var key = "", name; |
| 431 | for (name in vocab) if (Object.prototype.hasOwnProperty.call(vocab, name)) key += name + "=" + vocab[name] + "\u0000"; |
| 432 | if (tbl.indexKey !== key) { |
| 433 | tbl.index = index(vocab, tbl); |
| 434 | tbl.indexKey = key; |
| 435 | } |
| 436 | return tbl.index; |
| 437 | } |
| 438 | |
| 439 | // A separator is never a letter, whatever block it lives in. The |
| 440 | // Arabic comma sits at \u060c, inside the same range as the letters |
| 441 | // either side of it, so a class drawn by range alone reads |
| 442 | // "\u063a\u062f\u0627\u060c 6" as one unbroken word and refuses a phrase that was |
| 443 | // punctuated correctly. |
| 444 | function letterish(c) { |
| 445 | return !!c && !BREAKS.test(c) && LETTERISH.test(c); |
| 446 | } |
| 447 | |
| 448 | function boundaryOK(s, from, to) { |
| 449 | if (from > 0 && letterish(s.charAt(from - 1)) && letterish(s.charAt(from))) return false; |
| 450 | if (to < s.length && letterish(s.charAt(to)) && letterish(s.charAt(to - 1))) return false; |
| 451 | return true; |
| 452 | } |
| 453 | |
| 454 | // The longest phrase starting here, as one token. Every entry of that |
| 455 | // same length is merged in, because one spelling can wear two hats: |
| 456 | // Vietnamese "sau" is both "next" and "in", and which one it means is |
| 457 | // a question the grammar answers, not the scanner. |
| 458 | function matchAt(s, pos, idx) { |
| 459 | var best = -1, tok = null, i, e; |
| 460 | for (i = 0; i < idx.length; i++) { |
| 461 | e = idx[i]; |
| 462 | if (best >= 0 && e.text.length < best) break; |
| 463 | if (s.slice(pos, pos + e.text.length) !== e.text) continue; |
| 464 | if (!boundaryOK(s, pos, pos + e.text.length)) continue; |
| 465 | if (best < 0) { |
| 466 | best = e.text.length; |
| 467 | tok = { t: "word", kinds: {}, month: -1, weekday: -1 }; |
| 468 | } |
| 469 | if (e.role === "word") tok.kinds[e.kind] = true; |
| 470 | else if (e.role === "month") tok.month = e.at; |
| 471 | else tok.weekday = e.at; |
| 472 | } |
| 473 | return tok ? { tok: tok, len: best } : null; |
| 474 | } |
| 475 | |
| 476 | function scan(s, idx) { |
| 477 | var toks = [], pos = 0, n = s.length, m, hit, start, glued = false; |
| 478 | // Whether nothing at all stood between this token and the one |
| 479 | // before it. A counter glued to its number belongs to that number |
| 480 | // (12月, 25日); the same word with a space in front of it is a noun |
| 481 | // being counted ("3 months"). A fact about the typing, not about |
| 482 | // any language, which is why it can be recorded without knowing |
| 483 | // which language was typed. |
| 484 | // |
| 485 | // Where the token was read is recorded alongside it, because |
| 486 | // completion below rewrites exactly one token and hands the |
| 487 | // sentence back to the grammar unchanged around it. A scanner that |
| 488 | // forgets where it read a word cannot offer to finish it. |
| 489 | function push(tok, from, to) { |
| 490 | tok.glued = glued; |
| 491 | tok.from = from; |
| 492 | tok.to = to; |
| 493 | toks.push(tok); |
| 494 | glued = true; |
| 495 | } |
| 496 | while (pos < n) { |
| 497 | if (BREAKS.test(s.charAt(pos))) { pos++; glued = false; continue; } |
| 498 | m = /^(\d{1,2}):(\d{2})/.exec(s.slice(pos)); |
| 499 | if (m) { |
| 500 | push({ t: "clock", h: +m[1], mi: +m[2] }, pos, pos + m[0].length); |
| 501 | pos += m[0].length; |
| 502 | continue; |
| 503 | } |
| 504 | // A numeric date with its year is one token, or pt-PT, which |
| 505 | // writes "sexta, 25/12/2026, 15:30", could not read itself back. |
| 506 | m = /^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})(?!\d)/.exec(s.slice(pos)); |
| 507 | if (m) { |
| 508 | push({ t: "ndate", d: +m[1], mo: +m[2], y: +m[3] }, pos, pos + m[0].length); |
| 509 | pos += m[0].length; |
| 510 | continue; |
| 511 | } |
| 512 | hit = matchAt(s, pos, idx); |
| 513 | if (hit) { push(hit.tok, pos, pos + hit.len); pos += hit.len; continue; } |
| 514 | m = /^\d+/.exec(s.slice(pos)); |
| 515 | if (m) { |
| 516 | push({ t: "num", n: +m[0], digits: m[0].length }, pos, pos + m[0].length); |
| 517 | pos += m[0].length; |
| 518 | continue; |
| 519 | } |
| 520 | start = pos; |
| 521 | while (pos < n && !BREAKS.test(s.charAt(pos)) && !/\d/.test(s.charAt(pos))) { |
| 522 | if (pos > start && matchAt(s, pos, idx)) break; |
| 523 | pos++; |
| 524 | } |
| 525 | if (pos === start) pos++; |
| 526 | push({ t: "other", raw: s.slice(start, pos) }, start, pos); |
| 527 | } |
| 528 | return toks; |
| 529 | } |
| 530 | |
| 531 | // parse(text, vocab, tbl, now, ctx) -> {date, hasDate, hasTime} or |
| 532 | // null. Never a guess: text this grammar does not understand comes |
| 533 | // back null, so nothing is armed and nothing is committed. |
| 534 | // |
| 535 | // ctx.prev is the value this field is already showing; ctx.base is |
| 536 | // the day a wired range's START is on. The two are not |
| 537 | // interchangeable. A phrase naming no date at all ("5pm") lands on |
| 538 | // the START's day — that is what makes a time-only end stay in the |
| 539 | // same session. A phrase naming PART of a date ("2030") keeps the day |
| 540 | // THIS field is showing, because the part it left out is the part it |
| 541 | // means to keep. |
| 542 | function parse(text, vocab, tbl, now, ctx) { |
| 543 | ctx = ctx || {}; |
| 544 | var raw = tbl.fold(text); |
| 545 | if (!raw) return null; |
| 546 | var idx = indexFor(vocab, tbl); |
| 547 | |
| 548 | var prev = ctx.prev || null; |
| 549 | var base = ctx.base || null; |
| 550 | var anchor = midnight(base || prev || now); |
| 551 | var own = midnight(prev || base || now); |
| 552 | var clockH = prev ? prev.getHours() : 9; |
| 553 | var clockM = prev ? prev.getMinutes() : 0; |
| 554 | |
| 555 | var m, d; |
| 556 | |
| 557 | // The wire formats, read straight back: what the native carrier |
| 558 | // holds, and what a person pastes out of one form into another. |
| 559 | m = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[t ](\d{1,2}):(\d{2})(?::\d{2})?)?$/.exec(raw); |
| 560 | if (m) { |
| 561 | d = makeDate(+m[1], +m[2] - 1, +m[3], |
| 562 | m[4] === undefined ? clockH : +m[4], |
| 563 | m[4] === undefined ? clockM : +m[5]); |
| 564 | return d ? { date: d, hasDate: true, hasTime: m[4] !== undefined } : null; |
| 565 | } |
| 566 | // Year first, with the separators a keyboard reaches for rather |
| 567 | // than the wire's hyphen. Nothing else in any calendar puts four |
| 568 | // digits in front of a date, so 2026/12/25 and 2026.12.25 need no |
| 569 | // day-or-month guess: it is how ja, zh and yue write one down. |
| 570 | m = /^(\d{4})[\/.](\d{1,2})[\/.](\d{1,2})$/.exec(raw); |
| 571 | if (m) { |
| 572 | d = makeDate(+m[1], +m[2] - 1, +m[3], clockH, clockM); |
| 573 | return d ? { date: d, hasDate: true, hasTime: false } : null; |
| 574 | } |
| 575 | // Numeric d/m[/y], day first — and swapped where only the other |
| 576 | // reading can be true, so a pasted 12/25/2027 is understood rather |
| 577 | // than refused. |
| 578 | function dmy(day, mon, year) { |
| 579 | if (mon > 12 && day <= 12) { var swap = day; day = mon; mon = swap; } |
| 580 | return makeDate(year === undefined ? inferYear(now, mon - 1, day) : year < 100 ? 2000 + year : year, |
| 581 | mon - 1, day, clockH, clockM); |
| 582 | } |
| 583 | m = /^(\d{1,2})[\/.\-](\d{1,2})(?:[\/.\-](\d{2}|\d{4}))?$/.exec(raw); |
| 584 | if (m) { |
| 585 | d = dmy(+m[1], +m[2], m[3] === undefined ? undefined : +m[3]); |
| 586 | return d ? { date: d, hasDate: true, hasTime: false } : null; |
| 587 | } |
| 588 | |
| 589 | var toks = scan(raw, idx), i; |
| 590 | if (!toks.length) return null; |
| 591 | for (i = 0; i < toks.length; i++) if (toks[i].t === "other") return null; |
| 592 | |
| 593 | var used = []; |
| 594 | function find(kind) { |
| 595 | for (var k = 0; k < toks.length; k++) |
| 596 | if (!used[k] && toks[k].t === "word" && toks[k].kinds[kind]) return k; |
| 597 | return -1; |
| 598 | } |
| 599 | function findRole(role) { |
| 600 | for (var k = 0; k < toks.length; k++) { |
| 601 | if (used[k] || toks[k].t !== "word" || toks[k][role] < 0) continue; |
| 602 | // A weekday name being counted is not a weekday: nobody writes |
| 603 | // a date by putting a number in front of one. A MONTH name is |
| 604 | // the opposite case and is left alone — "25dec" is how people |
| 605 | // type one. |
| 606 | if (role === "weekday" && k > 0 && toks[k].glued && isCounter(k, k - 1)) continue; |
| 607 | return k; |
| 608 | } |
| 609 | return -1; |
| 610 | } |
| 611 | function findKind(t) { |
| 612 | for (var k = 0; k < toks.length; k++) if (!used[k] && toks[k].t === t) return k; |
| 613 | return -1; |
| 614 | } |
| 615 | function after(k) { |
| 616 | return k + 1 < toks.length && !used[k + 1] ? k + 1 : -1; |
| 617 | } |
| 618 | function before(k) { |
| 619 | return k > 0 && !used[k - 1] ? k - 1 : -1; |
| 620 | } |
| 621 | function unit() { |
| 622 | var names = ["minute", "hour", "day", "week", "month"], k, at; |
| 623 | for (k = 0; k < names.length; k++) { |
| 624 | at = find(names[k]); |
| 625 | if (at >= 0) return { name: names[k], at: at }; |
| 626 | } |
| 627 | return null; |
| 628 | } |
| 629 | // Is the name at k being counted rather than named? A number in |
| 630 | // front of it says so, and so does a next/last particle written |
| 631 | // straight onto a word that is a unit as well as a name: 来月 is |
| 632 | // next MONTH and 翌日 is the next DAY, and both asked for a weekday |
| 633 | // until this line, because 月 and 日 are also how Japanese |
| 634 | // abbreviates Monday and Sunday. |
| 635 | function isCounter(k, p) { |
| 636 | if (toks[p].t === "num") return true; |
| 637 | if (toks[p].t !== "word") return false; |
| 638 | if (!toks[p].kinds.next && !toks[p].kinds.last) return false; |
| 639 | return !!(toks[k].kinds.day || toks[k].kinds.week || toks[k].kinds.month); |
| 640 | } |
| 641 | function isWord(k, kind) { |
| 642 | return k >= 0 && toks[k].t === "word" && !!toks[k].kinds[kind]; |
| 643 | } |
| 644 | // The counter glued to a number already claimed — the 日 of 25日, |
| 645 | // the 号 of 25号. It belongs to that number, and leaving it behind |
| 646 | // failed the leftover check on a date written correctly. Glued is |
| 647 | // the whole test: "3 days" has a space and stays a duration. |
| 648 | function claimCounter(at, kind) { |
| 649 | var nx = at >= 0 ? after(at) : -1; |
| 650 | if (nx < 0 || toks[nx].t !== "word" || !toks[nx].glued) return false; |
| 651 | if (!toks[nx].kinds[kind]) return false; |
| 652 | used[nx] = true; |
| 653 | return true; |
| 654 | } |
| 655 | // A number glued to the month counter where months have no names: |
| 656 | // the 12 of 12月25日. Gated on the empty table so no language that |
| 657 | // CALLS its months something reaches here — without it, English |
| 658 | // "3months" would read as March. |
| 659 | function counterMonth() { |
| 660 | if (tbl.namedMonths) return -1; |
| 661 | for (var k = 0; k < toks.length; k++) { |
| 662 | if (used[k] || toks[k].t !== "num" || toks[k].digits > 2) continue; |
| 663 | var nx = after(k); |
| 664 | if (nx >= 0 && toks[nx].t === "word" && toks[nx].glued && toks[nx].kinds.month) return k; |
| 665 | } |
| 666 | return -1; |
| 667 | } |
| 668 | |
| 669 | var date = null, hasDate = false, hasTime = false, h = 0, mi = 0; |
| 670 | |
| 671 | // A quantity is what tells "in"/"ago" apart from "next"/"last" |
| 672 | // where one spelling carries both — Vietnamese "sau" is "next" |
| 673 | // with nothing counted and "in" with a number beside it. |
| 674 | var quantified = findKind("num") >= 0; |
| 675 | var into = find("in"), back = find("ago"); |
| 676 | // Both directions at once is not a phrase, it is a contradiction: |
| 677 | // "in 2 weeks ago" and "через 2 недели назад" name no instant, and |
| 678 | // the sign was being picked by whichever branch ran second — ago |
| 679 | // won, silently, and the reading looked confident. Two DISTINCT |
| 680 | // tokens is the test, because one spelling is allowed to carry |
| 681 | // both kinds (Vietnamese "sau" does) and a single word wearing two |
| 682 | // hats is still one word. |
| 683 | if (into >= 0 && back >= 0 && into !== back) return null; |
| 684 | if (quantified && (into >= 0 || back >= 0)) { |
| 685 | var u = unit(); |
| 686 | if (u) { |
| 687 | var qi = findKind("num"); |
| 688 | var qty = toks[qi].n, sign = back >= 0 ? -1 : 1; |
| 689 | used[qi] = true; |
| 690 | used[u.at] = true; |
| 691 | if (into >= 0) used[into] = true; |
| 692 | if (back >= 0) used[back] = true; |
| 693 | if (u.name === "minute" || u.name === "hour") { |
| 694 | // An offset counted in clock units moves the clock, so it |
| 695 | // names a time as well as a day. |
| 696 | date = new Date(now.getTime() + sign * qty * (u.name === "hour" ? 3600000 : 60000)); |
| 697 | hasDate = true; |
| 698 | hasTime = true; |
| 699 | h = date.getHours(); |
| 700 | mi = date.getMinutes(); |
| 701 | } else { |
| 702 | date = midnight(now); |
| 703 | if (u.name === "day") date.setDate(date.getDate() + sign * qty); |
| 704 | else if (u.name === "week") date.setDate(date.getDate() + sign * qty * 7); |
| 705 | else date.setMonth(date.getMonth() + sign * qty); |
| 706 | hasDate = true; |
| 707 | } |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | // The clock. Claimed before the calendar so "25 dec 6pm" cannot |
| 712 | // read its 6 as a second day number. |
| 713 | if (!hasTime) { |
| 714 | var noon = find("noon"), mid = find("midnight"); |
| 715 | if (noon >= 0) { h = 12; mi = 0; hasTime = true; used[noon] = true; } |
| 716 | else if (mid >= 0) { h = 0; mi = 0; hasTime = true; used[mid] = true; } |
| 717 | else { |
| 718 | var ci = findKind("clock"); |
| 719 | if (ci >= 0) { |
| 720 | h = toks[ci].h; |
| 721 | mi = toks[ci].mi; |
| 722 | used[ci] = true; |
| 723 | hasTime = true; |
| 724 | } else { |
| 725 | for (i = 0; i < toks.length && !hasTime; i++) { |
| 726 | if (used[i] || toks[i].t !== "num") continue; |
| 727 | var nx = after(i), pv = before(i); |
| 728 | if (isWord(nx, "am") || isWord(nx, "pm") || isWord(pv, "am") || isWord(pv, "pm")) { |
| 729 | h = toks[i].n; |
| 730 | mi = 0; |
| 731 | used[i] = true; |
| 732 | hasTime = true; |
| 733 | // A half-of-the-day marker and a clock marker can both |
| 734 | // sit on the same hour: \u5348\u5f8c6\u6642 and \u4e0b\u53486\u70b9 are "6 in the |
| 735 | // afternoon" with the hour word still attached. Claiming |
| 736 | // the number and leaving the \u6642 behind failed the |
| 737 | // leftover check and refused a perfectly ordinary phrase. |
| 738 | if (isWord(nx, "hour")) { |
| 739 | used[nx] = true; |
| 740 | var mnx = after(nx); |
| 741 | if (mnx >= 0 && toks[mnx].t === "num" && (after(mnx) < 0 || isWord(after(mnx), "minute"))) { |
| 742 | mi = toks[mnx].n; |
| 743 | used[mnx] = true; |
| 744 | if (after(mnx) >= 0) used[after(mnx)] = true; |
| 745 | } |
| 746 | } |
| 747 | } else if (isWord(nx, "hour")) { |
| 748 | h = toks[i].n; |
| 749 | mi = 0; |
| 750 | used[i] = true; |
| 751 | used[nx] = true; |
| 752 | hasTime = true; |
| 753 | var mn = after(nx); |
| 754 | if (mn >= 0 && toks[mn].t === "num" && (after(mn) < 0 || isWord(after(mn), "minute"))) { |
| 755 | mi = toks[mn].n; |
| 756 | used[mn] = true; |
| 757 | if (after(mn) >= 0) used[after(mn)] = true; |
| 758 | } |
| 759 | } else if (isWord(pv, "at") || isWord(nx, "at")) { |
| 760 | // The particle sits on either side of the hour: English |
| 761 | // and Spanish put it in front ("at 6", "a las 6") and |
| 762 | // Hindi, Bengali and Japanese put it behind (6 बजे, ৬ টায়, |
| 763 | // 6時に). Reading only the front one refused half the |
| 764 | // world's ordinary way of naming a time. |
| 765 | h = toks[i].n; |
| 766 | mi = 0; |
| 767 | used[i] = true; |
| 768 | used[isWord(pv, "at") ? pv : nx] = true; |
| 769 | hasTime = true; |
| 770 | } |
| 771 | } |
| 772 | } |
| 773 | if (hasTime) { |
| 774 | var pm = find("pm"), am = find("am"); |
| 775 | // A half-of-the-day marker says the clock beside it is a |
| 776 | // 12-hour one, and a 12-hour clock has twelve hours on it. |
| 777 | // The fold below is modular, so without this line every |
| 778 | // impossible hour came back as a plausible one: "25pm" was |
| 779 | // 13:00 and "99am" was 03:00 — a guess dressed as a reading, |
| 780 | // and the leftover check could not see it because every token |
| 781 | // had been claimed. |
| 782 | // |
| 783 | // Zero is one of the twelve. Japanese and Chinese write |
| 784 | // midnight and noon as 午前0時 and 午後0時, 上午0点 and 下午0点 — |
| 785 | // ordinary usage, not a typo — and the modular fold already |
| 786 | // says the right thing about them: 0 with a morning marker is |
| 787 | // midnight, 0 with an afternoon one is noon, exactly as 12 |
| 788 | // reads the other way round. That also lets English "0am" |
| 789 | // through, which is coherent (it IS midnight) and much |
| 790 | // simpler than a rule that would have to know which |
| 791 | // languages say it. |
| 792 | if (pm >= 0 || am >= 0) { |
| 793 | if (h < 0 || h > 12) return null; |
| 794 | if (pm >= 0) { used[pm] = true; h = (h % 12) + 12; } |
| 795 | else { used[am] = true; h = h % 12; } |
| 796 | } |
| 797 | } |
| 798 | } |
| 799 | if (hasTime && (h > 23 || mi > 59)) return null; |
| 800 | } |
| 801 | |
| 802 | // The calendar. A weekday beside a numeric date is decoration, as |
| 803 | // it is beside a month name below. |
| 804 | var nd = findKind("ndate"); |
| 805 | if (!hasDate && nd >= 0) { |
| 806 | used[nd] = true; |
| 807 | if (findRole("weekday") >= 0) used[findRole("weekday")] = true; |
| 808 | date = dmy(toks[nd].d, toks[nd].mo, toks[nd].y); |
| 809 | if (!date) return null; |
| 810 | hasDate = true; |
| 811 | } |
| 812 | if (!hasDate) { |
| 813 | var tod = find("today"), tom = find("tomorrow"), yes = find("yesterday"); |
| 814 | if (tom >= 0) { date = shiftDays(now, 1); used[tom] = true; hasDate = true; } |
| 815 | else if (yes >= 0) { date = shiftDays(now, -1); used[yes] = true; hasDate = true; } |
| 816 | else if (tod >= 0) { date = midnight(now); used[tod] = true; hasDate = true; } |
| 817 | } |
| 818 | if (!hasDate) { |
| 819 | var next = find("next"), last = find("last"), dup; |
| 820 | var cm = counterMonth(); |
| 821 | var wi = findRole("weekday"); |
| 822 | var mo = findRole("month"); |
| 823 | if (wi >= 0 && mo >= 0) { |
| 824 | if (wi === mo) { |
| 825 | // ONE spelling wearing both hats: Spanish "mar" is March and |
| 826 | // it is Tuesday, and the scanner merges same-length matches |
| 827 | // rather than picking for you. Twice in a row is this field's |
| 828 | // own writing — "mar, 3 mar 2026" is Tuesday and then March — |
| 829 | // so a second month-naming token settles it and the first is |
| 830 | // the decoration. Otherwise a day number beside it settles it |
| 831 | // ("25 mar" is a date and nothing else), and with no number |
| 832 | // to count, a weekday is what a person means. |
| 833 | for (dup = wi + 1; dup < toks.length; dup++) |
| 834 | if (!used[dup] && toks[dup].t === "word" && toks[dup].month >= 0) { mo = dup; break; } |
| 835 | if (mo !== wi) { used[wi] = true; wi = -1; } |
| 836 | else if (findKind("num") >= 0) wi = -1; |
| 837 | else mo = -1; |
| 838 | } else { |
| 839 | // A weekday sitting beside a month and a day is decoration, |
| 840 | // not the date: it is how this field writes its own value |
| 841 | // back ("Fri, 28 Aug 2026"), and the only way to retype a |
| 842 | // year in place is for the weekday beside it to be consumed |
| 843 | // rather than obeyed. Obeying it would move the date to the |
| 844 | // coming Friday and silently throw the typed year away. |
| 845 | used[wi] = true; |
| 846 | wi = -1; |
| 847 | } |
| 848 | } else if (wi >= 0 && cm >= 0) { |
| 849 | // Same rule where the months are numbered rather than named: |
| 850 | // 2026年12月25日(金) and 2026年12月25日周五 are a full date with |
| 851 | // the weekday written beside it, and the weekday is the part |
| 852 | // that follows from the rest. |
| 853 | used[wi] = true; |
| 854 | wi = -1; |
| 855 | } |
| 856 | if (wi >= 0) { |
| 857 | used[wi] = true; |
| 858 | if (last >= 0) { used[last] = true; date = lastWeekday(now, toks[wi].weekday); } |
| 859 | else { |
| 860 | if (next >= 0) used[next] = true; |
| 861 | date = comingWeekday(now, toks[wi].weekday); |
| 862 | } |
| 863 | hasDate = true; |
| 864 | } else { |
| 865 | if (mo >= 0) { |
| 866 | used[mo] = true; |
| 867 | var dnum = -1, ynum = -1, dat = -1, k; |
| 868 | for (k = 0; k < toks.length; k++) { |
| 869 | if (used[k] || toks[k].t !== "num") continue; |
| 870 | if (toks[k].digits === 4) { |
| 871 | if (ynum >= 0) break; |
| 872 | ynum = toks[k].n; |
| 873 | used[k] = true; |
| 874 | } else if (dnum < 0) { |
| 875 | dnum = toks[k].n; |
| 876 | dat = k; |
| 877 | used[k] = true; |
| 878 | } else if (ynum < 0) { |
| 879 | ynum = toks[k].n < 100 ? 2000 + toks[k].n : toks[k].n; |
| 880 | used[k] = true; |
| 881 | } else break; |
| 882 | } |
| 883 | if (dnum < 0) return null; |
| 884 | claimCounter(dat, "day"); |
| 885 | date = makeDate(ynum >= 0 ? ynum : inferYear(now, toks[mo].month, dnum), |
| 886 | toks[mo].month, dnum, 0, 0); |
| 887 | if (!date) return null; |
| 888 | hasDate = true; |
| 889 | } else if (cm >= 0) { |
| 890 | // A calendar that numbers its months writes the number with |
| 891 | // its counter, and the counter is doing the work a name does |
| 892 | // everywhere else. The day beside it is written the same way, |
| 893 | // with or without its own counter. |
| 894 | var cmon = toks[cm].n - 1, cday = -1, cyear = -1, ck; |
| 895 | used[cm] = true; |
| 896 | used[cm + 1] = true; |
| 897 | ck = after(cm + 1); |
| 898 | if (ck >= 0 && toks[ck].t === "num" && toks[ck].digits <= 2) { |
| 899 | cday = toks[ck].n; |
| 900 | used[ck] = true; |
| 901 | claimCounter(ck, "day"); |
| 902 | } |
| 903 | if (cday < 0) return null; |
| 904 | // The year goes in front here (2026年12月25日), so it is |
| 905 | // whatever four digits are still unspoken for. Without this |
| 906 | // the field could write a full date it could not read back. |
| 907 | for (ck = 0; ck < toks.length && cyear < 0; ck++) { |
| 908 | if (used[ck] || toks[ck].t !== "num" || toks[ck].digits !== 4) continue; |
| 909 | cyear = toks[ck].n; |
| 910 | used[ck] = true; |
| 911 | } |
| 912 | date = makeDate(cyear >= 0 ? cyear : inferYear(now, cmon, cday), cmon, cday, 0, 0); |
| 913 | if (!date) return null; |
| 914 | hasDate = true; |
| 915 | } else if (next >= 0 || last >= 0) { |
| 916 | var u2 = unit(); |
| 917 | if (u2 && (u2.name === "day" || u2.name === "week" || u2.name === "month")) { |
| 918 | used[u2.at] = true; |
| 919 | var dir = last >= 0 ? -1 : 1; |
| 920 | used[last >= 0 ? last : next] = true; |
| 921 | date = midnight(now); |
| 922 | if (u2.name === "day") date.setDate(date.getDate() + dir); |
| 923 | else if (u2.name === "week") date.setDate(date.getDate() + 7 * dir); |
| 924 | else date.setMonth(date.getMonth() + dir); |
| 925 | hasDate = true; |
| 926 | } |
| 927 | } |
| 928 | } |
| 929 | } |
| 930 | if (!hasDate) { |
| 931 | // Four digits on their own move the year and nothing else — the |
| 932 | // same bargain a lone time strikes with the date. Focus selects |
| 933 | // the whole value, so "2030" is not a fragment someone left |
| 934 | // behind: it is the entire gesture of changing a year. |
| 935 | var only = -1, count = 0; |
| 936 | for (i = 0; i < toks.length; i++) { |
| 937 | if (used[i] || toks[i].t !== "num") continue; |
| 938 | count++; |
| 939 | if (toks[i].digits === 4) only = i; |
| 940 | } |
| 941 | // Four digits, and a year makeDate's floor will accept — checked |
| 942 | // here too so a refused year never claims its token and never |
| 943 | // reads as a date at all. |
| 944 | if (count === 1 && only >= 0 && toks[only].n >= 1000) { |
| 945 | used[only] = true; |
| 946 | date = new Date(own.getFullYear(), own.getMonth(), own.getDate()); |
| 947 | date.setFullYear(toks[only].n); |
| 948 | hasDate = true; |
| 949 | } |
| 950 | } |
| 951 | |
| 952 | // Anything left over is a word this reading did not account for, |
| 953 | // and a reading that ignores half the sentence is a guess. Two |
| 954 | // exceptions: the "at" particle, which is optional everywhere it |
| 955 | // appears and required nowhere; and a literal the display itself |
| 956 | // writes, which nobody typed on purpose and which carries no |
| 957 | // meaning to drop. |
| 958 | for (i = 0; i < toks.length; i++) { |
| 959 | if (used[i]) continue; |
| 960 | if (toks[i].t !== "word") return null; |
| 961 | if (toks[i].month >= 0 || toks[i].weekday >= 0) return null; |
| 962 | for (var kind in toks[i].kinds) if (kind !== "at" && kind !== "ignore") return null; |
| 963 | } |
| 964 | if (!hasDate && !hasTime) return null; |
| 965 | |
| 966 | var on = hasDate ? date : anchor; |
| 967 | var out = makeDate(on.getFullYear(), on.getMonth(), on.getDate(), |
| 968 | hasTime ? h : clockH, hasTime ? mi : clockM); |
| 969 | return out ? { date: out, hasDate: hasDate, hasTime: hasTime } : null; |
| 970 | } |
| 971 | |
| 972 | /* ── completion ────────────────────────────────────────────────── |
| 973 | The grammar above is exact, and says so: one word it does not know |
| 974 | makes the whole reading null. That refusal is the promise which |
| 975 | keeps this field from ever committing a date nobody typed, and it |
| 976 | is not up for negotiation. |
| 977 | |
| 978 | Completion is the other half of that bargain. It never overrules a |
| 979 | reading — an exact one wins outright and nothing below runs — it |
| 980 | only offers one where the grammar had none. "5j" is not a date. |
| 981 | "5 January", "5 June" and "5 July" are three, and putting all |
| 982 | three on screen with their dates written out in full is precisely |
| 983 | how a guess stops being a guess: the person reads the answer |
| 984 | before they accept it. |
| 985 | |
| 986 | Nearest first, which is the whole of "the fifth of the next month |
| 987 | starting with j": inferYear already rolls a bare day-and-month |
| 988 | forward to its next occurrence, so ordering the readings by date |
| 989 | IS that rule. Nothing here knows what a month is called. */ |
| 990 | |
| 991 | // How many finishings one unfinished word may offer. A single letter |
| 992 | // is the front of three months in English, and of more than that |
| 993 | // once the weekdays and the vocabulary are in; a list nobody can |
| 994 | // read at a glance has stopped being a suggestion. |
| 995 | var MAX_COMPLETIONS = 6; |
| 996 | |
| 997 | // Every word in this locale's index that the run could still become. |
| 998 | // A prefix, never an infix: people type the front of a word, so "ju" |
| 999 | // finishes to June and July and "une" finishes to nothing. |
| 1000 | // |
| 1001 | // Two kinds are skipped. A literal is punctuation Intl threads |
| 1002 | // between numbers, which nobody is halfway through typing; and "at" |
| 1003 | // is optional everywhere it appears, so finishing a word INTO it |
| 1004 | // changes no reading and only spends a row. |
| 1005 | function completionsFor(run, idx) { |
| 1006 | var out = [], seen = {}, i, e; |
| 1007 | if (!run) return out; |
| 1008 | for (i = 0; i < idx.length; i++) { |
| 1009 | e = idx[i]; |
| 1010 | if (e.role === "word" && (e.kind === "ignore" || e.kind === "at")) continue; |
| 1011 | if (e.text.length <= run.length) continue; |
| 1012 | if (e.text.slice(0, run.length) !== run) continue; |
| 1013 | if (seen[e.text]) continue; |
| 1014 | seen[e.text] = true; |
| 1015 | out.push(e.text); |
| 1016 | } |
| 1017 | return out; |
| 1018 | } |
| 1019 | |
| 1020 | // parseAll(text, vocab, tbl, now, ctx) -> [reading, ...], best first, |
| 1021 | // and empty where the grammar and the completions both come back |
| 1022 | // with nothing. This is what the field itself reads with; parse |
| 1023 | // stays exactly as strict as it was, and the read-back gate still |
| 1024 | // holds it to reading its own writing with no help from here. |
| 1025 | function parseAll(text, vocab, tbl, now, ctx) { |
| 1026 | var exact = parse(text, vocab, tbl, now, ctx); |
| 1027 | if (exact) return [exact]; |
| 1028 | var raw = tbl.fold(text); |
| 1029 | if (!raw) return []; |
| 1030 | var idx = indexFor(vocab, tbl); |
| 1031 | var toks = scan(raw, idx), at = -1, i; |
| 1032 | for (i = 0; i < toks.length; i++) { |
| 1033 | if (toks[i].t !== "other") continue; |
| 1034 | // Two unfinished words is not a typo anybody is mid-way through, |
| 1035 | // it is prose — and finishing both would multiply out into a |
| 1036 | // list of dates nobody asked for. |
| 1037 | if (at >= 0) return []; |
| 1038 | at = i; |
| 1039 | } |
| 1040 | if (at < 0) return []; |
| 1041 | var names = completionsFor(toks[at].raw, idx), out = [], seen = {}, read, key; |
| 1042 | for (i = 0; i < names.length; i++) { |
| 1043 | // The sentence handed back to the grammar with that one run |
| 1044 | // finished, spaced off on both sides so a finished word cannot |
| 1045 | // glue itself onto its neighbour and mean something else. |
| 1046 | read = parse(raw.slice(0, toks[at].from) + " " + names[i] + " " + raw.slice(toks[at].to), |
| 1047 | vocab, tbl, now, ctx); |
| 1048 | if (!read) continue; |
| 1049 | key = String(read.date.getTime()) + (read.hasTime ? "t" : ""); |
| 1050 | if (seen[key]) continue; |
| 1051 | seen[key] = true; |
| 1052 | out.push(read); |
| 1053 | } |
| 1054 | out.sort(function (a, b) { return a.date - b.date; }); |
| 1055 | return out.slice(0, MAX_COMPLETIONS); |
| 1056 | } |
| 1057 | |
| 1058 | /* ── the page ──────────────────────────────────────────────────── |
| 1059 | Everything below is behind the document guard, so Node loads the |
| 1060 | parser above and nothing else. */ |
| 1061 | |
| 1062 | // The English vocabulary, i18n/en.json's date-words: the fallback |
| 1063 | // when neither the attribute nor a loaded table spells one, as the |
| 1064 | // labels fall back to their English. check/datetime_test.go holds |
| 1065 | // it to the shipped table, so a word improved there is improved |
| 1066 | // here. Before the page half, which scans at once on a document |
| 1067 | // that has already parsed (a deferred or late-loaded script): a var |
| 1068 | // assigned after that scan would be undefined to every field it |
| 1069 | // enhanced. |
| 1070 | var ENGLISH_WORDS = { |
| 1071 | ago: "ago", am: "am|a.m.", at: "at", day: "day|days", hour: "hour|hours|h", in: "in", |
| 1072 | last: "last", midnight: "midnight", minute: "minute|minutes|min|mins|m", |
| 1073 | month: "month|months", next: "next", noon: "noon|midday", pm: "pm|p.m.", |
| 1074 | today: "today", tomorrow: "tomorrow", week: "week|weeks", yesterday: "yesterday" |
| 1075 | }; |
| 1076 | |
| 1077 | // An element with no attributes, for a lookup that skips the one on |
| 1078 | // the field. |
| 1079 | var NO_ATTRIBUTE = { getAttribute: function () { return null; } }; |
| 1080 | |
| 1081 | // The parser's vocabulary for one field: the attribute (JSON text), |
| 1082 | // else the page's table (the object), else English. An attribute |
| 1083 | // that is not JSON is as good as absent, so a Spanish page with a |
| 1084 | // broken attribute still reads its Spanish table before English. |
| 1085 | function vocabulary(native) { |
| 1086 | var words = fiestaWord(native, "date-words", ENGLISH_WORDS); |
| 1087 | if (typeof words === "string") { |
| 1088 | try { |
| 1089 | words = JSON.parse(words); |
| 1090 | } catch (e) { |
| 1091 | words = fiestaWord(NO_ATTRIBUTE, "date-words", ENGLISH_WORDS); |
| 1092 | } |
| 1093 | } |
| 1094 | return words || ENGLISH_WORDS; |
| 1095 | } |
| 1096 | |
| 1097 | if (typeof document !== "undefined") { |
| 1098 | |
| 1099 | // Resets, per form, for the commit a blur queues. A reset that is |
| 1100 | // dispatched after the blur and before its timer blanks the box, |
| 1101 | // and the commit would empty the field the reset restored; so the |
| 1102 | // commit waits while any reset for its form is still deciding |
| 1103 | // (resetsPending), and then runs only if no reset was accepted |
| 1104 | // since it was queued (resetsAccepted). Whether a reset was |
| 1105 | // accepted is known only once it has been dispatched, so it is |
| 1106 | // counted in a timer, where defaultPrevented is final; a cancelled |
| 1107 | // reset is never counted; and nothing ever clears a count, so a |
| 1108 | // later reset cannot erase an earlier one's protection. One |
| 1109 | // listener for every field, registered before any field's own. |
| 1110 | var resetsPending = new WeakMap(), resetsAccepted = new WeakMap(); |
| 1111 | function tally(map, form) { return (form && map.get(form)) || 0; } |
| 1112 | function count(map, form, by) { if (form) map.set(form, tally(map, form) + by); } |
| 1113 | document.addEventListener("reset", function (e) { |
| 1114 | var form = e.target; |
| 1115 | count(resetsPending, form, 1); |
| 1116 | setTimeout(function () { |
| 1117 | count(resetsPending, form, -1); |
| 1118 | if (!e.defaultPrevented) count(resetsAccepted, form, 1); |
| 1119 | }, 0); |
| 1120 | }); |
| 1121 | |
| 1122 | var CAL_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| 1123 | 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + |
| 1124 | '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'; |
| 1125 | |
| 1126 | // A time field's button opens a clock, not a calendar, and says so |
| 1127 | // with the one glyph everybody reads the same way. |
| 1128 | var CLOCK_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' + |
| 1129 | 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + |
| 1130 | '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'; |
| 1131 | |
| 1132 | var seq = 0; |
| 1133 | |
| 1134 | function toWire(kind, d) { |
| 1135 | var day = d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()); |
| 1136 | var clock = pad(d.getHours()) + ":" + pad(d.getMinutes()); |
| 1137 | if (kind === "date") return day; |
| 1138 | if (kind === "time") return clock; |
| 1139 | return day + "T" + clock; |
| 1140 | } |
| 1141 | |
| 1142 | function fromWire(kind, value, now) { |
| 1143 | var s = String(value || ""), m; |
| 1144 | if (kind === "time") { |
| 1145 | m = /^(\d{2}):(\d{2})/.exec(s); |
| 1146 | return m ? new Date(now.getFullYear(), now.getMonth(), now.getDate(), +m[1], +m[2]) : null; |
| 1147 | } |
| 1148 | m = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(s); |
| 1149 | if (!m) return null; |
| 1150 | return new Date(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); |
| 1151 | } |
| 1152 | |
| 1153 | // displayOptions is up in the pure half, because the parser reads |
| 1154 | // these back: one set of options, formatted here and derived there. |
| 1155 | function formatter(locale, kind) { |
| 1156 | try { |
| 1157 | return new Intl.DateTimeFormat(locale, displayOptions(kind)); |
| 1158 | } catch (e) { |
| 1159 | return null; |
| 1160 | } |
| 1161 | } |
| 1162 | |
| 1163 | // Does this browser open a picker for this input? showPicker cannot |
| 1164 | // be asked: HTML has it RETURN silently — no error, no event — |
| 1165 | // where the browser has no picker for the type, which is exactly |
| 1166 | // how a button wired to it ends up looking like a control and doing |
| 1167 | // nothing. So the presence of the method is the only signal a page |
| 1168 | // gets, and every field that has it gets the button. |
| 1169 | // |
| 1170 | // Recorded rather than guarded, because it will be rediscovered |
| 1171 | // otherwise: measured on Firefox 152 (macOS), a date and a |
| 1172 | // datetime-local both open the panel and a TIME opens nothing. The |
| 1173 | // button is offered there anyway, on the same reasoning that lets a |
| 1174 | // time field share the calendar's label — one affordance, in one |
| 1175 | // place, on every date field. If a dead clock is reported, this |
| 1176 | // paragraph is where the fix starts. |
| 1177 | function canPick(el) { return !!el.showPicker; } |
| 1178 | |
| 1179 | function enhance(native) { |
| 1180 | if (native.dataset.rstEnhanced) return null; // idempotent: safe to re-scan |
| 1181 | native.dataset.rstEnhanced = "true"; |
| 1182 | |
| 1183 | var kind = native.type === "time" ? "time" : (native.type === "date" ? "date" : "datetime"); |
| 1184 | var locale = document.documentElement.getAttribute("lang") || undefined; |
| 1185 | var tbl = tables(locale); |
| 1186 | var fmt = formatter(locale, kind); |
| 1187 | var vocab = vocabulary(native); |
| 1188 | |
| 1189 | var id = native.id || "rst-dtp-" + ++seq; |
| 1190 | var listId = id + "-listbox"; |
| 1191 | var setLabel = fiestaWord(native, "date-set", "Set"); |
| 1192 | var hintText = fiestaWord(native, "date-hint", ""); |
| 1193 | var pickLabel = fiestaWord(native, "date-pick", "Open the calendar"); |
| 1194 | var clockLabel = fiestaWord(native, "date-pick-time", "Open the clock"); |
| 1195 | var calLabel = fiestaWord(native, "date-calendar", "Calendar"); |
| 1196 | var prevLabel = fiestaWord(native, "date-prev-month", "Previous month"); |
| 1197 | var nextLabel = fiestaWord(native, "date-next-month", "Next month"); |
| 1198 | var manyFmt = fiestaWord(native, "date-results", "{n} suggestions"); |
| 1199 | var oneFmt = fiestaWord(native, "date-result-one", "1 suggestion"); |
| 1200 | var quick = { |
| 1201 | today: fiestaWord(native, "date-quick-today", "Today"), |
| 1202 | tomorrow: fiestaWord(native, "date-quick-tomorrow", "Tomorrow"), |
| 1203 | nextWeek: fiestaWord(native, "date-quick-next-week", "In a week"), |
| 1204 | plus1h: fiestaWord(native, "date-quick-plus-1h", "An hour later"), |
| 1205 | plus2h: fiestaWord(native, "date-quick-plus-2h", "Two hours later"), |
| 1206 | endOfDay: fiestaWord(native, "date-quick-end-of-day", "End of that day"), |
| 1207 | nextDay: fiestaWord(native, "date-quick-next-day", "Same time next day") |
| 1208 | }; |
| 1209 | |
| 1210 | var wrap = document.createElement("div"); |
| 1211 | wrap.setAttribute("rst-dtp", ""); |
| 1212 | |
| 1213 | var input = document.createElement("input"); |
| 1214 | input.type = "text"; |
| 1215 | input.setAttribute("rst-input", ""); |
| 1216 | input.setAttribute("rst-dtp-input", ""); |
| 1217 | input.id = id + "-combo"; |
| 1218 | input.autocomplete = "off"; |
| 1219 | input.spellcheck = false; |
| 1220 | input.setAttribute("role", "combobox"); |
| 1221 | input.setAttribute("aria-expanded", "false"); |
| 1222 | input.setAttribute("aria-controls", listId); |
| 1223 | input.setAttribute("aria-autocomplete", "list"); |
| 1224 | |
| 1225 | var list = document.createElement("ul"); |
| 1226 | list.setAttribute("rst-dtp-list", ""); |
| 1227 | list.id = listId; |
| 1228 | list.hidden = true; |
| 1229 | list.setAttribute("role", "listbox"); |
| 1230 | |
| 1231 | var status = document.createElement("p"); |
| 1232 | status.className = "rst-sr-only"; |
| 1233 | status.setAttribute("role", "status"); |
| 1234 | status.setAttribute("aria-live", "polite"); |
| 1235 | |
| 1236 | native.parentNode.insertBefore(wrap, native); |
| 1237 | wrap.appendChild(input); |
| 1238 | wrap.appendChild(list); |
| 1239 | wrap.appendChild(status); |
| 1240 | wrap.appendChild(native); |
| 1241 | |
| 1242 | // The label named the native input; it now names the control the |
| 1243 | // user actually types into. Without this the combobox has no |
| 1244 | // accessible name at all. |
| 1245 | var label = native.id ? document.querySelector('label[for="' + native.id + '"]') : null; |
| 1246 | if (label) label.setAttribute("for", input.id); |
| 1247 | else if (native.getAttribute("aria-label")) input.setAttribute("aria-label", native.getAttribute("aria-label")); |
| 1248 | if (native.getAttribute("aria-describedby")) |
| 1249 | input.setAttribute("aria-describedby", native.getAttribute("aria-describedby")); |
| 1250 | |
| 1251 | // Hidden, never removed: it is what the form submits. |
| 1252 | native.removeAttribute("rst-input"); |
| 1253 | native.classList.add("rst-sr-only"); |
| 1254 | native.setAttribute("tabindex", "-1"); |
| 1255 | native.setAttribute("aria-hidden", "true"); |
| 1256 | |
| 1257 | var rows = []; // [{li, date, hasTime}] — selectable rows only |
| 1258 | var active = -1; |
| 1259 | var baseOf = null; // range end: () => Date|null, the start's value |
| 1260 | var onCommit = null; |
| 1261 | var isEnd = false; |
| 1262 | var mine = false; // true while WE are the ones writing to the carrier |
| 1263 | |
| 1264 | function current() { return fromWire(kind, native.value, new Date()); } |
| 1265 | function show(d) { return fmt ? fmt.format(d) : toWire(kind, d); } |
| 1266 | function paint() { |
| 1267 | var d = current(); |
| 1268 | input.value = d ? show(d) : ""; |
| 1269 | } |
| 1270 | |
| 1271 | function setActive(i) { |
| 1272 | active = i; |
| 1273 | for (var k = 0; k < rows.length; k++) rows[k].li.classList.toggle("is-active", k === i); |
| 1274 | if (i >= 0) input.setAttribute("aria-activedescendant", rows[i].li.id); |
| 1275 | else input.removeAttribute("aria-activedescendant"); |
| 1276 | } |
| 1277 | |
| 1278 | // extra is the row's variant ("set", "alt") or a second attribute |
| 1279 | // ("rst-dtp-quick"): the vocabulary spells a variant in the value |
| 1280 | // slot and a kind as an attribute of its own, so one parameter |
| 1281 | // cannot be both, and the caller says which. |
| 1282 | function addRow(label, meta, date, hasTime, variant, extra) { |
| 1283 | var li = document.createElement("li"); |
| 1284 | li.setAttribute("rst-dtp-row", variant || ""); |
| 1285 | if (extra) li.setAttribute(extra, ""); |
| 1286 | li.id = listId + "-" + rows.length; |
| 1287 | li.setAttribute("role", "option"); |
| 1288 | // The row that IS the committed value says so in the |
| 1289 | // accessibility tree, which is what aria-selected means on a |
| 1290 | // listbox option — the highlight below is a separate thing, |
| 1291 | // moved by the arrow keys. |
| 1292 | li.setAttribute("aria-selected", toWire(kind, date) === native.value ? "true" : "false"); |
| 1293 | var main = document.createElement("span"); |
| 1294 | main.setAttribute("rst-dtp-label", ""); |
| 1295 | main.textContent = label; |
| 1296 | li.appendChild(main); |
| 1297 | if (meta) { |
| 1298 | var m = document.createElement("span"); |
| 1299 | m.setAttribute("rst-dtp-set", ""); |
| 1300 | m.textContent = meta; |
| 1301 | li.appendChild(m); |
| 1302 | } |
| 1303 | li.addEventListener("mousedown", function (e) { |
| 1304 | e.preventDefault(); // keep focus; blur would undo the pick |
| 1305 | commit(date, hasTime); |
| 1306 | }); |
| 1307 | list.appendChild(li); |
| 1308 | rows.push({ li: li, date: date, hasTime: hasTime }); |
| 1309 | } |
| 1310 | |
| 1311 | function picks(now) { |
| 1312 | var start = baseOf ? baseOf() : null; |
| 1313 | var out = [], at; |
| 1314 | if (isEnd && start && kind !== "date") { |
| 1315 | return [ |
| 1316 | { label: quick.plus1h, date: new Date(start.getTime() + 3600000) }, |
| 1317 | { label: quick.plus2h, date: new Date(start.getTime() + 7200000) }, |
| 1318 | { label: quick.endOfDay, date: new Date(start.getFullYear(), start.getMonth(), start.getDate(), 23, 59) }, |
| 1319 | { label: quick.nextDay, date: new Date(start.getTime() + 86400000) } |
| 1320 | ]; |
| 1321 | } |
| 1322 | if (kind === "time") { |
| 1323 | var from = current() || now; |
| 1324 | return [ |
| 1325 | { label: quick.plus1h, date: new Date(from.getTime() + 3600000) }, |
| 1326 | { label: quick.plus2h, date: new Date(from.getTime() + 7200000) }, |
| 1327 | { label: quick.endOfDay, date: new Date(from.getFullYear(), from.getMonth(), from.getDate(), 23, 59) } |
| 1328 | ]; |
| 1329 | } |
| 1330 | at = function (d) { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 9, 0); }; |
| 1331 | out.push({ label: quick.today, date: at(now) }); |
| 1332 | out.push({ label: quick.tomorrow, date: at(shiftDays(now, 1)) }); |
| 1333 | out.push({ label: quick.nextWeek, date: at(shiftDays(now, 7)) }); |
| 1334 | return out; |
| 1335 | } |
| 1336 | |
| 1337 | function ctx() { |
| 1338 | return { prev: current(), base: baseOf ? baseOf() : null }; |
| 1339 | } |
| 1340 | |
| 1341 | function rebuild(query) { |
| 1342 | var now = new Date(); |
| 1343 | list.textContent = ""; |
| 1344 | rows = []; |
| 1345 | var text = String(query || "").trim(); |
| 1346 | var reads = text ? parseAll(text, vocab, tbl, now, ctx()) : []; |
| 1347 | var i, skip = {}, wire; |
| 1348 | for (i = 0; i < reads.length; i++) { |
| 1349 | // The first reading is the one Enter takes, and it is |
| 1350 | // labelled as such. The rest are the other months a |
| 1351 | // half-typed word could still have meant — offered, never |
| 1352 | // chosen, each with its date written out in full, which is |
| 1353 | // what keeps a completion from being a guess. |
| 1354 | addRow(show(reads[i].date), i === 0 ? setLabel : "", reads[i].date, reads[i].hasTime, |
| 1355 | i === 0 ? "set" : "alt"); |
| 1356 | skip[toWire(kind, reads[i].date)] = true; |
| 1357 | } |
| 1358 | var quickPicks = picks(now); |
| 1359 | for (i = 0; i < quickPicks.length; i++) { |
| 1360 | wire = toWire(kind, quickPicks[i].date); |
| 1361 | if (skip[wire]) continue; |
| 1362 | addRow(quickPicks[i].label, show(quickPicks[i].date), quickPicks[i].date, kind !== "date", "", "rst-dtp-quick"); |
| 1363 | } |
| 1364 | if (hintText) { |
| 1365 | var hint = document.createElement("li"); |
| 1366 | hint.setAttribute("rst-dtp-hint", ""); |
| 1367 | hint.setAttribute("aria-hidden", "true"); |
| 1368 | hint.textContent = hintText.replace("{example}", show(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 9, 0))); |
| 1369 | list.appendChild(hint); |
| 1370 | } |
| 1371 | status.textContent = rows.length === 1 ? oneFmt : manyFmt.replace("{n}", String(rows.length)); |
| 1372 | // Text that read as nothing leaves NOTHING armed: a quick pick |
| 1373 | // sitting under the cursor would make Enter on a date the |
| 1374 | // grammar did not understand commit today instead of refusing. |
| 1375 | setActive(rows.length && (reads.length || !text) ? 0 : -1); |
| 1376 | } |
| 1377 | |
| 1378 | // What the carrier itself allows, plus — for a range's end — the |
| 1379 | // start it must not come before. Asked fresh on every question |
| 1380 | // rather than captured once, because the start moves. |
| 1381 | function bounds() { |
| 1382 | var when = new Date(); |
| 1383 | var lo = fromWire(kind, native.getAttribute("min"), when); |
| 1384 | var hi = fromWire(kind, native.getAttribute("max"), when); |
| 1385 | var start = baseOf ? baseOf() : null; |
| 1386 | if (isEnd && start && (!lo || start > lo)) lo = midnight(start); |
| 1387 | return { min: lo, max: hi }; |
| 1388 | } |
| 1389 | |
| 1390 | // What the text in the box currently reads as, committing |
| 1391 | // nothing. Null where it reads as nothing at all. |
| 1392 | function previewOf(text) { |
| 1393 | var t = String(text || "").trim(); |
| 1394 | if (!t) return null; |
| 1395 | var reads = parseAll(t, vocab, tbl, new Date(), ctx()); |
| 1396 | return reads.length ? reads[0].date : null; |
| 1397 | } |
| 1398 | |
| 1399 | // The calendar overlay, when calendar.js is on the page. Looked |
| 1400 | // up here rather than at load time so the two files may arrive |
| 1401 | // in either order: both are deferred and this runs on |
| 1402 | // DOMContentLoaded, by which point both have executed. |
| 1403 | // |
| 1404 | // Absent, the button falls back to the browser's own panel — |
| 1405 | // the same posture this file takes towards a missing attribute. |
| 1406 | // A page that links one script and not the other gets the old |
| 1407 | // behaviour rather than a dead control. |
| 1408 | var makeCal = (typeof window !== "undefined" && window.fiestaCalendar) || null; |
| 1409 | var cal = null; |
| 1410 | |
| 1411 | if (makeCal && kind !== "time") { |
| 1412 | cal = makeCal({ |
| 1413 | id: id, |
| 1414 | locale: locale, |
| 1415 | labels: { calendar: calLabel, prev: prevLabel, next: nextLabel }, |
| 1416 | value: current, |
| 1417 | bounds: bounds, |
| 1418 | onPick: function (day) { |
| 1419 | // A day chosen on the grid keeps the clock the field |
| 1420 | // already holds: picking the 5th for a meeting says |
| 1421 | // nothing about what time it starts, and throwing the time |
| 1422 | // away would be a second edit nobody asked for. |
| 1423 | var had = current(); |
| 1424 | commit(new Date(day.getFullYear(), day.getMonth(), day.getDate(), |
| 1425 | had ? had.getHours() : 9, had ? had.getMinutes() : 0), kind !== "date"); |
| 1426 | }, |
| 1427 | onDismiss: function () { |
| 1428 | closeAll(); |
| 1429 | input.focus(); |
| 1430 | } |
| 1431 | }); |
| 1432 | cal.el.id = id + "-cal"; |
| 1433 | wrap.appendChild(cal.el); |
| 1434 | } |
| 1435 | |
| 1436 | var pick = null; |
| 1437 | var ownsPopup = !!cal || kind === "time"; |
| 1438 | function pickExpanded(open) { |
| 1439 | if (pick && ownsPopup) pick.setAttribute("aria-expanded", open ? "true" : "false"); |
| 1440 | } |
| 1441 | if (cal || kind === "time" || canPick(native)) { |
| 1442 | pick = document.createElement("button"); |
| 1443 | pick.type = "button"; |
| 1444 | pick.setAttribute("rst-dtp-pick", ""); |
| 1445 | pick.setAttribute("aria-label", kind === "time" ? clockLabel : pickLabel); |
| 1446 | // Popup attributes only where the popup is ours: the grid is a |
| 1447 | // dialog and the clock is a listbox. Without either, the button |
| 1448 | // hands over to the browser's own panel, which is not a |
| 1449 | // listbox and whose open state this page never learns, so it |
| 1450 | // is announced as the plain button it is. |
| 1451 | if (ownsPopup) { |
| 1452 | pick.setAttribute("aria-expanded", "false"); |
| 1453 | pick.setAttribute("aria-haspopup", cal ? "dialog" : "listbox"); |
| 1454 | } |
| 1455 | pick.innerHTML = kind === "time" ? CLOCK_SVG : CAL_SVG; |
| 1456 | wrap.insertBefore(pick, list); |
| 1457 | } |
| 1458 | |
| 1459 | /* ── which popup is open ───────────────────────────────────── |
| 1460 | One region under the field, in one of three states, so there |
| 1461 | is only ever one thing to close, one thing the arrow keys |
| 1462 | drive, and one Escape: |
| 1463 | |
| 1464 | "list" the suggestions — readings, quick picks, the hint |
| 1465 | "cal" the month grid, on a date or datetime field |
| 1466 | "slots" every half hour, on a time field |
| 1467 | |
| 1468 | The calendar REPLACES the suggestions rather than sitting |
| 1469 | beside them, because two popups under one field is two things |
| 1470 | the same keystroke could mean. The words keep working while it |
| 1471 | is open: typing moves the grid, and Enter still commits what |
| 1472 | the text reads as. */ |
| 1473 | |
| 1474 | var mode = ""; |
| 1475 | |
| 1476 | function closeAll() { |
| 1477 | mode = ""; |
| 1478 | list.hidden = true; |
| 1479 | active = -1; |
| 1480 | input.setAttribute("aria-expanded", "false"); |
| 1481 | input.setAttribute("aria-controls", listId); |
| 1482 | input.removeAttribute("aria-activedescendant"); |
| 1483 | if (cal) cal.close(); |
| 1484 | pickExpanded(false); |
| 1485 | wrap.classList.remove("is-above"); |
| 1486 | } |
| 1487 | |
| 1488 | // Which side of the field the popup opens on. Below by default, |
| 1489 | // above where there is no room below and there is room above: a |
| 1490 | // panel hanging off the bottom of the window is a panel whose |
| 1491 | // last week nobody can click. |
| 1492 | function place() { |
| 1493 | var panel = mode === "cal" && cal ? cal.el : list; |
| 1494 | wrap.classList.remove("is-above"); |
| 1495 | if (!panel || panel.hidden) return; |
| 1496 | var box = wrap.getBoundingClientRect(); |
| 1497 | var need = panel.getBoundingClientRect().height; |
| 1498 | if (need > window.innerHeight - box.bottom && box.top > need) { |
| 1499 | wrap.classList.add("is-above"); |
| 1500 | } |
| 1501 | } |
| 1502 | |
| 1503 | function openList() { |
| 1504 | if (locked()) return; |
| 1505 | if (cal) cal.close(); |
| 1506 | mode = "list"; |
| 1507 | list.hidden = false; |
| 1508 | input.setAttribute("aria-expanded", "true"); |
| 1509 | input.setAttribute("aria-controls", listId); |
| 1510 | pickExpanded(false); |
| 1511 | rebuild(input.value); |
| 1512 | place(); |
| 1513 | } |
| 1514 | |
| 1515 | function openCal() { |
| 1516 | if (locked()) return; |
| 1517 | list.hidden = true; |
| 1518 | active = -1; |
| 1519 | input.removeAttribute("aria-activedescendant"); |
| 1520 | mode = "cal"; |
| 1521 | // Opened on what the text already says, so somebody who typed |
| 1522 | // "next march" and then reached for the calendar arrives in |
| 1523 | // March rather than in this month. |
| 1524 | cal.open(previewOf(input.value)); |
| 1525 | input.setAttribute("aria-expanded", "true"); |
| 1526 | input.setAttribute("aria-controls", cal.el.id); |
| 1527 | pickExpanded(true); |
| 1528 | place(); |
| 1529 | } |
| 1530 | |
| 1531 | // A time field has no calendar, so its button opens the clock: |
| 1532 | // every half hour of its own day, written in the field's own |
| 1533 | // locale and scrolled to the one it is already showing. The same |
| 1534 | // list element and the same keys as the suggestions — one popup, |
| 1535 | // one Escape, one set of arrows. |
| 1536 | function openSlots() { |
| 1537 | if (locked()) return; |
| 1538 | var base = current() || new Date(); |
| 1539 | var day = midnight(base); |
| 1540 | var want = base.getHours() * 60 + base.getMinutes(); |
| 1541 | var i, at, best = -1, near = 0, gap; |
| 1542 | mode = "slots"; |
| 1543 | list.hidden = false; |
| 1544 | list.textContent = ""; |
| 1545 | rows = []; |
| 1546 | for (i = 0; i < 48; i++) { |
| 1547 | at = new Date(day.getFullYear(), day.getMonth(), day.getDate(), |
| 1548 | Math.floor(i / 2), (i % 2) * 30); |
| 1549 | gap = Math.abs(at.getHours() * 60 + at.getMinutes() - want); |
| 1550 | if (best < 0 || gap < best) { best = gap; near = rows.length; } |
| 1551 | addRow(show(at), "", at, true, "", "rst-dtp-quick"); |
| 1552 | } |
| 1553 | input.setAttribute("aria-expanded", "true"); |
| 1554 | input.setAttribute("aria-controls", listId); |
| 1555 | pickExpanded(true); |
| 1556 | status.textContent = manyFmt.replace("{n}", String(rows.length)); |
| 1557 | setActive(near); |
| 1558 | place(); |
| 1559 | // The nearest half hour put where the eye lands. The list's own |
| 1560 | // scrollTop rather than scrollIntoView, which would also scroll |
| 1561 | // the page out from under the field. |
| 1562 | if (rows[near]) { |
| 1563 | list.scrollTop = rows[near].li.offsetTop - Math.round(list.clientHeight / 2); |
| 1564 | } |
| 1565 | } |
| 1566 | |
| 1567 | // The native field's own state. A disabled field is disabled |
| 1568 | // throughout, box and button. A readonly one shows its value and |
| 1569 | // refuses every way of changing it: typing, the suggestions, the |
| 1570 | // quick picks, the grid and the clock. Followed as the attributes |
| 1571 | // change, and read again at each commit, since a disabled |
| 1572 | // <fieldset> sets neither attribute; the box needs no mirror for |
| 1573 | // that, sitting inside the same fieldset as the field. |
| 1574 | function locked() { return native.readOnly || native.matches(":disabled"); } |
| 1575 | function follow() { |
| 1576 | input.disabled = native.disabled; |
| 1577 | input.readOnly = native.readOnly; |
| 1578 | if (pick) pick.disabled = native.disabled || native.readOnly; |
| 1579 | if (locked()) closeAll(); |
| 1580 | } |
| 1581 | new MutationObserver(follow).observe(native, { attributes: true, attributeFilter: ["disabled", "readonly"] }); |
| 1582 | follow(); |
| 1583 | |
| 1584 | function commit(date, hasTime) { |
| 1585 | // A field that cannot be changed is not changed, whichever |
| 1586 | // way the value arrived: the box goes back to what it holds. |
| 1587 | if (locked()) { paint(); closeAll(); return; } |
| 1588 | // Ours, so the change listener below leaves the repaint alone: |
| 1589 | // this path has already painted. |
| 1590 | mine = true; |
| 1591 | native.value = toWire(kind, date); |
| 1592 | native.dispatchEvent(new Event("change", { bubbles: true })); |
| 1593 | mine = false; |
| 1594 | input.value = show(date); |
| 1595 | closeAll(); |
| 1596 | if (onCommit) onCommit(date, hasTime); |
| 1597 | } |
| 1598 | |
| 1599 | function commitText() { |
| 1600 | if (locked()) { paint(); return; } |
| 1601 | var text = input.value.trim(); |
| 1602 | if (!text) { |
| 1603 | // Cleared on purpose: an emptied field empties the carrier, |
| 1604 | // and a required native then fails validation the way it |
| 1605 | // would with no script at all. |
| 1606 | input.value = ""; |
| 1607 | if (native.value) { |
| 1608 | mine = true; |
| 1609 | native.value = ""; |
| 1610 | native.dispatchEvent(new Event("change", { bubbles: true })); |
| 1611 | mine = false; |
| 1612 | } |
| 1613 | return; |
| 1614 | } |
| 1615 | var reads = parseAll(text, vocab, tbl, new Date(), ctx()); |
| 1616 | if (reads.length) commit(reads[0].date, reads[0].hasTime); |
| 1617 | else paint(); // unreadable text is not a value: put the old one back |
| 1618 | } |
| 1619 | |
| 1620 | input.addEventListener("focus", function () { |
| 1621 | input.select(); |
| 1622 | if (!mode) openList(); |
| 1623 | }); |
| 1624 | input.addEventListener("click", function () { |
| 1625 | if (!mode) openList(); |
| 1626 | }); |
| 1627 | input.addEventListener("input", function () { |
| 1628 | // The live half of the brief: while the grid is up, the words |
| 1629 | // drive it. Every keystroke re-reads the text and moves the |
| 1630 | // calendar to what it now says, committing nothing and taking |
| 1631 | // focus out of nothing. |
| 1632 | if (mode === "cal" && cal) return cal.show(previewOf(input.value)); |
| 1633 | openList(); |
| 1634 | }); |
| 1635 | input.addEventListener("keydown", function (e) { |
| 1636 | switch (e.key) { |
| 1637 | case "ArrowDown": |
| 1638 | e.preventDefault(); |
| 1639 | // Down out of the box is down into whatever is open: the |
| 1640 | // grid takes focus, and the arrow keys become days. |
| 1641 | if (mode === "cal" && cal) { cal.focusGrid(); break; } |
| 1642 | if (!mode) { openList(); break; } |
| 1643 | if (rows.length) setActive(Math.min(active + 1, rows.length - 1)); |
| 1644 | break; |
| 1645 | case "ArrowUp": |
| 1646 | e.preventDefault(); |
| 1647 | if (mode === "cal" && cal) { cal.focusGrid(); break; } |
| 1648 | if (!mode) { openList(); break; } |
| 1649 | if (rows.length) setActive(Math.max(active - 1, 0)); |
| 1650 | break; |
| 1651 | case "Enter": |
| 1652 | e.preventDefault(); |
| 1653 | if (mode !== "cal" && !list.hidden && active >= 0) commit(rows[active].date, rows[active].hasTime); |
| 1654 | else { commitText(); closeAll(); } |
| 1655 | break; |
| 1656 | case "Escape": |
| 1657 | e.preventDefault(); |
| 1658 | paint(); |
| 1659 | closeAll(); |
| 1660 | break; |
| 1661 | } |
| 1662 | }); |
| 1663 | |
| 1664 | // Enter belongs to the combobox, never to the form: a commit that |
| 1665 | // also submits the page throws the commit away and posts a value |
| 1666 | // nobody finished choosing. The keydown handler above already |
| 1667 | // calls preventDefault, which normally stops this event being |
| 1668 | // generated at all — it is stopped here as well because a |
| 1669 | // SYNTHETIC key injection (CDP, and every browser drive built on |
| 1670 | // it) delivers keydown and keypress as two independent events, |
| 1671 | // so the second one arrives with the first one's refusal |
| 1672 | // discarded. Measured, not guessed: a keypress listener on the |
| 1673 | // page reports defaultPrevented=false for exactly that Enter. |
| 1674 | input.addEventListener("keypress", function (e) { |
| 1675 | if (e.key === "Enter") e.preventDefault(); |
| 1676 | }); |
| 1677 | |
| 1678 | // Focus left the field, so commit what is in the box and shut |
| 1679 | // the popup — but only if it really left. |
| 1680 | // |
| 1681 | // The check is deferred by a tick, and that tick is the whole |
| 1682 | // point. Paging the calendar REBUILDS the grid, which removes the |
| 1683 | // very cell that had focus; the browser fires focusout with a |
| 1684 | // null relatedTarget on the way out and calendar.js puts focus on |
| 1685 | // the new cell immediately afterwards. Reading relatedTarget |
| 1686 | // alone, every arrow key inside the grid looked like a person |
| 1687 | // walking away: the field committed whatever the box happened to |
| 1688 | // say and slammed the panel shut under the cursor. A tick later |
| 1689 | // the answer is not in doubt — document.activeElement is either |
| 1690 | // back inside this field or it is not. |
| 1691 | // A reset accepted after the blur wins over its commit: the reset |
| 1692 | // has blanked the box, and committing that blank would empty the |
| 1693 | // field the reset restored (the accounting is at the top of the |
| 1694 | // page half). A cancelled reset changes nothing, so the commit |
| 1695 | // goes through as it would have. |
| 1696 | wrap.addEventListener("focusout", function () { |
| 1697 | var form = native.form, at = tally(resetsAccepted, form); |
| 1698 | var settle = function () { |
| 1699 | if (wrap.contains(document.activeElement)) return; |
| 1700 | if (tally(resetsPending, form) > 0) { setTimeout(settle, 0); return; } |
| 1701 | if (tally(resetsAccepted, form) !== at) return; |
| 1702 | commitText(); |
| 1703 | closeAll(); |
| 1704 | }; |
| 1705 | setTimeout(settle, 0); |
| 1706 | }); |
| 1707 | |
| 1708 | if (pick) { |
| 1709 | // The press must not blur the box: a blur commits the text, |
| 1710 | // and not committing it yet is the whole reason the button is |
| 1711 | // being pressed. |
| 1712 | pick.addEventListener("mousedown", function (e) { e.preventDefault(); }); |
| 1713 | pick.addEventListener("click", function () { |
| 1714 | if (mode === "cal" || mode === "slots") { |
| 1715 | closeAll(); |
| 1716 | input.focus(); |
| 1717 | return; |
| 1718 | } |
| 1719 | input.focus(); |
| 1720 | if (cal) return openCal(); |
| 1721 | if (kind === "time") return openSlots(); |
| 1722 | // Neither a calendar nor a clock, which means calendar.js is |
| 1723 | // not on the page. The browser's own panel is better than a |
| 1724 | // button that does nothing, and this is the only line in |
| 1725 | // this file that opens it. |
| 1726 | closeAll(); |
| 1727 | try { |
| 1728 | native.showPicker(); |
| 1729 | } catch (err) { |
| 1730 | /* no user gesture, or no picker — nothing to break */ |
| 1731 | } |
| 1732 | }); |
| 1733 | } |
| 1734 | |
| 1735 | // The carrier changed underneath us: a pick in the browser's own |
| 1736 | // panel, a range's start seeding its end, an app's own script. |
| 1737 | // Repaint, so the box shows what the form will actually post. |
| 1738 | // |
| 1739 | // The guard here used to be "unless the combobox has focus", |
| 1740 | // which silently swallowed the one case it most needed to catch. |
| 1741 | // The native panel opens WITHOUT moving focus, so a date picked |
| 1742 | // in it never reached the box and the field looked like it had |
| 1743 | // ignored the selection. It now skips only our own commits, which |
| 1744 | // have painted already, and a change that arrives while somebody |
| 1745 | // is genuinely mid-word with a popup open. |
| 1746 | native.addEventListener("change", function () { |
| 1747 | if (mine) return; |
| 1748 | if (mode && document.activeElement === input) return; |
| 1749 | paint(); |
| 1750 | }); |
| 1751 | |
| 1752 | // A form reset dispatches no change: the carrier goes back to |
| 1753 | // its default, and the box, whose own default is empty, goes |
| 1754 | // blank, so the page would show nothing while the form posted a |
| 1755 | // date. The controls take their reset values only after the |
| 1756 | // event has been dispatched, so the repaint waits a task, and a |
| 1757 | // reset that a listener cancelled leaves everything as it was. |
| 1758 | // On the document, since the field's form is read when the |
| 1759 | // reset happens, not when the field was enhanced. |
| 1760 | document.addEventListener("reset", function (e) { |
| 1761 | if (e.target !== native.form) return; |
| 1762 | setTimeout(function () { |
| 1763 | if (e.defaultPrevented) return; |
| 1764 | closeAll(); |
| 1765 | paint(); |
| 1766 | }, 0); |
| 1767 | }); |
| 1768 | |
| 1769 | paint(); |
| 1770 | |
| 1771 | return { |
| 1772 | get value() { return current(); }, |
| 1773 | commit: commit, |
| 1774 | follow: function (fn) { baseOf = fn; isEnd = true; }, |
| 1775 | committed: function (fn) { onCommit = fn; } |
| 1776 | }; |
| 1777 | } |
| 1778 | |
| 1779 | // Two armed inputs inside one [data-rst-range] are a range: first |
| 1780 | // is the start, second the end, by DOM order. The end's quick picks |
| 1781 | // are relative to the start's current value, and a time typed into |
| 1782 | // the end with no date lands on the start's day. |
| 1783 | function wireRange(wrapper, start, end) { |
| 1784 | end.follow(function () { return start.value; }); |
| 1785 | if (wrapper.getAttribute("data-rst-range") !== "session") return; |
| 1786 | // "session" ranges (a talk, a meeting) seed an empty or backwards |
| 1787 | // end to an hour later. Server-side nothing is seeded, so a |
| 1788 | // submission with scripts off is exactly what was typed. |
| 1789 | start.committed(function (d) { |
| 1790 | var have = end.value; |
| 1791 | if (!have || have <= d) end.commit(new Date(d.getTime() + 3600000), true); |
| 1792 | }); |
| 1793 | } |
| 1794 | |
| 1795 | function scanPage(root) { |
| 1796 | var armed = root.querySelectorAll("input[data-rst-date], input[data-rst-time]"); |
| 1797 | var made = [], i, k, wrappers, inside, pair; |
| 1798 | for (i = 0; i < armed.length; i++) made.push([armed[i], enhance(armed[i])]); |
| 1799 | wrappers = root.querySelectorAll("[data-rst-range]"); |
| 1800 | for (i = 0; i < wrappers.length; i++) { |
| 1801 | inside = wrappers[i].querySelectorAll("input[data-rst-date], input[data-rst-time]"); |
| 1802 | pair = []; |
| 1803 | for (k = 0; k < inside.length && pair.length < 2; k++) { |
| 1804 | for (var j = 0; j < made.length; j++) |
| 1805 | if (made[j][0] === inside[k] && made[j][1]) pair.push(made[j][1]); |
| 1806 | } |
| 1807 | if (pair.length === 2) wireRange(wrappers[i], pair[0], pair[1]); |
| 1808 | } |
| 1809 | } |
| 1810 | |
| 1811 | if (document.readyState === "loading") { |
| 1812 | document.addEventListener("DOMContentLoaded", function () { scanPage(document); }); |
| 1813 | } else { |
| 1814 | scanPage(document); |
| 1815 | } |
| 1816 | } |
| 1817 | |
| 1818 | /* ── the strings ───────────────────────────────────────────────── |
| 1819 | The attribute, then the page's table, then the fallback (the |
| 1820 | header says why). select.js carries the same two functions, so |
| 1821 | each enhancement stays one script tag; check/strings_test.go holds |
| 1822 | the two copies identical. */ |
| 1823 | |
| 1824 | // The table for a tag: an exact match whatever its case, else the |
| 1825 | // first in its primary language (pt-BR reads pt-PT), else null. |
| 1826 | function fiestaPickLang(tag, registry) { |
| 1827 | var want = String(tag || "").toLowerCase(); |
| 1828 | if (!want) return null; |
| 1829 | var keys = Object.keys(registry || {}), i; |
| 1830 | for (i = 0; i < keys.length; i++) { |
| 1831 | if (keys[i].toLowerCase() === want) return keys[i]; |
| 1832 | } |
| 1833 | var primary = want.split("-")[0]; |
| 1834 | for (i = 0; i < keys.length; i++) { |
| 1835 | if (keys[i].toLowerCase().split("-")[0] === primary) return keys[i]; |
| 1836 | } |
| 1837 | return null; |
| 1838 | } |
| 1839 | |
| 1840 | function fiestaWord(el, key, fallback) { |
| 1841 | var v = el.getAttribute("data-rst-" + key); |
| 1842 | if (v !== null && v !== "") return v; |
| 1843 | var registry = (typeof self !== "undefined" && self.fiestaStrings) || {}; |
| 1844 | var lang = fiestaPickLang(document.documentElement.lang, registry); |
| 1845 | var table = lang ? registry[lang] : null; |
| 1846 | return table && table[key] !== undefined && table[key] !== "" ? table[key] : fallback; |
| 1847 | } |
| 1848 | |
| 1849 | // The pure half, for check/node/datetime_node.mjs, and the lookup |
| 1850 | // for node/strings_node.mjs. A browser has no `module` and never |
| 1851 | // takes this branch. |
| 1852 | if (typeof module !== "undefined" && module && module.exports) { |
| 1853 | module.exports = { |
| 1854 | parse: parse, |
| 1855 | parseAll: parseAll, |
| 1856 | tables: tables, |
| 1857 | displayOptions: displayOptions, |
| 1858 | vocabulary: vocabulary, |
| 1859 | fiestaPickLang: fiestaPickLang, |
| 1860 | fiestaWord: fiestaWord |
| 1861 | }; |
| 1862 | } |
| 1863 | })(); |
| 1864 | |