blob: a8477fbb9b52e6aada937c70521d6bafffda17c1 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:collection';
/// A class for parsing and formatting dates for a fixed format string. For
/// example, calling
/// `FixedDateTimeCodec('YYYYMMDDhhmmss').decodeToLocal('19960425050322')` has
/// the same result as calling `DateTime(1996, 4, 25, 5, 3, 22)`.
///
/// The allowed characters are
/// * `Y` a digit used in the time scale component “calendar year”
/// * `M` a digit used in the time scale component “calendar month”
/// * `D` a digit used in the time scale component “calendar day”
/// * `E` a digit used in the time scale component “decade”
/// * `C` a digit used in the time scale component “century”
/// * `h` a digit used in the time scale component “clock hour”
/// * `m` a digit used in the time scale component “clock minute”
/// * `s` a digit used in the time scale component “clock second”
/// as specified in the ISO 8601 standard.
///
/// Non-allowed characters in the format [pattern] are ignored when decoding a
/// string, in this case `YYYY kiwi MM` is the same format string as
/// `YYYY------MM`. When encoding a datetime, the non-format characters are in
/// the output verbatim.
///
/// Note: this class differs from [DateFormat] in that here, the characters are
/// treated literally, i.e. the format string `YYY` matching `996` would result in
/// the same as calling `DateTime(996)`. [DateFormat] on the other hand uses the
/// specification in https://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table,
/// the format string (or "skeleton") `YYY` specifies only the padding, so
/// `1996` would be a valid match. This limits is use to format strings
/// containing delimiters, as the parser would not know how many digits to take
/// otherwise.
class FixedDateTimeFormatter {
static const _validFormatCharacters = 'YMDEChms';
final String pattern;
// ignore: prefer_collection_literals
final _occurences = LinkedHashMap<String, _Range>();
/// Creates a new [FixedDateTimeFormatter] with the provided [pattern].
///
/// The [pattern] interprets the characters mentioned in
/// [FixedDateTimeFormatter] to represent fields of a `DateTime` value. Other
/// characters are not special.
///
/// There must at most be one sequence of each special character to ensure a
/// single source of truth when constructing the [DateTime], so a pattern of
/// `"CCCC-MM-DD, CC"` is invalid, because it has two separate `C` sequences.
FixedDateTimeFormatter(this.pattern) {
String? current;
var start = 0;
for (var i = 0; i < pattern.length; i++) {
var char = pattern[i];
if (current != char) {
_saveFormatBlock(current, start, i);
if (_validFormatCharacters.contains(char)) {
var hasSeenBefore = _occurences.containsKey(char);
if (hasSeenBefore) {
throw FormatException(
"Pattern contains more than one '$char' block.\n"
"Previous occurrence at position ${_occurences[char]!.from}",
pattern,
i);
} else {
start = i;
current = char;
}
} else {
current = null;
}
}
}
_saveFormatBlock(current, start, pattern.length);
}
void _saveFormatBlock(String? current, int start, int i) {
if (current != null) _occurences[current] = _Range(start, i);
}
/// Convert [DateTime] to a [String] exactly as specified by the [pattern].
String encode(DateTime datetime) {
var buffer = StringBuffer();
var previousEnd = 0;
_occurences.forEach((key, value) {
if (previousEnd < value.from) {
buffer.write(pattern.substring(previousEnd, value.from));
}
var number = _extractNumFromDateTime(key, datetime).toString();
var length = value.length;
if (number.length > length) {
number = number.substring(number.length - length);
} else if (number.length < length) {
number = number.padLeft(length, '0');
}
buffer.write(number);
previousEnd = value.to;
});
if (previousEnd < pattern.length) {
buffer.write(pattern.substring(previousEnd, pattern.length));
}
return buffer.toString();
}
int _extractNumFromDateTime(String? key, DateTime dt) {
switch (key) {
case 'Y':
return dt.year;
case 'C':
return (dt.year / 100).floor();
case 'E':
return (dt.year / 10).floor();
case 'M':
return dt.month;
case 'D':
return dt.day;
case 'h':
return dt.hour;
case 'm':
return dt.minute;
case 's':
return dt.second;
}
throw AssertionError("Unreachable, the key is checked in the constructor");
}
/// Parse a string [formattedDateTime] to a local [DateTime] as specified in the
/// [pattern], substituting missing values with a default. Throws an exception
/// on failure to parse.
DateTime decode(String formattedDateTime, [bool isUtc = false]) {
return _decode(formattedDateTime, isUtc, int.parse);
}
/// Same as [decode], but will not throw on parsing erros, instead using
/// the default value as if the format char was not present in the [pattern].
DateTime tryDecode(String formattedDateTime, [bool isUtc = false]) {
return _decode(formattedDateTime, isUtc, int.tryParse);
}
DateTime _decode(
String formattedDateTime,
bool isUtc,
int? Function(String) parser,
) {
var year = _extractNumFromString(formattedDateTime, 'Y', parser) ?? 0;
var century = _extractNumFromString(formattedDateTime, 'C', parser) ?? 0;
var decade = _extractNumFromString(formattedDateTime, 'E', parser) ?? 0;
var totalYear = year + 100 * century + 10 * decade;
var month = _extractNumFromString(formattedDateTime, 'M', parser) ?? 1;
var day = _extractNumFromString(formattedDateTime, 'D', parser) ?? 1;
var hour = _extractNumFromString(formattedDateTime, 'h', parser) ?? 0;
var minute = _extractNumFromString(formattedDateTime, 'm', parser) ?? 0;
var second = _extractNumFromString(formattedDateTime, 's', parser) ?? 0;
if (isUtc) {
return DateTime.utc(totalYear, month, day, hour, minute, second, 0, 0);
} else {
return DateTime(totalYear, month, day, hour, minute, second, 0, 0);
}
}
int? _extractNumFromString(
String s,
String id,
int? Function(String) parser,
) {
var pos = _occurences[id];
if (pos != null) {
return parser.call(s.substring(pos.from, pos.to));
} else {
return null;
}
}
}
class _Range {
final int from;
final int to;
_Range(this.from, this.to);
int get length => to - from;
}