// What GSF's criteria functions compute (genesis-commons v8.15.29: Expr.java, // TimeUtils.java, ExtraOperators.kt, TimeRange.kt, TimeContext.kt), for the // evaluator in criteria.ts. GSF runs criteria in UTC (Joda's default zone, // set by RxDb.kt), so every date here is a UTC one. // // A Java exception inside a compiled criteria (a date that doesn't parse, a // null field, an unknown TimeRange) makes the row not match, however the // expression goes on: `!Expr.dateIsEqual(F, 'bad')` matches nothing. These // functions throw CriteriaEvaluationError for those, and the evaluator's // predicate answers false. export class CriteriaEvaluationError extends Error { override name = 'CriteriaEvaluationError'; } export const evaluationError = (message: string): never => { throw new CriteriaEvaluationError(message); }; const HOURS_PER_DAY = 24; const MINUTES_PER_HOUR = 60; const SECONDS_PER_MINUTE = 60; const MS_PER_SECOND = 1000; const MS_PER_MINUTE = SECONDS_PER_MINUTE * MS_PER_SECOND; const MS_PER_HOUR = MINUTES_PER_HOUR * MS_PER_MINUTE; export const MS_PER_DAY = HOURS_PER_DAY * MS_PER_HOUR; const DAYS_PER_WEEK = 7; const MONTHS_PER_YEAR = 12; // --- the calendar (proleptic Gregorian, as Joda's ISO chronology) ------------ const DAYS_PER_ERA = 146_097; const YEARS_PER_ERA = 400; const MARCH_BASED_SHIFT = 3; // months before March belong to the year before const DAYS_IN_FIVE_MONTHS = 153; const FIVE_MONTHS = 5; const DAYS_TO_1970 = 719_468; const LEAP_EVERY = 4; const CENTURY = 100; const DAYS_PER_YEAR = 365; const DAYS_PER_4_YEARS = 1460; const DAYS_PER_100_YEARS = 36_524; const DAYS_PER_400_YEARS_MINUS_ONE = 146_096; const FEBRUARY = 2; const LONG_FEBRUARY = 29; const SHORT_FEBRUARY = 28; const APRIL = 4; const JUNE = 6; const SEPTEMBER = 9; const NOVEMBER = 11; const THIRTY_DAY_MONTHS = new Set([APRIL, JUNE, SEPTEMBER, NOVEMBER]); const SHORT_MONTH = 30; const LONG_MONTH = 31; // Days since 1970-01-01 of a date (month 1-12), any year. export function daysFromCivil(year: number, month: number, day: number): number { const y = month <= 2 ? year - 1 : year; const era = Math.floor(y / YEARS_PER_ERA); const yearOfEra = y - era * YEARS_PER_ERA; const monthFromMarch = (month + MONTHS_PER_YEAR - MARCH_BASED_SHIFT) % MONTHS_PER_YEAR; const dayOfYear = Math.floor((DAYS_IN_FIVE_MONTHS * monthFromMarch + 2) / FIVE_MONTHS) + day - 1; const dayOfEra = yearOfEra * DAYS_PER_YEAR + Math.floor(yearOfEra / LEAP_EVERY) - Math.floor(yearOfEra / CENTURY) + dayOfYear; return era * DAYS_PER_ERA + dayOfEra - DAYS_TO_1970; } // The date of a day since 1970-01-01. export function civilFromDays(days: number): { year: number; month: number; day: number } { const z = days + DAYS_TO_1970; const era = Math.floor(z / DAYS_PER_ERA); const dayOfEra = z - era * DAYS_PER_ERA; const yearOfEra = Math.floor( (dayOfEra - Math.floor(dayOfEra / DAYS_PER_4_YEARS) + Math.floor(dayOfEra / DAYS_PER_100_YEARS) - Math.floor(dayOfEra / DAYS_PER_400_YEARS_MINUS_ONE)) / DAYS_PER_YEAR, ); const dayOfYear = dayOfEra - (DAYS_PER_YEAR * yearOfEra + Math.floor(yearOfEra / LEAP_EVERY) - Math.floor(yearOfEra / CENTURY)); const monthFromMarch = Math.floor((FIVE_MONTHS * dayOfYear + 2) / DAYS_IN_FIVE_MONTHS); const day = dayOfYear - Math.floor((DAYS_IN_FIVE_MONTHS * monthFromMarch + 2) / FIVE_MONTHS) + 1; const month = monthFromMarch < MONTHS_PER_YEAR - 2 ? monthFromMarch + MARCH_BASED_SHIFT : monthFromMarch - (MONTHS_PER_YEAR - MARCH_BASED_SHIFT); const year = yearOfEra + era * YEARS_PER_ERA + (month <= 2 ? 1 : 0); return { year, month, day }; } const isLeapYear = (year: number) => year % LEAP_EVERY === 0 && (year % CENTURY !== 0 || year % YEARS_PER_ERA === 0); function daysInMonth(year: number, month: number): number { if (month === FEBRUARY) return isLeapYear(year) ? LONG_FEBRUARY : SHORT_FEBRUARY; return THIRTY_DAY_MONTHS.has(month) ? SHORT_MONTH : LONG_MONTH; } export const epochDay = (millis: number) => Math.floor(millis / MS_PER_DAY); // --- TimeUtils.ALLOWED_DATE_PARSER ------------------------------------------- // One field of a Joda pattern as its parser reads it: up to `max` digits // (at least one, more are never backtracked), a year's optionally signed. interface PatternField { field: 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'fraction'; max: number; } type Pattern = Array; // `yyyy` reads at most four digits when another number follows it, and nine // otherwise (DateTimeFormat.parsePatternTo); MM, dd, HH, mm and ss two; // SSS three. const YEAR: PatternField = { field: 'year', max: 4 }; const LONG_YEAR: PatternField = { field: 'year', max: 9 }; const MONTH: PatternField = { field: 'month', max: 2 }; const DAY: PatternField = { field: 'day', max: 2 }; const HOUR: PatternField = { field: 'hour', max: 2 }; const MINUTE: PatternField = { field: 'minute', max: 2 }; const SECOND: PatternField = { field: 'second', max: 2 }; const FRACTION: PatternField = { field: 'fraction', max: 3 }; // ProcessConstants: DATE_FORMAT_FULL, _TO_SECONDS, _TO_MINUTES, _TO_DATE // and DATE_FORMAT_ISO8601_TO_DATE, in ALLOWED_DATE_PARSER's order. const TO_DATE: Pattern = [YEAR, MONTH, DAY]; const TO_MINUTES: Pattern = [...TO_DATE, '-', HOUR, ':', MINUTE]; const TO_SECONDS: Pattern = [...TO_MINUTES, ':', SECOND]; const FULL: Pattern = [...TO_SECONDS, '.', FRACTION]; const ISO8601_TO_DATE: Pattern = [LONG_YEAR, '-', MONTH, '-', DAY]; const ALLOWED_PATTERNS = [FULL, TO_SECONDS, TO_MINUTES, TO_DATE, ISO8601_TO_DATE]; interface DateFields { year: number; month: number; day: number; hour: number; minute: number; second: number; millis: number; } const DIGIT = /\d/; const FRACTION_DIGITS = 3; // The fields of `text` when the pattern reads all of it, else undefined. function readPattern(text: string, pattern: Pattern): DateFields | undefined { const fields: DateFields = { year: 0, month: 1, day: 1, hour: 0, minute: 0, second: 0, millis: 0, }; let at = 0; for (const part of pattern) { if (typeof part === 'string') { if (text[at] !== part) return undefined; at += 1; continue; } let sign = 1; if (part.field === 'year' && (text[at] === '-' || text[at] === '+')) { if (!DIGIT.test(text[at + 1] ?? '')) return undefined; sign = text[at] === '-' ? -1 : 1; at += 1; } let digits = ''; while (digits.length < part.max && DIGIT.test(text[at] ?? '')) { digits += text[at]; at += 1; } if (digits === '') return undefined; if (part.field === 'fraction') { fields.millis = Number(digits.padEnd(FRACTION_DIGITS, '0')); } else { fields[part.field] = sign * Number(digits); } } return at === text.length ? fields : undefined; } // Joda's ISO chronology years. const MIN_YEAR = -292_275_054; const MAX_YEAR = 292_278_993; const MAX_HOUR = 23; const MAX_MINUTE = 59; const MAX_SECOND = 59; // The fields' instant, or a Joda IllegalFieldValueException. function instantOf(fields: DateFields, text: string): number { const { year, month, day, hour, minute, second, millis } = fields; const valid = year >= MIN_YEAR && year <= MAX_YEAR && month >= 1 && month <= MONTHS_PER_YEAR && day >= 1 && day <= daysInMonth(year, month) && hour <= MAX_HOUR && minute <= MAX_MINUTE && second <= MAX_SECOND; if (!valid) evaluationError(`Cannot parse "${text}": a field value is out of range`); return ( daysFromCivil(year, month, day) * MS_PER_DAY + hour * MS_PER_HOUR + minute * MS_PER_MINUTE + second * MS_PER_SECOND + millis ); } function parseWith(text: string, patterns: Pattern[]): number { for (const pattern of patterns) { const fields = readPattern(text, pattern); if (fields) return instantOf(fields, text); } return evaluationError(`Invalid format: "${text}"`); } // Expr.stringDateTime / TimeUtils.parseDateTime: the instant a criteria date // names — yyyyMMdd-HH:mm:ss.SSS, yyyyMMdd-HH:mm:ss, yyyyMMdd-HH:mm, yyyyMMdd // or yyyy-MM-dd, in UTC. Joda reads fewer digits than the pattern shows // ('2024-1-1', '20240101-9:5') and a fraction of a second ('.5' is 500 ms), // nothing else: no 'T', no offset, no spaces. export const stringDateTime = (text: string): number => parseWith(text, ALLOWED_PATTERNS); // Expr.stringLocalDate / TimeUtils.parseLocalDate: the same formats, as a day // (days since 1970-01-01). export const stringLocalDate = (text: string): number => epochDay(stringDateTime(text)); // Expr.singlePatternLocalDate: the day a yyyyMMdd date names — the only form // the Long and DateTime overloads of dateIs* take for the date compared to. export const singlePatternLocalDate = (text: string): number => epochDay(parseWith(text, [TO_DATE])); // DateTime.now().toLocalDate() in UTC. export const today = () => epochDay(Date.now()); // --- strings ----------------------------------------------------------------- // Character.isWhitespace: the Unicode space separators except the no-break // ones, and the ASCII controls \t \n \v \f \r and \u001C-\u001F. const JAVA_WHITESPACE = /^[\t\n\v\f\r\p{Zl}\p{Zp}\p{Zs}]$/u; const NO_BREAK_SPACE = /^[\u00A0\u2007\u202F]$/u; const FILE_SEPARATOR = 0x1c; const UNIT_SEPARATOR = 0x1f; const isJavaWhitespace = (char: string) => { const code = char.charCodeAt(0); if (code >= FILE_SEPARATOR && code <= UNIT_SEPARATOR) return true; return JAVA_WHITESPACE.test(char) && !NO_BREAK_SPACE.test(char); }; // String.isBlank. export const isBlank = (text: string) => [...text].every(isJavaWhitespace); const upper = (char: string) => { const result = char.toUpperCase(); return result.length === 1 ? result : char; }; const lower = (char: string) => { const result = char.toLowerCase(); return result.length === 1 ? result : char; }; // String.regionMatches(true, ...): char by char, equal as they are, upper // cased, or upper then lower cased. function regionMatchesIgnoreCase( text: string, offset: number, other: string, length: number, ): boolean { for (let index = 0; index < length; index += 1) { const a = text[offset + index]; const b = other[index]; if (a === b) continue; const [ua, ub] = [upper(a), upper(b)]; if (ua === ub || lower(ua) === lower(ub)) continue; return false; } return true; } // Expr.containsIgnoreCase and the String?.containsIgnoreCase extension. export function containsIgnoreCase(text: string | null, search: string | null): boolean { if (text === null || search === null) return false; if (search.length === 0) return true; for (let at = text.length - search.length; at >= 0; at -= 1) { if (regionMatchesIgnoreCase(text, at, search, search.length)) return true; } return false; } // Expr.containsWordsStartingWithIgnoreCase and its String? extension: whether // a word of `text` — split on the delimiter, ' ' by default — starts with // `search`, ignoring case. A word is never shorter than the search. export function containsWordsStartingWithIgnoreCase( text: string | null, search: string | null, delimiter = ' ', ): boolean { if (text === null || search === null) return false; const length = search.length; if (length === 0) return true; let start = 0; let end: number; do { end = text.indexOf(delimiter, start); if (end === -1) end = text.length; if (end - start >= length && regionMatchesIgnoreCase(text, start, search, length)) { return true; } start = end + 1; } while (end !== text.length); return false; } // --- Expr.dateTimeIsInRange -------------------------------------------------- const TIME_CONTEXT_OFFSETS: Record = { PREVIOUS: 1, CURRENT: 0 }; const SATURDAY = 6; const SUNDAY = 7; const THURSDAY_OFFSET = 3; // 1970-01-01 was a Thursday // ISO day of week, Monday 1 to Sunday 7. const dayOfWeek = (day: number) => ((((day + THURSDAY_OFFSET) % DAYS_PER_WEEK) + DAYS_PER_WEEK) % DAYS_PER_WEEK) + 1; const startOfMonth = (year: number, month: number) => { const months = year * MONTHS_PER_YEAR + (month - 1); const y = Math.floor(months / MONTHS_PER_YEAR); return daysFromCivil(y, months - y * MONTHS_PER_YEAR + 1, 1) * MS_PER_DAY; }; // TimeRange.getTimeBounds: [beginning, end) in epoch millis. An enum name // TimeRange.valueOf / TimeContext.valueOf doesn't know (they are case // sensitive) throws. export function timeBounds(range: string, context: string, now: number): [number, number] { const offset = Object.hasOwn(TIME_CONTEXT_OFFSETS, context) ? TIME_CONTEXT_OFFSETS[context] : evaluationError(`No enum constant TimeContext.${context}`); const day = epochDay(now); const { year, month } = civilFromDays(day); switch (range) { case 'HOUR': { const beginning = Math.floor(now / MS_PER_HOUR) * MS_PER_HOUR - offset * MS_PER_HOUR; return [beginning, beginning + MS_PER_HOUR]; } case 'DAY': { const beginning = (day - offset) * MS_PER_DAY; return [beginning, beginning + MS_PER_DAY]; } case 'WEEK': { const shifted = day - offset * DAYS_PER_WEEK; const monday = shifted - (dayOfWeek(shifted) - 1); return [monday * MS_PER_DAY, (monday + DAYS_PER_WEEK) * MS_PER_DAY]; } case 'WEEK_DAY': { const base = day - offset; if (offset === 0) { return dayOfWeek(base) >= SATURDAY ? [0, 0] : [base * MS_PER_DAY, (base + 1) * MS_PER_DAY]; } const weekday = dayOfWeek(base); const friday = weekday === SATURDAY ? base - 1 : weekday === SUNDAY ? base - 2 : base; return [friday * MS_PER_DAY, (friday + 1) * MS_PER_DAY]; } case 'MONTH': return [startOfMonth(year, month - offset), startOfMonth(year, month - offset + 1)]; case 'YEAR': return [ daysFromCivil(year - offset, 1, 1) * MS_PER_DAY, daysFromCivil(year - offset + 1, 1, 1) * MS_PER_DAY, ]; default: return evaluationError(`No enum constant TimeRange.${range}`); } } // TimeRange.valueOf(range).isInTimeRange(dateTime, TimeContext.valueOf(context)). export function dateTimeIsInRange(millis: number, range: string, context: string): boolean { const [beginning, end] = timeBounds(range, context, Date.now()); return millis >= beginning && millis < end; }