UNPKG

universal-common

Version:

Library that provides useful missing base class library functionality.

791 lines (702 loc) 30.8 kB
import ArgumentError from './ArgumentError.js'; import DateTime from './DateTime.js'; import DateTimeKind from './DateTimeKind.js'; import TimeSpan from './TimeSpan.js'; import DateTimeFormatInfo from './DateTimeFormatInfo.js'; /** * Provides functionality to format DateTime instances according to format strings. * This is the main formatting engine that processes custom format patterns. */ export default class DateTimeFormat { // Maximum number of fractional second digits supported static MAX_SECONDS_FRACTION_DIGITS = 7; // Constant representing null offset for DateTime (vs DateTimeOffset) static NULL_OFFSET = Number.MIN_SAFE_INTEGER; // All standard format characters static ALL_STANDARD_FORMATS = "dDfFgGmMoOrRstTuUyY"; // Standard format patterns static ROUNDTRIP_FORMAT = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffffffK"; static ROUNDTRIP_DATETIME_UNFIXED = "yyyy'-'MM'-'ddTHH':'mm':'ss zzz"; // Format length constants static FORMAT_O_MIN_LENGTH = 27; static FORMAT_O_MAX_LENGTH = 33; static FORMAT_INVARIANT_G_MIN_LENGTH = 19; static FORMAT_INVARIANT_G_MAX_LENGTH = 26; static FORMAT_R_LENGTH = 29; static FORMAT_S_LENGTH = 19; static FORMAT_U_LENGTH = 20; // Fixed number formats for fractional seconds static FIXED_NUMBER_FORMATS = [ "0", "00", "000", "0000", "00000", "000000", "0000000" ]; // Invariant format info cache static #invariantFormatInfo = null; // Invariant abbreviated month/day names static #invariantAbbreviatedMonthNames = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", "" ]; static #invariantAbbreviatedDayNames = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ]; /** * Gets the invariant DateTimeFormatInfo instance. * * @type {DateTimeFormatInfo} * @readonly * @static */ static get invariantFormatInfo() { if (!DateTimeFormat.#invariantFormatInfo) { DateTimeFormat.#invariantFormatInfo = DateTimeFormatInfo.invariantInfo; } return DateTimeFormat.#invariantFormatInfo; } /** * Formats a positive integer with leading zeros. * * @private * @param {number} value - The value to format * @param {number} minimumLength - Minimum length with leading zeros * @returns {string} Formatted number string */ static #formatDigits(value, minimumLength) { if (value < 0) { throw new ArgumentError("DateTimeFormat.formatDigits(): value must be >= 0"); } return value.toString().padStart(minimumLength, '0'); } /** * Parses the repeat count of a pattern character. * * @private * @param {string} format - The format string * @param {number} pos - Starting position * @param {string} patternChar - The character to count * @returns {number} Number of consecutive pattern characters */ static #parseRepeatPattern(format, pos, patternChar) { let index = pos + 1; while (index < format.length && format[index] === patternChar) { index++; } return index - pos; } /** * Formats day of week name. * * @private * @param {number} dayOfWeek - Day of week (0-6, Sunday=0) * @param {number} repeat - Number of pattern characters * @param {DateTimeFormatInfo} dtfi - Format info * @returns {string} Formatted day name */ static #formatDayOfWeek(dayOfWeek, repeat, dtfi) { if (dayOfWeek < 0 || dayOfWeek > 6) { throw new ArgumentError("Day of week out of range"); } if (repeat === 3) { return dtfi.getAbbreviatedDayName(dayOfWeek); } return dtfi.getDayName(dayOfWeek); } /** * Formats month name. * * @private * @param {number} month - Month (1-12) * @param {number} repeatCount - Number of pattern characters * @param {DateTimeFormatInfo} dtfi - Format info * @returns {string} Formatted month name */ static #formatMonth(month, repeatCount, dtfi) { if (month < 1 || month > 12) { throw new ArgumentError("Month out of range"); } if (repeatCount === 3) { return dtfi.getAbbreviatedMonthName(month); } return dtfi.getMonthName(month); } /** * Parses quoted string in format pattern. * * @private * @param {string} format - The format string * @param {number} pos - Position of quote character * @returns {{text: string, length: number}} Parsed text and consumed length */ static #parseQuoteString(format, pos) { const formatLen = format.length; const beginPos = pos; const quoteChar = format[pos++]; let foundQuote = false; let result = ''; while (pos < formatLen) { const ch = format[pos++]; if (ch === quoteChar) { foundQuote = true; break; } else if (ch === '\\') { if (pos < formatLen) { result += format[pos++]; } else { throw new ArgumentError("Invalid format string - backslash at end"); } } else { result += ch; } } if (!foundQuote) { throw new ArgumentError(`Invalid format string - unmatched quote: ${quoteChar}`); } return { text: result, length: pos - beginPos }; } /** * Gets the next character in format string. * * @private * @param {string} format - The format string * @param {number} pos - Current position * @returns {number} Next character code, or -1 if at end */ static #parseNextChar(format, pos) { if (pos + 1 >= format.length) { return -1; } return format.charCodeAt(pos + 1); } /** * Checks if genitive form should be used for month names. * * @private * @param {string} format - The format string * @param {number} index - Current position * @param {number} tokenLen - Length of current token * @param {string} patternToMatch - Pattern to search for (usually 'd') * @returns {boolean} Whether to use genitive form */ static #isUseGenitiveForm(format, index, tokenLen, patternToMatch) { // Look back for day pattern let i = index - 1; let repeat = 0; // Find first occurrence of pattern while (i >= 0 && format[i] !== patternToMatch) { i--; } if (i >= 0) { // Count consecutive patterns while (--i >= 0 && format[i] === patternToMatch) { repeat++; } // repeat == 0 means one pattern, repeat == 1 means two patterns if (repeat <= 1) { return true; } } // Look ahead for day pattern i = index + tokenLen; while (i < format.length && format[i] !== patternToMatch) { i++; } if (i < format.length) { repeat = 0; while (++i < format.length && format[i] === patternToMatch) { repeat++; } if (repeat <= 1) { return true; } } return false; } /** * Formats fractional seconds. * * @private * @param {number} fraction - Fractional part * @param {string} fractionFormat - Format pattern * @returns {string} Formatted fraction */ static #formatFraction(fraction, fractionFormat) { return DateTimeFormat.#formatDigits(fraction, fractionFormat.length); } /** * Formats timezone offset. * * @private * @param {DateTime} dateTime - The datetime * @param {TimeSpan|number} offset - Offset from UTC (or NULL_OFFSET) * @param {number} tokenLen - Length of 'z' pattern * @param {boolean} timeOnly - Whether this is time-only formatting * @returns {string} Formatted timezone */ static #formatCustomizedTimeZone(dateTime, offset, tokenLen, timeOnly) { let offsetSpan; let dateTimeFormat = (offset === DateTimeFormat.NULL_OFFSET); if (dateTimeFormat) { // No offset provided - determine from DateTime if (timeOnly && dateTime.ticks < TimeSpan.TICKS_PER_DAY) { // For time-only, use current system offset const now = new Date(); offsetSpan = TimeSpan.fromMinutes(-now.getTimezoneOffset()); } else if (dateTime.kind === DateTimeKind.UTC) { offsetSpan = TimeSpan.zero; } else { // Get local offset for the datetime const jsDate = dateTime.toDate(); offsetSpan = TimeSpan.fromMinutes(-jsDate.getTimezoneOffset()); } } else { offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); } let result = ''; const totalMinutes = offsetSpan.totalMinutes; if (totalMinutes >= 0) { result += '+'; } else { result += '-'; } const absMinutes = Math.abs(totalMinutes); const hours = Math.floor(absMinutes / 60); const minutes = absMinutes % 60; if (tokenLen <= 1) { // 'z' format: +8 or -7 result += hours.toString(); } else if (tokenLen === 2) { // 'zz' format: +08 or -07 result += DateTimeFormat.#formatDigits(hours, 2); } else { // 'zzz' format: +08:00 or -07:30 result += DateTimeFormat.#formatDigits(hours, 2); result += ':'; result += DateTimeFormat.#formatDigits(minutes, 2); } return result; } /** * Formats roundtrip timezone (K format). * * @private * @param {DateTime} dateTime - The datetime * @param {TimeSpan|number} offset - Offset from UTC (or NULL_OFFSET) * @returns {string} Formatted timezone for roundtrip */ static #formatCustomizedRoundtripTimeZone(dateTime, offset) { if (offset === DateTimeFormat.NULL_OFFSET) { // Source is DateTime switch (dateTime.kind) { case DateTimeKind.LOCAL: const jsDate = dateTime.toDate(); const offsetMinutes = -jsDate.getTimezoneOffset(); const offsetSpan = TimeSpan.fromMinutes(offsetMinutes); return DateTimeFormat.#formatCustomizedTimeZone(dateTime, offsetSpan, 3, false); case DateTimeKind.UTC: return 'Z'; default: // Unspecified return ''; } } // Source is DateTimeOffset const offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); return DateTimeFormat.#formatCustomizedTimeZone(dateTime, offsetSpan, 3, false); } /** * Main formatting method for custom patterns. * * @private * @param {DateTime} dateTime - The datetime to format * @param {string} format - Custom format pattern * @param {DateTimeFormatInfo} dtfi - Format info * @param {TimeSpan|number} offset - Timezone offset (or NULL_OFFSET) * @returns {string} Formatted datetime string */ static #formatCustomized(dateTime, format, dtfi, offset) { let result = ''; let isTimeOnly = true; let i = 0; while (i < format.length) { const ch = format[i]; let tokenLen; let hour12; switch (ch) { case 'g': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); result += dtfi.getEraName(dateTime); break; case 'h': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); hour12 = dateTime.hour; if (hour12 > 12) { hour12 -= 12; } else if (hour12 === 0) { hour12 = 12; } result += DateTimeFormat.#formatDigits(hour12, Math.min(tokenLen, 2)); break; case 'H': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); result += DateTimeFormat.#formatDigits(dateTime.hour, Math.min(tokenLen, 2)); break; case 'm': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); result += DateTimeFormat.#formatDigits(dateTime.minute, Math.min(tokenLen, 2)); break; case 's': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); result += DateTimeFormat.#formatDigits(dateTime.second, Math.min(tokenLen, 2)); break; case 'f': case 'F': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); if (tokenLen <= DateTimeFormat.MAX_SECONDS_FRACTION_DIGITS) { const totalTicks = Number(dateTime.ticks); const secondTicks = totalTicks % TimeSpan.TICKS_PER_SECOND; const divisor = Math.pow(10, DateTimeFormat.MAX_SECONDS_FRACTION_DIGITS - tokenLen); let fraction = Math.floor(secondTicks / divisor); if (ch === 'f') { result += DateTimeFormat.#formatFraction(fraction, DateTimeFormat.FIXED_NUMBER_FORMATS[tokenLen - 1]); } else { // 'F' format - remove trailing zeros let effectiveDigits = tokenLen; while (effectiveDigits > 0 && fraction % 10 === 0) { fraction = Math.floor(fraction / 10); effectiveDigits--; } if (effectiveDigits > 0) { result += DateTimeFormat.#formatFraction(fraction, DateTimeFormat.FIXED_NUMBER_FORMATS[effectiveDigits - 1]); } else if (result.endsWith('.')) { result = result.slice(0, -1); } } } else { throw new ArgumentError("Invalid format string"); } break; case 't': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); if (tokenLen === 1) { const designator = dateTime.hour < 12 ? dtfi.amDesignator : dtfi.pmDesignator; if (designator.length >= 1) { result += designator[0]; } } else { result += dateTime.hour < 12 ? dtfi.amDesignator : dtfi.pmDesignator; } break; case 'd': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); if (tokenLen <= 2) { const day = dateTime.day; result += DateTimeFormat.#formatDigits(day, tokenLen); } else { const dayOfWeek = dateTime.dayOfWeek; result += DateTimeFormat.#formatDayOfWeek(dayOfWeek, tokenLen, dtfi); } isTimeOnly = false; break; case 'M': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); const month = dateTime.month; if (tokenLen <= 2) { result += DateTimeFormat.#formatDigits(month, tokenLen); } else { if (dtfi.useGenitiveMonth) { const useGenitive = DateTimeFormat.#isUseGenitiveForm(format, i, tokenLen, 'd'); result += dtfi.getMonthName(month, useGenitive ? 'genitive' : 'regular', tokenLen === 3); } else { result += DateTimeFormat.#formatMonth(month, tokenLen, dtfi); } } isTimeOnly = false; break; case 'y': const year = dateTime.year; tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); if (tokenLen <= 2) { result += DateTimeFormat.#formatDigits(year % 100, tokenLen); } else if (tokenLen <= 16) { result += DateTimeFormat.#formatDigits(year, tokenLen); } else { result += year.toString().padStart(tokenLen, '0'); } isTimeOnly = false; break; case 'z': tokenLen = DateTimeFormat.#parseRepeatPattern(format, i, ch); result += DateTimeFormat.#formatCustomizedTimeZone(dateTime, offset, tokenLen, isTimeOnly); break; case 'K': tokenLen = 1; result += DateTimeFormat.#formatCustomizedRoundtripTimeZone(dateTime, offset); break; case ':': result += dtfi.timeSeparator; tokenLen = 1; break; case '/': result += dtfi.dateSeparator; tokenLen = 1; break; case "'": case '"': const quoted = DateTimeFormat.#parseQuoteString(format, i); result += quoted.text; tokenLen = quoted.length; break; case '%': const nextChar = DateTimeFormat.#parseNextChar(format, i); if (nextChar >= 0 && nextChar !== '%'.charCodeAt(0)) { const nextCharStr = String.fromCharCode(nextChar); result += DateTimeFormat.#formatCustomized(dateTime, nextCharStr, dtfi, offset); tokenLen = 2; } else { throw new ArgumentError("Invalid format string"); } break; case '\\': const escapedChar = DateTimeFormat.#parseNextChar(format, i); if (escapedChar >= 0) { result += String.fromCharCode(escapedChar); tokenLen = 2; } else { throw new ArgumentError("Invalid format string"); } break; default: result += ch; tokenLen = 1; break; } i += tokenLen; } return result; } /** * Expands standard format character to custom pattern. * * @param {string} format - Single character standard format * @param {DateTimeFormatInfo} dtfi - Format info * @returns {string} Expanded custom pattern */ static expandStandardFormatToCustomPattern(format, dtfi) { switch (format) { case 'd': return dtfi.shortDatePattern; case 'D': return dtfi.longDatePattern; case 'f': return dtfi.longDatePattern + " " + dtfi.shortTimePattern; case 'F': return dtfi.fullDateTimePattern; case 'g': return dtfi.generalShortTimePattern; case 'G': return dtfi.generalLongTimePattern; case 'm': case 'M': return dtfi.monthDayPattern; case 'o': case 'O': return DateTimeFormat.ROUNDTRIP_FORMAT; case 'r': case 'R': return dtfi.rfc1123Pattern; case 's': return dtfi.sortableDateTimePattern; case 't': return dtfi.shortTimePattern; case 'T': return dtfi.longTimePattern; case 'u': return dtfi.universalSortableDateTimePattern; case 'U': return dtfi.fullDateTimePattern; case 'y': case 'Y': return dtfi.yearMonthPattern; default: throw new ArgumentError("Invalid format string"); } } /** * Formats DateTime with optional format and provider. * * @param {DateTime} dateTime - DateTime to format * @param {string} [format] - Format string * @param {string} [locale] - Locale string (e.g., 'en-US') * @param {TimeSpan|number} [offset] - Timezone offset * @returns {string} Formatted datetime string */ static format(dateTime, format = null, locale = null, offset = DateTimeFormat.NULL_OFFSET) { if (!(dateTime instanceof DateTime)) { throw new TypeError("First argument must be a DateTime instance"); } const dtfi = DateTimeFormatInfo.getInstance(locale); if (!format) { if (offset === DateTimeFormat.NULL_OFFSET) { // Default DateTime formatting format = dtfi.generalLongTimePattern; } else { // Default DateTimeOffset formatting format = dtfi.dateTimeOffsetPattern; } } else if (format.length === 1) { // Single character - expand to pattern if (format === 'U') { // Universal time format requires UTC conversion if (offset !== DateTimeFormat.NULL_OFFSET) { throw new ArgumentError("Universal format not supported for DateTimeOffset"); } dateTime = new DateTime(dateTime.ticks, DateTimeKind.UTC); } format = DateTimeFormat.expandStandardFormatToCustomPattern(format, dtfi); } return DateTimeFormat.#formatCustomized(dateTime, format, dtfi, offset); } /** * Checks if DateTime represents time-only for special case formatting. * * @private * @param {DateTime} dateTime - DateTime to check * @param {DateTimeFormatInfo} dtfi - Format info * @returns {boolean} Whether this should be treated as time-only */ static #isTimeOnlySpecialCase(dateTime, dtfi) { // Special case for certain calendars when time is less than 1 day return dateTime.ticks < TimeSpan.TICKS_PER_DAY && dtfi.calendar && dtfi.calendar.isNonGregorian; } /** * Formats DateTime with 'O' roundtrip format. * * @param {DateTime} dateTime - DateTime to format * @param {TimeSpan|number} offset - Timezone offset * @returns {string} Formatted string */ static formatO(dateTime, offset = DateTimeFormat.NULL_OFFSET) { let kind = DateTimeKind.LOCAL; let offsetSpan = null; if (offset === DateTimeFormat.NULL_OFFSET) { kind = dateTime.kind; if (kind === DateTimeKind.LOCAL) { const jsDate = dateTime.toDate(); offsetSpan = TimeSpan.fromMinutes(-jsDate.getTimezoneOffset()); } } else { offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); } const year = dateTime.year; const month = dateTime.month; const day = dateTime.day; const hour = dateTime.hour; const minute = dateTime.minute; const second = dateTime.second; const fraction = Number(dateTime.ticks % BigInt(TimeSpan.TICKS_PER_SECOND)); let result = `${year.toString().padStart(4, '0')}-`; result += `${month.toString().padStart(2, '0')}-`; result += `${day.toString().padStart(2, '0')}T`; result += `${hour.toString().padStart(2, '0')}:`; result += `${minute.toString().padStart(2, '0')}:`; result += `${second.toString().padStart(2, '0')}`; if (fraction > 0) { result += `.${fraction.toString().padStart(7, '0')}`; } else { result += `.0000000`; } if (kind === DateTimeKind.UTC || (offsetSpan && offsetSpan.ticks === 0)) { result += 'Z'; } else if (offsetSpan) { const totalMinutes = offsetSpan.totalMinutes; const sign = totalMinutes >= 0 ? '+' : '-'; const absMinutes = Math.abs(totalMinutes); const offsetHours = Math.floor(absMinutes / 60); const offsetMins = absMinutes % 60; result += `${sign}${offsetHours.toString().padStart(2, '0')}:${offsetMins.toString().padStart(2, '0')}`; } return result; } /** * Formats DateTime with 'R' RFC1123 format. * * @param {DateTime} dateTime - DateTime to format * @param {TimeSpan|number} offset - Timezone offset * @returns {string} Formatted string */ static formatR(dateTime, offset = DateTimeFormat.NULL_OFFSET) { // Convert to UTC for RFC1123 let utcDateTime = dateTime; if (offset !== DateTimeFormat.NULL_OFFSET) { const offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); utcDateTime = dateTime.subtract(offsetSpan); } else if (dateTime.kind === DateTimeKind.LOCAL) { const jsDate = dateTime.toDate(); const localOffset = TimeSpan.fromMinutes(-jsDate.getTimezoneOffset()); utcDateTime = dateTime.subtract(localOffset); } const dayName = DateTimeFormat.#invariantAbbreviatedDayNames[utcDateTime.dayOfWeek]; const monthName = DateTimeFormat.#invariantAbbreviatedMonthNames[utcDateTime.month - 1]; return `${dayName}, ${utcDateTime.day.toString().padStart(2, '0')} ${monthName} ${utcDateTime.year} ` + `${utcDateTime.hour.toString().padStart(2, '0')}:${utcDateTime.minute.toString().padStart(2, '0')}:${utcDateTime.second.toString().padStart(2, '0')} GMT`; } /** * Formats DateTime with 'S' sortable format. * * @param {DateTime} dateTime - DateTime to format * @returns {string} Formatted string */ static formatS(dateTime) { const year = dateTime.year; const month = dateTime.month; const day = dateTime.day; const hour = dateTime.hour; const minute = dateTime.minute; const second = dateTime.second; return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}T` + `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`; } /** * Formats DateTime with 'U' universal sortable format. * * @param {DateTime} dateTime - DateTime to format * @param {TimeSpan|number} offset - Timezone offset * @returns {string} Formatted string */ static formatU(dateTime, offset = DateTimeFormat.NULL_OFFSET) { // Convert to UTC let utcDateTime = dateTime; if (offset !== DateTimeFormat.NULL_OFFSET) { const offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); utcDateTime = dateTime.subtract(offsetSpan); } const year = utcDateTime.year; const month = utcDateTime.month; const day = utcDateTime.day; const hour = utcDateTime.hour; const minute = utcDateTime.minute; const second = utcDateTime.second; return `${year.toString().padStart(4, '0')}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')} ` + `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}Z`; } /** * Formats DateTime with invariant 'G' format (for performance). * * @param {DateTime} dateTime - DateTime to format * @param {TimeSpan|number} offset - Timezone offset * @returns {string} Formatted string */ static formatInvariantG(dateTime, offset = DateTimeFormat.NULL_OFFSET) { const month = dateTime.month; const day = dateTime.day; const year = dateTime.year; const hour = dateTime.hour; const minute = dateTime.minute; const second = dateTime.second; let result = `${month.toString().padStart(2, '0')}/${day.toString().padStart(2, '0')}/${year.toString().padStart(4, '0')} ` + `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}:${second.toString().padStart(2, '0')}`; if (offset !== DateTimeFormat.NULL_OFFSET) { const offsetSpan = offset instanceof TimeSpan ? offset : TimeSpan.fromTicks(offset); const totalMinutes = offsetSpan.totalMinutes; const sign = totalMinutes >= 0 ? '+' : '-'; const absMinutes = Math.abs(totalMinutes); const offsetHours = Math.floor(absMinutes / 60); const offsetMins = absMinutes % 60; result += ` ${sign}${offsetHours.toString().padStart(2, '0')}:${offsetMins.toString().padStart(2, '0')}`; } return result; } }