Mehr Zeit mit FreshMarker

„Gäbe es die letzte Minute nicht,
so würde niemals etwas fertig.“

Mark Twain

Einer der Gründe für die Entwicklung der FreshMarker Bibliothek, war die fehlende Unterstützung der Java Time API in FreeMarker. Daher existierte schon in der ersten Implementierung neben der Unterstützung für java.util.Date, java.sql.Date und java.sql.Time auch eine Unterstützung für java.time.LocalDateTime, java.time.LocalDate und java.time.LocalTime.

In diesem Beitrag wird die Formatierung für alle temporalen Typen verbessert. Bislang beherrschte FreshMarker nur die Standarddarstellung und eine Computer Audience Darstellung.

${dateTime}, ${dateTime?c}
${date}, ${date?c}
${time}, ${time?c}

Die oben dargestellten Interpolationen erzeugen die folgende Ausgabe.

2023-05-06 13:42:00, 2023-05-06T13:42:00
2023-05-06, 2023-05-06
13:42:00, 13:42:00

Die erste Darstellung ist jeweils für natürliche Leser und die zweite für technische Systeme. Bislang konnte dies nur über ein selbstgeschriebenes Plugin verändert werden, dass die Standardformatierung überschrieb. Das ist unelegant und nicht besonders flexibel. Aus diesem Grund werden nun spezielle Built-Ins hinzugefügt und die Setting Direktive erweitert.

Die neuen Built-Ins lehnen sich lose an ihre Namensvettern aus FreeMarker an. Sie haben den Bezeichner string und besitzen einen Parameter, mit dem der Format-String übergeben wird.

${dateTime?string('d. MMMM yyyy hh:mm')}

Das Ergebnis der Interpolation ist der Text 6. Mai 2023 13:42.

Die neuen Built-Ins ergänzen den TemporalPluginProvider für die Typen LocalDateTime, LocalDate und LocalTime. Andere Typen werden vorerst einmal nicht unterstützt. Der Kern der Implementierung ist die formatTemporal Methode.

builtIns.put(DATE_TIME_BUILDER.of("string"), new FunctionalBuiltIn(
                (TemplateObject x, List<TemplateObject> y, ProcessContext e) -> formatTemporal(y, e, ((TemplateLocalDateTime) x).getValue())));

Der zu formatierende Wert wird als Temporal Instanz an die Methode übergeben. Außerdem erhält die Methode die Parameter des Built-Ins als Liste übergeben. Die Liste muss genau ein Element enthalten, dass zum Typ TemplateString ausgewertet wird. Der Formatierungsstring kann also nicht nur als Konstante übergeben werden, sondern auch aus einer Variable gelesen werden.

private static TemplateString formatTemporal(List<TemplateObject> y, ProcessContext e, Temporal value) {
    if (y.isEmpty()) {
        throw new ProcessException("missing format parameter");
    }
    String pattern = y.get(0).evaluateToObject(e).asString().map(TemplateString::getValue).orElseThrow(() -> new ProcessException("invalid format parameter"));
    return new TemplateString(java.time.format.DateTimeFormatter.ofPattern(pattern, e.getEnvironment().getLocale()).format(value));
}

Neben den Built-Ins um einzelne Interpolations anzupassen, wird auch die Setting Direktive angepasst. Sie verändert alle Formatierungen in ihrem Geltungsbereich. Der Geltungsbereich der Setting Direktive ist das aktuelle Environment. Das BaseEnvironment gilt für das gesamte Template. Andere Environment Implementierungen werden auf diesem gestapelt und schränken den Geltungsbereich ggf. weiter ein. Der Geltungsbereich des SettingEnvironment ist so groß wie das übergeordnete Environment, bei dem ListEnvironment ist es nur der innere Teil einer Schleife.

Im nachfolgenden Beispiel ist die Verwendung der Setting Direktive dargestellt. Die erste Interpolation wird mit dem Standardformat ausgewertet und die zweite wird mit dem Format aus der Direktive ausgewertet.

${date}
<#setting date_format="dd. MMMM yyyy">
${date}

Die Setting Direktive wird im SettingFragment verarbeitet. Innerhalb der process Methode wird eine SettingEnvironment erzeugt und als aktuelles Environment in den Context eingefügt.

Je nach Attributnamen, werden für unterschiedliche temporale Typen Formatter erzeugt und in das SettingEnvironment eingefügt. Die Implementierung ist nicht besonders komplex, daher werden in diesem Fall auch die älteren temporalen Typen unterstützt. Nachfolgende Setting Direktiven werden auf dieser gestapelt und überschreiben dann ggf. Formatter des gleichen Typs.

if ("date_format".equals(name)) {
    String value = setting.evaluate(context, TemplateString.class).getValue();
    Map<Class<? extends TemplateObject>, Formatter> formatter = Map.of(
            TemplateLocalDate.class, new DateFormatter(value), TemplateClassicDate.class, new ClassicDateFormatter(value));
    context.setEnvironment(new SettingEnvironment(context.getEnvironment(), null, null, formatter));
    return;
}
public class SettingEnvironment extends WrapperEnvironment {

  // ...

  private final Map<Class<? extends TemplateObject>, Formatter> formatters;

  public SettingEnvironment(Environment wrapped, Locale locale, OutputFormat format, Map<Class<? extends TemplateObject>, Formatter> formatters) {
    super(wrapped);
    this.locale = locale;
    this.format = format;
    this.formatters = formatters;
  }

  // ...

  @Override
  public <T extends TemplateObject> Formatter getFormatter(Class<T> type) {
    Formatter formatter = formatters == null ? null : formatters.get(type);
    return formatter != null ? formatter : wrapped.getFormatter(type);
  }
}

Wird ein Formatter benötigt, dann liefert die Methode getFormatter die notwendige Instanz. Ist im aktuellen Environment kein passender Formatter zu finden, dann wird im übergeordneten Environment wrapped danach gesucht.

Die Anpassungen aus diesem Beitrag sind in der neuesten Version von FreshMarker auf Maven Central zu finden.

<dependency>
    <groupId>de.schegge</groupId>
    <artifactId>freshmarker</artifactId>
    <version>0.3.0</version>
</dependency>

Schreibe einen Kommentar