Last active
April 7, 2023 19:00
-
-
Save brookjordan/846463a23615ee390d2c8839a64034f5 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class ISO8601 { | |
#originalString: ParsedISO["originalString"]; | |
#repetitions?: ParsedISO["repetitions"]; | |
#dates: | |
| Readonly<[Readonly<ISOPart>, Readonly<ISODatePart>]> | |
| Readonly<[Readonly<ISODatePart>, Readonly<ISOPart>]> | |
| Readonly<[Readonly<ISOPart>]>; | |
get originalString() { | |
return this.#originalString; | |
} | |
get repetitions() { | |
return this.#repetitions; | |
} | |
get dates() { | |
return this.#dates; | |
} | |
get inclusiveStartString() { | |
const inclusiveStart = this.inclusiveStart; | |
if (!inclusiveStart) { | |
return null; | |
} | |
return getISODateString(inclusiveStart); | |
} | |
get exclusiveEndString() { | |
const exclusiveEnd = this.exclusiveEnd; | |
if (!exclusiveEnd) { | |
return null; | |
} | |
return getISODateString(exclusiveEnd); | |
} | |
get singleRepinclusiveStartString() { | |
const singleRepinclusiveStart = this.singleRepInclusiveStart; | |
if (!singleRepinclusiveStart) { | |
return null; | |
} | |
return getISODateString(singleRepinclusiveStart); | |
} | |
get singleRepExclusiveEndString() { | |
const firstRepExclusiveEnd = this.singleRepExclusiveEnd; | |
if (!firstRepExclusiveEnd) { | |
return null; | |
} | |
return getISODateString(firstRepExclusiveEnd); | |
} | |
get fullISOString() { | |
const endISOString = this.singleRepExclusiveEndString; | |
return `${this.#repetitions ? `R${this.#repetitions}/` : ""}${ | |
this.singleRepInclusiveStart | |
}${endISOString ? `/${endISOString}` : ""}`; | |
} | |
get inclusiveStart() { | |
const startISO = this.#dates[0]; | |
return isDatePart(startISO) | |
? startISO | |
: getUnambiguousDateRange(this.#dates, this.#repetitions).inclusiveStart; | |
} | |
get exclusiveEnd() { | |
const endISO = this.#dates[1]; | |
return isDatePart(endISO) | |
? endISO | |
: getUnambiguousDateRange(this.#dates, this.#repetitions).exclusiveEnd; | |
} | |
get singleRepInclusiveStart() { | |
const startISO = this.#dates[0]; | |
return isDatePart(startISO) | |
? startISO | |
: getUnambiguousDateRange(this.#dates).inclusiveStart; | |
} | |
get singleRepExclusiveEnd() { | |
const endISO = this.#dates[1]; | |
return isDatePart(endISO) | |
? endISO | |
: getUnambiguousDateRange(this.#dates).exclusiveEnd; | |
} | |
constructor(ISO8601String: string) { | |
const { repetitions, dates } = parseISO(ISO8601String); | |
this.#originalString = ISO8601String; | |
this.#repetitions = repetitions; | |
this.#dates = dates; | |
} | |
} | |
interface ISODate { | |
year?: bigint | undefined; | |
month?: bigint | undefined; | |
week?: bigint | undefined; | |
day?: bigint | undefined; | |
} | |
interface ISOTime { | |
hour?: bigint | undefined; | |
minute?: bigint | undefined; | |
second?: bigint | undefined; | |
millisecond?: bigint | undefined; | |
} | |
interface ISOTimezone { | |
tzDirection?: bigint | undefined; | |
tzHours?: bigint | undefined; | |
tzMinutes?: bigint | undefined; | |
} | |
type ISOFullTime = ISOTime & ISOTimezone; | |
interface ISOBaseMeta { | |
datetimeString?: string; | |
dateString?: string | undefined; | |
timeString?: string | undefined; | |
} | |
interface ISODateMeta extends ISOBaseMeta { | |
format: "calendar" | "week" | "ordinal"; | |
} | |
interface ISODurationMeta extends ISOBaseMeta { | |
format: "duration"; | |
} | |
type ISOMeta = ISODateMeta | ISODurationMeta; | |
type ISOPartBase = ISODate & ISOFullTime; | |
type ISODatePart = ISOPartBase & ISODateMeta; | |
type ISODurationPart = ISOPartBase & ISODurationMeta; | |
type ISOPart = ISOPartBase & ISOMeta; | |
interface ParsedISO { | |
originalString?: string | undefined; | |
repetitions?: bigint | undefined; | |
dates: [ISOPart] | [ISODatePart, ISOPart] | [ISOPart, ISODatePart]; | |
} | |
const isDurationPart = (iso?: ISOPart | null): iso is ISODurationPart => | |
iso?.format === "duration"; | |
const isDatePart = (iso?: ISOPart | null): iso is ISODatePart => | |
!!iso && iso.format !== "duration"; | |
const firstValue: { [PartName in keyof (ISODate & ISOFullTime)]: bigint } = { | |
year: 0n, | |
month: 1n, | |
week: 1n, | |
day: 1n, | |
hour: 0n, | |
minute: 0n, | |
second: 0n, | |
millisecond: 0n, | |
tzDirection: 0n, | |
tzHours: 0n, | |
tzMinutes: 0n, | |
} as const; | |
const pad = ( | |
number: bigint | number | undefined, | |
padLength = 2, | |
fallback = 0 | |
) => String(number ?? fallback).padStart(padLength, "0"); | |
const getISODateString = ({ | |
format, | |
year: YYYY, | |
month: MM, | |
week: WW, | |
day: DD, | |
hour: hh, | |
minute: mm, | |
second: ss, | |
millisecond: ms, | |
tzDirection: tD, | |
tzHours: tH, | |
tzMinutes: tM, | |
}: ISODatePart) => { | |
const tzSign = tD === -1n ? "-" : "+"; | |
return format === "week" | |
? `${pad(YYYY, 4)}-W${pad(WW, 2, 1)}-${pad(DD, 2, 1)}T${pad(hh)}:${pad( | |
mm | |
)}:${pad(ss)}${ms ? `.${pad(ms, 3)}` : ""}${tzSign}${pad(tH)}:${pad(tM)}` | |
: format === "ordinal" | |
? `${pad(YYYY, 4)}-${pad(DD, 3, 1)}T${pad(hh)}:${pad(mm)}:${pad(ss)}${ | |
ms ? `.${pad(ms, 3)}` : "" | |
}${tzSign}${pad(tH)}:${pad(tM)}` | |
: `${pad(YYYY, 4)}-${pad(MM, 2, 1)}-${pad(DD, 2, 1)}T${pad(hh)}:${pad( | |
mm | |
)}:${pad(ss)}${ms ? `.${pad(ms, 3)}` : ""}${tzSign}${pad(tH)}:${pad(tM)}`; | |
}; | |
const matchAccuracy = <Part extends ISOPart>(date: Part, toMatch: Part) => { | |
const newDate = { | |
format: date.format, | |
} as Part; | |
(Object.keys(toMatch) as (keyof ISOPart)[]).forEach((partName) => { | |
if (partName === "format") return; | |
newDate[partName] = date[partName] ?? firstValue[partName]; | |
}); | |
(Object.entries(date) as [keyof ISOPart, ISOPart[keyof ISOPart]][]).forEach( | |
([partName, value]) => { | |
if (partName === "format") return; | |
if ( | |
typeof newDate[partName] !== "bigint" && | |
value !== firstValue[partName] | |
) { | |
// TODO: type this correctly, it was giving me trouble earlier | |
// newDate[partName] = value as bigint; | |
newDate[partName] = value as any; | |
} | |
} | |
); | |
return newDate; | |
}; | |
const addDurationToDate = ( | |
startDate: ISODatePart, | |
duration: ISODurationPart | |
) => { | |
const date = new Date(getISODateString(startDate)); | |
const years = duration.year ? Number(duration.year) : 0; | |
const months = duration.month ? Number(duration.month) : 0; | |
const days = duration.day ? Number(duration.day) : 0; | |
const hours = duration.hour ? Number(duration.hour) : 0; | |
const minutes = duration.minute ? Number(duration.minute) : 0; | |
const seconds = duration.second ? Number(duration.second) : 0; | |
const milliseconds = duration.millisecond ? Number(duration.millisecond) : 0; | |
date.setFullYear(date.getFullYear() + years); | |
const newMonth = date.getMonth() + months; | |
const yearDelta = Math.floor(newMonth / 12); | |
date.setMonth(newMonth % 12); | |
date.setFullYear(date.getFullYear() + yearDelta); | |
const newDate = date.getDate() + days; | |
date.setDate(newDate); | |
date.setHours(date.getHours() + hours); | |
date.setMinutes(date.getMinutes() + minutes); | |
date.setSeconds(date.getSeconds() + seconds); | |
date.setMilliseconds(date.getMilliseconds() + milliseconds); | |
return matchAccuracy( | |
new ISO8601(date.toISOString()).dates[0] as ISODatePart, | |
startDate | |
); | |
}; | |
const subtractDurationFromDate = ( | |
startDate: ISODatePart, | |
duration: ISODurationPart | |
) => { | |
const date = new Date(getISODateString(startDate)); | |
const years = duration.year ? Number(duration.year) : 0; | |
const months = duration.month ? Number(duration.month) : 0; | |
const days = duration.day ? Number(duration.day) : 0; | |
const hours = duration.hour ? Number(duration.hour) : 0; | |
const minutes = duration.minute ? Number(duration.minute) : 0; | |
const seconds = duration.second ? Number(duration.second) : 0; | |
const milliseconds = duration.millisecond ? Number(duration.millisecond) : 0; | |
date.setFullYear(date.getFullYear() - years); | |
const newMonth = date.getMonth() - months; | |
if (newMonth < 0) { | |
const yearDelta = Math.ceil(-newMonth / 12); | |
date.setFullYear(date.getFullYear() - yearDelta); | |
date.setMonth(date.getMonth() + 12 * yearDelta - months); | |
} else { | |
date.setMonth(newMonth); | |
} | |
const newDate = date.getDate() - days; | |
if (newDate < 1) { | |
const monthDays = new Date( | |
date.getFullYear(), | |
date.getMonth() + 1, | |
0 | |
).getDate(); | |
const monthDelta = Math.ceil(-newDate / monthDays); | |
date.setMonth(date.getMonth() - monthDelta); | |
date.setDate(date.getDate() + monthDays * monthDelta - days); | |
} else { | |
date.setDate(newDate); | |
} | |
date.setHours(date.getHours() - hours); | |
date.setMinutes(date.getMinutes() - minutes); | |
date.setSeconds(date.getSeconds() - seconds); | |
date.setMilliseconds(date.getMilliseconds() - milliseconds); | |
return matchAccuracy( | |
new ISO8601(date.toISOString()).dates[0] as ISODatePart, | |
startDate | |
); | |
}; | |
const dateExclusiveEnd = (date: ISODatePart) => { | |
if ("millisecond" in date) { | |
return addDurationToDate(date, { format: "duration", millisecond: 1n }); | |
} | |
if ("second" in date) { | |
return addDurationToDate(date, { format: "duration", second: 1n }); | |
} | |
if ("minute" in date) { | |
return addDurationToDate(date, { format: "duration", minute: 1n }); | |
} | |
if ("hour" in date) { | |
return addDurationToDate(date, { format: "duration", hour: 1n }); | |
} | |
if ("day" in date) { | |
return addDurationToDate(date, { format: "duration", day: 1n }); | |
} | |
if ("week" in date) { | |
return addDurationToDate(date, { format: "duration", week: 1n }); | |
} | |
if ("month" in date) { | |
return addDurationToDate(date, { format: "duration", millisecond: 1n }); | |
} | |
if ("year" in date) { | |
return addDurationToDate(date, { format: "duration", year: 1n }); | |
} | |
return date; | |
}; | |
const getUnambiguousDateRange = ( | |
dates: | |
| Readonly<[Readonly<ISOPart>]> | |
| Readonly<[Readonly<ISOPart>, Readonly<ISODatePart>]> | |
| Readonly<[Readonly<ISODatePart>, Readonly<ISOPart>]>, | |
_repetitions?: bigint | undefined | |
): { inclusiveStart: ISODatePart | null; exclusiveEnd: ISODatePart | null } => { | |
const repetitions = _repetitions ?? 1n; | |
if (repetitions && repetitions < 1n) { | |
throw new Error("Cannot calculate zero, negative or infinite repetitions."); | |
} | |
const startISO = dates[0]; | |
const endISO = dates[1]; | |
if ( | |
startISO.format === "duration" && | |
(!endISO || endISO.format === "duration") | |
) { | |
throw new Error( | |
"Cannot calculate start and end dates without at least one date." | |
); | |
} | |
let inclusiveStart: ISODatePart | null; | |
let exclusiveEnd: ISODatePart | null; | |
if (isDatePart(startISO)) { | |
inclusiveStart = startISO; | |
} else if (isDurationPart(startISO)) { | |
if (isDatePart(endISO)) { | |
inclusiveStart = subtractDurationFromDate(endISO, startISO); | |
} else { | |
inclusiveStart = null; | |
} | |
} else { | |
inclusiveStart = null; | |
} | |
if (isDatePart(endISO)) { | |
exclusiveEnd = endISO; | |
} else if (isDurationPart(endISO)) { | |
if (isDatePart(startISO)) { | |
exclusiveEnd = dateExclusiveEnd(addDurationToDate(startISO, endISO)); | |
} else { | |
exclusiveEnd = null; | |
} | |
} else { | |
if (isDatePart(startISO)) { | |
exclusiveEnd = dateExclusiveEnd(startISO); | |
} else { | |
exclusiveEnd = null; | |
} | |
} | |
return { | |
inclusiveStart, | |
exclusiveEnd, | |
}; | |
}; | |
const valuefulGroups = (parts: RegExpExecArray | null) => | |
Object.fromEntries( | |
Object.entries(parts?.groups || {}) | |
.filter( | |
([durationName, count]) => | |
Number.isNaN(+durationName) && !Number.isNaN(+count) | |
) | |
.map(([durationName, count]) => [ | |
durationName, | |
Number.isNaN(parseFloat(count.replace(",", "."))) | |
? null | |
: BigInt(parseFloat(count.replace(",", "."))), | |
]) | |
.filter(([durationName, count]) => !Number.isNaN(count)) | |
); | |
const parseDateDuration = (duration: string): ISODate => | |
valuefulGroups( | |
/^((?<year>\d+([.,]\d+)?)Y)?((?<month>\d+([.,]\d+)?)M)?((?<week>\d+([.,]\d+)?)W)?((?<day>\d+([.,]\d+)?)D)?$/i.exec( | |
duration | |
) | |
); | |
const parseTimeDuration = (duration: string): ISOTime => | |
valuefulGroups( | |
/^((?<hour>\d+([.,]\d+)?)H)?((?<minute>\d+([.,]\d+)?)M)?((?<second>\d+([.,]\d+)?)S)?$/i.exec( | |
duration | |
) | |
); | |
const parseDate = ( | |
date: string | |
): { parsedDate: ISODate; format: ISOMeta["format"] } | null => { | |
if (date.includes("W")) { | |
return { | |
format: "week", | |
parsedDate: valuefulGroups( | |
/^(?<year>\d{4})(-W(?<week>\d{1,2})(-(?<day>\d))?)?$/i.exec(date) || | |
/^(?<year>\d{4})(W(?<week>\d{1,2})((?<day>\d))?)?$/i.exec(date) | |
), | |
}; | |
} else if (/^\d{4}?\d{3}(T|$)/.test(date)) { | |
return { | |
format: "ordinal", | |
parsedDate: valuefulGroups( | |
/^(?<year>\d{4})(-(?<day>\d{3}))$/i.exec(date) || | |
/^(?<year>\d{4})((?<day>\d{3}))$/i.exec(date) | |
), | |
}; | |
} else { | |
return { | |
format: "calendar", | |
parsedDate: valuefulGroups( | |
/^(?<year>\d{4})(-(?<month>\d{2})(-(?<day>\d{2}))?)?$/i.exec(date) || | |
/^(?<year>\d{4})((?<month>\d{2})((?<day>\d{2}))?)?$/i.exec(date) | |
), | |
}; | |
} | |
}; | |
const parseTime = (time: string): ISOTime => { | |
const timeParts = time.split(/[-+Z]/); | |
const timeGroups: ISOTime = valuefulGroups( | |
/^(?<hour>\d{2})(:(?<minute>\d{2})(:(?<second>\d{2})([.,](?<decimalSecond>\d+))?)?)?$/i.exec( | |
timeParts[0] | |
) || | |
/^(?<hour>\d{2})((?<minute>\d{2})((?<second>\d{2})([.,](?<decimalSecond>\d+))?)?)?$/i.exec( | |
timeParts[0] | |
) | |
); | |
if ("decimalSecond" in timeGroups) { | |
timeGroups.millisecond = BigInt( | |
String(timeGroups.decimalSecond).padEnd(3, "0").slice(0, 3) | |
); | |
delete timeGroups.decimalSecond; | |
} | |
const tzParts: ISOTimezone = time.endsWith("Z") | |
? { | |
tzDirection: 0n, | |
tzHours: 0n, | |
tzMinutes: 0n, | |
} | |
: valuefulGroups( | |
/^(?<tzHours>\d{2})(\:?(?<tzMinutes>\d{2}))?$/i.exec(timeParts[1]) | |
); | |
const parsedTime: ISOFullTime = { | |
...timeGroups, | |
...tzParts, | |
}; | |
if (time.includes("-")) { | |
parsedTime.tzDirection = -1n; | |
} else if (time.includes("+")) { | |
parsedTime.tzDirection = 1n; | |
} | |
return parsedTime; | |
}; | |
const isDatesTuple = ( | |
dates: ParsedISO["dates"][number][] | |
): dates is ParsedISO["dates"] => [1, 2].includes(dates.length); | |
const parseISO = (isoDate: string): ParsedISO => { | |
const split = isoDate.split("/"); | |
const dates: ParsedISO["dates"][number][] = []; | |
let repetitions: bigint | undefined; | |
split.forEach((stringPart) => { | |
if (!stringPart.startsWith("R")) { | |
const part: Partial<ISOPart> = {}; | |
if (stringPart.startsWith("P")) { | |
part.format = "duration"; | |
part.datetimeString = stringPart.slice(1); | |
} else { | |
part.datetimeString = stringPart; | |
} | |
const dateAndTime = part.datetimeString.split("T"); | |
if (dateAndTime.length > 1) { | |
part.dateString = dateAndTime[0]; | |
part.timeString = dateAndTime[1]; | |
} else if (!dateAndTime[0]) { | |
// do nothing | |
} else if (dateAndTime[0].includes(":")) { | |
part.timeString = dateAndTime[0]; | |
} else { | |
part.dateString = dateAndTime[0]; | |
} | |
dates.push(part as ISOPart); | |
} else if (!stringPart.slice(1) || stringPart.slice(1) === "-1") { | |
repetitions = -1n; | |
} else { | |
repetitions = BigInt(parseInt(stringPart.slice(1))); | |
} | |
}); | |
dates.forEach((part, index, parts) => { | |
const dateString = part.dateString; | |
const timeString = part.timeString; | |
if (part.format === "duration") { | |
if (dateString) { | |
parts[index] = { | |
...parts[index], | |
...parseDateDuration(dateString), | |
}; | |
} | |
if (timeString) { | |
parts[index] = { | |
...parts[index], | |
...parseTimeDuration(timeString), | |
}; | |
} | |
} else { | |
if (dateString) { | |
const parsedDate = parseDate(dateString); | |
if (parsedDate) { | |
parts[index] = { | |
...parts[index], | |
...parsedDate.parsedDate, | |
format: parsedDate.format, | |
}; | |
} | |
} | |
if (timeString) { | |
parts[index] = { | |
...parts[index], | |
...parseTime(timeString), | |
}; | |
} | |
} | |
}); | |
if (!isDatesTuple(dates)) { | |
throw new Error("No dates in the string"); | |
} | |
const parsedISO: ParsedISO = { | |
dates, | |
originalString: isoDate, | |
}; | |
if (repetitions) { | |
parsedISO.repetitions = repetitions; | |
} | |
return parsedISO; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment