use chrono::Timelike;
use chrono_tz::Tz;
use thiserror::Error;
use crate::util::{Locale, Utf16String};
use super::temporal_creation_utils::process_pattern;
use super::temporal_objects::java_short_zone;
use super::{TemporalObjects, TemporalValue};
pub struct TemporalFormattingUtils {
locale: Locale,
default_zone_id: Tz,
}
impl TemporalFormattingUtils {
pub fn new(locale: Locale, default_zone_id: Tz) -> Result<Self, TemporalFormattingError> {
Ok(Self {
locale,
default_zone_id,
})
}
pub fn format(
&self,
target: Option<&TemporalValue>,
pattern: Option<&str>,
locale: Option<&Locale>,
zone_id: Option<Tz>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
let Some(target) = target else {
return Ok(None);
};
if pattern.is_some_and(|pattern| pattern.trim().is_empty()) {
return Err(invalid("Pattern cannot be null or empty"));
}
let locale = locale.unwrap_or(&self.locale);
let has_explicit_pattern = pattern.is_some();
let pattern = match pattern {
None => TemporalObjects::formatter_for(target, locale)?,
Some("SHORT") => localized_pattern(target, locale, "SHORT", &self.default_zone_id),
Some("MEDIUM") => localized_pattern(target, locale, "MEDIUM", &self.default_zone_id),
Some("LONG") => localized_pattern(target, locale, "LONG", &self.default_zone_id),
Some("FULL") => localized_pattern(target, locale, "FULL", &self.default_zone_id),
Some(pattern) => process_pattern(pattern),
};
let formatted = match target {
TemporalValue::Instant(value) if !has_explicit_pattern && pattern.contains('Z') => {
value.format(&pattern).to_string()
}
TemporalValue::LocalDate(value) if !has_explicit_pattern && zone_id.is_none() => {
value.format(&pattern).to_string()
}
TemporalValue::LocalDateTime(value) if !has_explicit_pattern && zone_id.is_none() => {
value.format(&pattern).to_string()
}
TemporalValue::LocalTime(value) if !has_explicit_pattern && zone_id.is_none() => {
value.format(&pattern).to_string()
}
TemporalValue::OffsetDateTime(value) if !has_explicit_pattern && zone_id.is_none() => {
format!(
"{}{}",
value.format(&pattern),
gmt_offset(value.offset().local_minus_utc())
)
}
TemporalValue::OffsetTime(value, offset)
if !has_explicit_pattern && zone_id.is_none() =>
{
format!(
"{}{}",
value.format(&pattern),
gmt_offset(offset.local_minus_utc())
)
}
TemporalValue::Year(value) if !has_explicit_pattern && zone_id.is_none() => {
format_year(*value, &pattern)
}
TemporalValue::YearMonth(year, month) if !has_explicit_pattern && zone_id.is_none() => {
let date = chrono::NaiveDate::from_ymd_opt(*year, *month, 1)
.ok_or_else(|| invalid("Invalid YearMonth"))?;
date.format(&pattern).to_string()
}
TemporalValue::ZonedDateTime(value) if !has_explicit_pattern && zone_id.is_none() => {
value.format(&pattern).to_string()
}
_ => {
let zoned = TemporalObjects::zoned_time(target, self.default_zone_id)?;
let converted = match zone_id {
Some(zone) => zoned.with_timezone(&zone),
None => zoned,
};
converted.format(&pattern).to_string()
}
};
let formatted = replace_java_fraction_markers(formatted, target)?;
let formatted = if locale.get_language().to_string_lossy() == "de" {
localize_german_names(formatted)
} else {
formatted
};
Ok(Some(Utf16String::from_rust_str(&formatted)))
}
pub fn day(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
target
.map(|target| TemporalObjects::date_fields(target).map(|(_, _, day, _)| day as i32))
.transpose()
.map_err(Into::into)
}
pub fn month(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
target
.map(|target| TemporalObjects::date_fields(target).map(|(_, month, _, _)| month as i32))
.transpose()
.map_err(Into::into)
}
pub fn month_name(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
self.format(target, Some("MMMM"), None, None)
}
pub fn month_name_short(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
self.format(target, Some("MMM"), None, None)
}
pub fn year(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
target
.map(|target| TemporalObjects::date_fields(target).map(|(year, _, _, _)| year))
.transpose()
.map_err(Into::into)
}
pub fn day_of_week(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
target
.map(|target| {
TemporalObjects::date_fields(target).map(|(_, _, _, weekday)| weekday as i32)
})
.transpose()
.map_err(Into::into)
}
pub fn day_of_week_name(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
self.format(target, Some("EEEE"), None, None)
}
pub fn day_of_week_name_short(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
self.format(target, Some("EEE"), None, None)
}
pub fn hour(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
self.time_field(target, 0)
}
pub fn minute(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
self.time_field(target, 1)
}
pub fn second(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
self.time_field(target, 2)
}
pub fn nanosecond(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<i32>, TemporalFormattingError> {
self.time_field(target, 3)
}
pub fn format_iso(
&self,
target: Option<&TemporalValue>,
) -> Result<Option<Utf16String>, TemporalFormattingError> {
let Some(target) = target else {
return Ok(None);
};
let formatted = match target {
TemporalValue::OffsetDateTime(value) => {
value.format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string()
}
TemporalValue::ZonedDateTime(value) => {
value.format("%Y-%m-%dT%H:%M:%S%.3f%z").to_string()
}
_ => TemporalObjects::zoned_time(target, self.default_zone_id)?
.format("%Y-%m-%dT%H:%M:%S%.3f%z")
.to_string(),
};
Ok(Some(Utf16String::from_rust_str(&formatted)))
}
fn time_field(
&self,
target: Option<&TemporalValue>,
index: usize,
) -> Result<Option<i32>, TemporalFormattingError> {
target
.map(|target| {
let fields = TemporalObjects::time_fields(target)?;
Ok([fields.0, fields.1, fields.2, fields.3][index] as i32)
})
.transpose()
}
}
fn replace_java_fraction_markers(
mut formatted: String,
target: &TemporalValue,
) -> Result<String, TemporalFormattingError> {
let nanosecond = match target {
TemporalValue::Instant(value) => value.nanosecond(),
TemporalValue::LocalDate(_) | TemporalValue::Year(_) | TemporalValue::YearMonth(_, _) => 0,
TemporalValue::LocalDateTime(value) => value.nanosecond(),
TemporalValue::LocalTime(value) | TemporalValue::OffsetTime(value, _) => value.nanosecond(),
TemporalValue::OffsetDateTime(value) => value.nanosecond(),
TemporalValue::ZonedDateTime(value) => value.nanosecond(),
};
const PREFIX: &str = "__THYMELEAF_FRACTION_";
const SUFFIX: &str = "__";
while let Some(start) = formatted.find(PREFIX) {
let marker_end = formatted[start + PREFIX.len()..]
.find(SUFFIX)
.map(|offset| start + PREFIX.len() + offset)
.ok_or_else(|| invalid("Invalid Java fraction marker"))?;
let count = formatted[start + PREFIX.len()..marker_end]
.parse::<usize>()
.map_err(|_| invalid("Invalid Java fraction width"))?;
if !(1..=9).contains(&count) {
return Err(invalid("Fraction width must be between 1 and 9"));
}
let digits = format!("{nanosecond:09}");
formatted.replace_range(start..marker_end + SUFFIX.len(), &digits[..count]);
}
Ok(formatted)
}
#[derive(Debug, Error)]
#[error("{message}")]
pub struct TemporalFormattingError {
message: String,
}
impl From<super::temporal_objects::TemporalError> for TemporalFormattingError {
fn from(error: super::temporal_objects::TemporalError) -> Self {
invalid(error.to_string())
}
}
fn invalid(message: impl Into<String>) -> TemporalFormattingError {
TemporalFormattingError {
message: message.into(),
}
}
fn localized_pattern(
target: &TemporalValue,
locale: &Locale,
style: &str,
default_zone: &Tz,
) -> String {
let date_only = matches!(target, TemporalValue::LocalDate(_));
let time_only = matches!(
target,
TemporalValue::LocalTime(_) | TemporalValue::OffsetTime(_, _)
);
let language = locale.get_language().to_string_lossy();
let zh = language == "zh";
let de = language == "de";
let zone = java_short_zone(target, default_zone);
match (date_only, time_only, zh, de, style) {
(true, _, true, _, "FULL") => "%Y年%m月%d日 %A".to_owned(),
(true, _, true, _, _) => "%Y年%m月%d日".to_owned(),
(true, _, false, true, "SHORT") => "%d.%m.%y".to_owned(),
(true, _, false, true, "MEDIUM") => "%d.%m.%Y".to_owned(),
(true, _, false, true, "FULL") => "%A, %-d. %B %Y".to_owned(),
(true, _, false, true, _) => "%-d. %B %Y".to_owned(),
(true, _, false, false, "SHORT") => "%-m/%-d/%y".to_owned(),
(true, _, false, false, "MEDIUM") => "%b %-d, %Y".to_owned(),
(true, _, false, false, "FULL") => "%A, %B %-d, %Y".to_owned(),
(true, _, false, false, _) => "%B %-d, %Y".to_owned(),
(_, true, true, _, "SHORT") | (_, true, true, _, "MEDIUM") => "%H:%M:%S".to_owned(),
(_, true, true, _, _) => format!("{zone} %H:%M:%S"),
(_, true, false, true, "SHORT") => "%H:%M".to_owned(),
(_, true, false, true, "MEDIUM") => "%H:%M:%S".to_owned(),
(_, true, false, true, _) => format!("%H:%M:%S {zone}"),
(_, true, false, false, "SHORT") => "%-I:%M %p".to_owned(),
(_, true, false, false, "MEDIUM") => "%-I:%M:%S %p".to_owned(),
(_, true, false, false, _) => format!("%-I:%M:%S %p {zone}"),
(_, _, true, _, "SHORT") => "%Y/%m/%d %H:%M".to_owned(),
(_, _, true, _, "MEDIUM") => "%Y年%m月%d日 %H:%M:%S".to_owned(),
(_, _, true, _, _) => format!("%Y年%m月%d日 {zone} %H:%M:%S"),
(_, _, false, true, "SHORT") => "%d.%m.%y, %H:%M".to_owned(),
(_, _, false, true, "MEDIUM") => "%d.%m.%Y, %H:%M:%S".to_owned(),
(_, _, false, true, "FULL") => format!("%A, %-d. %B %Y, %H:%M:%S {zone}"),
(_, _, false, true, _) => format!("%-d. %B %Y, %H:%M:%S {zone}"),
(_, _, false, false, "SHORT") => "%-m/%-d/%y, %-I:%M %p".to_owned(),
(_, _, false, false, "MEDIUM") => "%b %-d, %Y, %-I:%M:%S %p".to_owned(),
(_, _, false, false, "LONG") => format!("%B %-d, %Y, %-I:%M:%S %p {zone}"),
(_, _, false, false, "FULL") => format!("%A, %B %-d, %Y, %-I:%M:%S %p {zone}"),
_ => "%B %-d, %Y, %-I:%M:%S %p".to_owned(),
}
}
fn localize_german_names(formatted: String) -> String {
const MONTHS: [(&str, &str); 12] = [
("January", "Januar"),
("February", "Februar"),
("March", "März"),
("April", "April"),
("May", "Mai"),
("June", "Juni"),
("July", "Juli"),
("August", "August"),
("September", "September"),
("October", "Oktober"),
("November", "November"),
("December", "Dezember"),
];
const DAYS: [(&str, &str); 7] = [
("Monday", "Montag"),
("Tuesday", "Dienstag"),
("Wednesday", "Mittwoch"),
("Thursday", "Donnerstag"),
("Friday", "Freitag"),
("Saturday", "Samstag"),
("Sunday", "Sonntag"),
];
let mut out = formatted;
for (english, german) in MONTHS {
out = out.replace(english, german);
}
for (english, german) in DAYS {
out = out.replace(english, german);
}
out
}
fn format_year(year: i32, pattern: &str) -> String {
pattern
.replace("%Y", &format!("{year:04}"))
.replace("%y", &format!("{:02}", year.rem_euclid(100)))
}
fn gmt_offset(seconds: i32) -> String {
if seconds == 0 {
"GMT".to_owned()
} else {
let sign = if seconds < 0 { '-' } else { '+' };
let seconds = seconds.unsigned_abs();
format!("GMT{sign}{:02}:{:02}", seconds / 3600, seconds % 3600 / 60)
}
}