001/* 002 * SPDX-License-Identifier: Apache-2.0 003 * 004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * https://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 */ 018package dev.enola.common.convert; 019 020import com.google.common.collect.ImmutableList; 021 022import dev.enola.common.locale.LocaleSupplier; 023import dev.enola.common.locale.LocaleSupplierTLC; 024import dev.enola.common.time.ZoneIdSupplier; 025import dev.enola.common.time.ZoneIdSupplierTLC; 026 027import org.jspecify.annotations.Nullable; 028 029import java.time.Instant; 030import java.time.ZonedDateTime; 031import java.time.format.DateTimeFormatter; 032import java.time.format.DateTimeParseException; 033import java.time.format.FormatStyle; 034import java.time.format.ResolverStyle; 035import java.time.temporal.TemporalAccessor; 036import java.util.Locale; 037 038public class TemporalAccessorToStringConverter<T extends TemporalAccessor> 039 implements BiConverter<T, String>, ObjectToStringBiConverter<T> { 040 041 // TODO Factor common.convert unrelated parts of this out into common.time 042 043 // TODO Test (and likely fix resulting bugs from) other TemporalAccessor than Instant 044 045 public static final ObjectToStringBiConverter<Instant> INSTANT = 046 new TemporalAccessorToStringConverter<>(); 047 048 private final String INSTANT_MIN_TEXT = Instant.MIN.toString(); 049 private final String INSTANT_MAX_TEXT = Instant.MAX.toString(); 050 051 private final LocaleSupplier localeSupplier; 052 private final ZoneIdSupplier timezoneSupplier; 053 private final ImmutableList<DateTimeFormatter> formatters; 054 055 public TemporalAccessorToStringConverter( 056 LocaleSupplier localeSupplier, 057 ZoneIdSupplier timezoneSupplier, 058 Iterable<DateTimeFormatter> formatters) { 059 this.localeSupplier = localeSupplier; 060 this.timezoneSupplier = timezoneSupplier; 061 this.formatters = ImmutableList.copyOf(formatters); 062 } 063 064 public TemporalAccessorToStringConverter( 065 LocaleSupplier localeSupplier, ZoneIdSupplier timezoneSupplier) { 066 this( 067 localeSupplier, 068 timezoneSupplier, 069 ImmutableList.of( 070 DateTimeFormatter.ISO_INSTANT, 071 DateTimeFormatter.ofLocalizedDateTime(FormatStyle.FULL), 072 DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG), 073 DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM), 074 DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT), 075 // TODO This seems stupid - is there really no better way to do this?! 076 DateTimeFormatter.ofPattern("MMMM d, yyyy, h:mm:ss a z"), 077 DateTimeFormatter.ofPattern("dd. MMMM yyyy, HH:mm:ss XXX"), 078 DateTimeFormatter.ofPattern("dd. MMMM yyyy, HH:mm:ss z") 079 // ... 080 )); 081 } 082 083 public TemporalAccessorToStringConverter() { 084 // NOT this(LocaleSupplierTLC.JVM_DEFAULT, TimezoneSupplierTLC.JVM_DEFAULT); 085 // but intentionally use something stable which does not change, for unflaky tests; 086 // this is fine, because real applications (CLI, webapps, etc.) should always have 087 // another Locale and Timezone set in the TLC anyways. 088 this(LocaleSupplierTLC.ROOT, ZoneIdSupplierTLC.UTC); 089 } 090 091 @Override 092 public @Nullable String convertTo(@Nullable T input) throws ConversionException { 093 if (input == null) return null; 094 TemporalAccessor temporalAccessor = input; 095 096 // Special handling of MIN & MAX using the default ISO_INSTANT DateTimeFormatter 097 if (input.equals(Instant.MIN)) return INSTANT_MIN_TEXT; 098 if (input.equals(Instant.MAX)) return INSTANT_MAX_TEXT; 099 100 var tz = timezoneSupplier.get(); 101 if (temporalAccessor instanceof Instant instant) { 102 temporalAccessor = instant.atZone(tz); 103 } 104 105 DateTimeFormatter formatter; 106 var locale = localeSupplier.get(); 107 if (locale.equals(Locale.ROOT)) formatter = DateTimeFormatter.ISO_INSTANT; 108 else 109 formatter = 110 DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG) 111 .withLocale(locale) 112 .withZone(tz); 113 return formatter.format(temporalAccessor); 114 } 115 116 @Override 117 @SuppressWarnings("unchecked") // Java generics are stupid 118 public @Nullable T convertFrom(@Nullable String text) throws ConversionException { 119 if (text == null) return null; 120 121 // Special handling of MIN & MAX using the default ISO_INSTANT DateTimeFormatter 122 if (text.equals(INSTANT_MIN_TEXT)) return (T) Instant.MIN; 123 if (text.equals(INSTANT_MAX_TEXT)) return (T) Instant.MAX; 124 125 try { 126 return (T) parseTryingPatterns(text); 127 } catch (DateTimeParseException e) { 128 throw new ConversionException(text, e); 129 } 130 } 131 132 private TemporalAccessor parseTryingPatterns(String text) { 133 for (var formatter : formatters) { 134 try { 135 return parse(text, formatter); 136 } catch (DateTimeParseException e) { 137 // throw e; // For easier DEBUGGING (only) 138 // Ignore 139 } 140 } 141 throw new DateTimeParseException( 142 "Could not parse with any of the registered DateTimeFormatter", text, 0); 143 } 144 145 private TemporalAccessor parse(String text, DateTimeFormatter formatter) { 146 var localizedZonedFormatter = 147 formatter 148 .withLocale(localeSupplier.get()) 149 .withZone(timezoneSupplier.get()) 150 .withResolverStyle(ResolverStyle.SMART); // TODO STRICT ? 151 // NB: Must use ZonedDateTime instead of OffsetDateTime, because the former parses both 152 // "-06:00" (offset) as well as "MESZ" (zone) whereas the latter only handles offsets. 153 var offsetDateTime = ZonedDateTime.parse(text, localizedZonedFormatter); 154 return offsetDateTime.toInstant(); 155 } 156}