/** * utilities around unicode technical standard 35 * * @see {@link https://unicode.org/reports/tr35/tr35-dates.html#table-date-field-symbol-table} * @module */ import * as Either from "effect/Either"; import { type Codec, decodingError } from "./Codec.js"; import { iife } from "./Function.js"; const entry = ( regexp: RegExp, definition: { type: Type }, ) => [regexp, definition] as const; /** * date formats keyed by regex. * * Explicitly sorted by resolution low -> high and within a resolution from most specific to most generic */ export const dateFormatEntries = [ // era entry(/G{5}/, { type: "era", }), entry(/G{4}/, { type: "era", }), entry(/G{1,3}/, { type: "era", }), // year entry(/y+/, { type: "year", }), entry(/Y+/, { type: "year", }), // month entry(/MMMMM/, { type: "month", }), entry(/MMMM/, { type: "month", }), entry(/MMM/, { type: "month", }), entry(/M{1,2}/, { type: "month", }), entry(/E{1,3}/, { type: "weekday" }), entry(/EEEE/, { type: "weekday" }), entry(/EEEEE/, { type: "weekday" }), entry(/EEEEEE/, { type: "weekday" }), entry(/e/, { type: "weekday" }), entry(/ee/, { type: "weekday" }), entry(/eee/, { type: "weekday" }), entry(/eeee/, { type: "weekday" }), entry(/eeeee/, { type: "weekday" }), entry(/eeeeee/, { type: "weekday" }), entry(/c{1,2}/, { type: "weekday" }), entry(/ccc/, { type: "weekday" }), entry(/cccc/, { type: "weekday" }), entry(/ccccc/, { type: "weekday" }), entry(/cccccc/, { type: "weekday" }), entry(/d{1,2}/, { type: "day" }), entry(/a{1,3}/, { type: "period" }), entry(/aaaa/, { type: "period" }), entry(/aaaaa/, { type: "period" }), entry(/b{1,3}/, { type: "period" }), entry(/bbbb/, { type: "period" }), entry(/bbbbb/, { type: "period" }), entry(/B{1,3}/, { type: "period" }), entry(/BBBB/, { type: "period" }), entry(/BBBBB/, { type: "period" }), entry(/[hH]{1,2}/, { type: "hour" }), entry(/m{1,2}/, { type: "minute" }), entry(/s{1,2}/, { type: "second" }), entry(/S+/, { type: "fractionalSecond" }), entry(/X{1,4}/, { type: "timeZoneName" }), ]; export type PartDefinition = | (typeof dateFormatEntries)[number][1] | { type: "literal" } | { type: "unknown" }; export type FormatPart = PartDefinition & { value: string }; export type PatternPart = { pattern: string; } & PartDefinition; export type ValuePart = PatternPart & { value: string }; const indexOfPart = (part: PartDefinition) => dateFormatEntries.findIndex(({ 1: def }) => def.type === part.type); export const lowToHighResolution = (a: PartDefinition, b: PartDefinition) => indexOfPart(a) - indexOfPart(b); type DatePartMeta = { assign: (value: Date, part: ValuePart) => Date; format: (value: Date, part: PatternPart) => string; match: (off: string, part: PatternPart) => [value: string, remaining: string]; }; const noopMatcher: DatePartMeta["match"] = (off) => ["", off]; const createRegexMatcher = (regex: RegExp): DatePartMeta["match"] => (off) => { const match = regex.exec(off); return match == null ? ["", off] : [match[0], off.replace(match[0], "")]; }; const datePartMeta: Record = { literal: { assign: (value) => value, format: (_, part) => part.pattern, match: (off, part) => !off.startsWith(part.pattern) ? ["", off] : [part.pattern, off.replace(part.pattern, "")], }, period: { assign: (value) => value, format: (_, part) => part.pattern, match: noopMatcher, }, era: { assign: (value) => value, format: (_, part) => part.pattern, match: noopMatcher, }, timeZoneName: { assign: (value, part) => { const localOffset = value.getTimezoneOffset(); const offset = iife(() => { switch (true) { case part.value === "Z": return localOffset; default: return 0; } }); value.setMinutes(value.getMinutes() - offset); return value; }, format: (_, part) => part.pattern, match: (off) => { return !off.startsWith("Z") ? ["", off] : ["Z", off.slice(1)]; }, }, weekday: { assign: (value) => value, format: (_, part) => part.pattern, match: noopMatcher, }, unknown: { assign: (value) => value, format: (_, part) => part.pattern, match: noopMatcher, }, fractionalSecond: { assign: (value, part) => { value.setMilliseconds(Number(part.value)); return value; }, format: (value, part) => { return String(value.getMilliseconds()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d+/), }, second: { assign: (value, part) => { value.setSeconds(Number(part.value)); return value; }, format: (value, part) => { return String(value.getSeconds()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d{1,2}/), }, minute: { assign: (value, part) => { value.setMinutes(Number(part.value)); return value; }, format: (value, part) => { return String(value.getMinutes()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d{1,2}/), }, hour: { assign: (value, part) => { value.setHours(Number(part.value)); return value; }, format: (value, part) => { return String(value.getHours()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d{1,2}/), }, day: { assign: (value, part) => { value.setDate(Number(part.value)); return value; }, format: (value, part) => { return String(value.getDate()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d{1,2}/), }, month: { assign: (value, part) => { value.setMonth(Number(part.value) - 1); return value; }, format: (value, part) => { return String(value.getMonth() + 1).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d{1,2}/), }, year: { assign: (value, part) => { value.setFullYear(Number(part.value)); return value; }, format: (value, part) => { return String(value.getFullYear()).padStart(part.pattern.length, "0"); }, match: createRegexMatcher(/\d+/), }, }; export const patternToParts = (pattern: string): PatternPart[] => { type Part = PatternPart & { start: number; end: number }; const matchedParts = (function tryMatch(pattern: string) { const parts = [] as Part[]; for (const [regex, definition] of dateFormatEntries) { const match = regex.exec(pattern); if (match != null) { const part = { start: match.index, end: match.index + match[0].length, pattern: match[0], ...definition, }; parts.push(part); } } return parts.sort(({ start: a }, { start: b }) => a - b); })(pattern); const allParts = matchedParts.reduce((allParts, part, i, array) => { const previous = array[i - 1]; const previousEnd = previous?.end ?? 0; const separator = pattern.slice(previousEnd, part.start); if (separator !== "") { allParts.push({ type: "literal", pattern: separator, start: previousEnd, end: previousEnd + separator.length, }); } allParts.push(part); return allParts; }, [] as Part[]); const lastPart = allParts.at(-1); if (lastPart != null && lastPart.end < pattern.length) { const ending = pattern.slice(lastPart.end); allParts.push({ type: "literal", pattern: ending, start: lastPart.end, end: lastPart.end + ending.length, }); } return allParts.map(({ start: _1, end: _2, ...part }) => part); }; export const valueToParts = (string: string, pattern: string): ValuePart[] => { return patternToParts(pattern).reduce( (acc, part) => { if (acc.remaining === "") { return acc; } const meta = datePartMeta[part.type]; const [match, remaining] = meta.match(acc.remaining, part); acc.parts.push({ ...part, value: match, }); acc.remaining = remaining; return acc; }, { parts: [] as ValuePart[], remaining: string, }, ).parts; }; export const splitBySeperators = ( string: string, pattern: string, ): FormatPart[] => { return patternToParts(pattern).reduce( (acc, part, i, patternParts) => { if (acc.remaining === "") { return acc; } if (part.type === "literal") { acc.parts.push({ type: part.type, value: part.pattern, }); acc.remaining = acc.remaining.slice(part.pattern.length); return acc; } const nextSeparator = patternParts .slice(i) .find((part) => part.type === "literal")?.pattern; const nextIncision = nextSeparator == null ? acc.remaining.length : acc.remaining.indexOf(nextSeparator); if (nextIncision === -1) { acc.parts.push({ type: part.type, value: acc.remaining }); acc.remaining = ""; return acc; } const value = acc.remaining.slice(0, nextIncision); acc.parts.push({ type: part.type, value }); acc.remaining = acc.remaining.slice(nextIncision); return acc; }, { parts: [] as FormatPart[], remaining: string, }, ).parts; }; export const parse = (value: string, pattern: string) => { const valueParts = valueToParts(value, pattern); const patternParts = patternToParts(pattern).sort(lowToHighResolution); return patternParts.reduce( (date, patternPart) => { const valuePart = valueParts.find( (valuePart) => valuePart.type === patternPart.type, ); if (valuePart == null) { return date; } return datePartMeta[patternPart.type].assign(date, valuePart); }, new Date(0, 0, 1, 0, 0, 0, 0), ); }; export const format = (value: Date, pattern: string) => patternToParts(pattern).reduce((string, part) => { return string + datePartMeta[part.type].format(value, part); }, ""); export const formatToParts = (value: Date, pattern: string) => patternToParts(pattern).map( (part): FormatPart => ({ type: part.type, value: datePartMeta[part.type].format(value, part), }), ); /** * assign a stringified value to a date according to a pattern */ export const assign = (date: Date, pattern: string, value: string) => { if (Number.isNaN(date.valueOf())) { return date; } valueToParts(value, pattern).forEach((part) => { const meta = datePartMeta[part.type]; meta.assign(date, part); }); return date; }; /** * creates a new codec with the given UTS35 pattern */ export const uts35__Date = (pattern: string): Codec => { return { encode: (value: Date) => Number.isNaN(value.valueOf()) ? Either.left(new Error("invalid date")) : Either.right(format(value, pattern)), decode: (value) => { if (value instanceof Date) { return Number.isNaN(value) ? Either.left(new Error("invalid date")) : Either.right(value); } switch (typeof value) { case "number": return Either.right(new Date(value)); case "string": { const parsed = parse(value, pattern); return Number.isNaN(parsed.valueOf()) ? Either.left( new Error(`could not parse ${value} according to ${pattern}`), ) : Either.right(parsed); } default: return Either.left(decodingError(value)); } }, }; };