“In the depth of winter, I finally learned that within me there lay an invincible summer.”
― Albert Camus
Some features in open source projects are gimmicks whose utility value is rather low. On the other hand, they enable the evaluation of new functions and previously unused libraries. In this article, support for astronomical seasons is implemented in Freshmarker using the Time4J library.
FreshMarker has a helpful extension mechanism that can be used to supplement the core functionality of the template engine. New primitive FreshMarker types, new feature flags, new built-ins and new built-in variables can be added.
Time4J is a Java library designed for advanced date, time and interval calculations. It is intended as a modern replacement for older Java classes such as java.util.Date
and java.util.Calendar
. It is also intended as an alternative to Joda-Time and JSR-310. For this blog post, we will only look at the net.time4j.calendar.astro.AstronomicalSeason
class.
To support this data type in FreshMarker as a primitive type, we need a class that implements some FreshMarker Extension interfaces.
public class AstroExtension implements BuiltInProvider, BuiltInVariableProvider, TemplateFeatureProvider, TypeMapperProvider { // ... }
The AstroExtension
implements four interfaces: BuiltInProvider
, BuiltInVariableProvider
, TemplateFeatureProvider
and TypeMapperProvider
. What the interfaces are used for is discussed below.
No additional work is actually required to use an AstronomicalSeason
instance in the model. As an enum, this type is automatically recognized by FreshMarker in the model. However, no additional built-ins can then be added. A TypeMapperProvider
is required so that a custom primitive type can be used.
@Override public Map<Class<?>, TypeMapper> providerTypeMapper() { return Map.of(AstronomicalSeason.class, o -> new TemplateSeason((AstronomicalSeason)o)); }
The providerTypeMapper
method returns a Map
that contains a TypeMapper
for each recognized type. In this case, only for the AstronomicalSeason
class, with a TypeMapper
that creates an instance of the TemplateSeason
class.
public class TemplateSeason extends TemplatePrimitive<AstronomicalSeason> { private static final Map<AstronomicalSeason, TemplateSeason> VALUES = Arrays.stream(AstronomicalSeason.values()) .collect(Collectors.toMap(Function.identity(), TemplateSeason::new)); public static TemplateSeason of(AstronomicalSeason season) { return VALUES.get(season); } private TemplateSeason(AstronomicalSeason value) { super(value); } }
The TemplateSeason
class inherits from TemplatePrimitive
and that’s all you need to know about the implementation. The custom type is necessary to implement built-ins for the AstronomicalSeason
class, so let’s look at the implementation of the BuiltInProvider
interface.
@Override public Register<Class<? extends TemplateObject>, String, BuiltIn> provideBuiltInRegister() { BuiltInRegister register = new BuiltInRegister(); register.add(TemplateSeason.class, "c", (x, y, c) -> new TemplateString(((TemplateSeason) x).getValue().toString())); register.add(TemplateSeason.class, "h", (x, y, c) -> getSeasonName((TemplateSeason) x, c)); register.add(TemplateSeason.class, "next", (x, y, c) -> getAdjacentSeason((TemplateSeason) x, 1)); register.add(TemplateSeason.class, "previous", (x, y, c) -> getAdjacentSeason((TemplateSeason) x, -1)); register.add(TemplateZonedDateTime.class, "season", (x, y, c) -> getAstronomicalSeason(((TemplateZonedDateTime) x).getValue().toInstant(), c)); register.add(TemplateInstant.class, "season", (x, y, c) -> getAstronomicalSeason(((TemplateInstant) x).getValue(), c)); return register; }
In the provideBuiltInRegister
method, BuiltIn
implementations are assigned to various types. We give the TemplateSeason
class four BuiltIn implementations. These are c
, h
, next
and previous
.
The BuiltIn
implementation c
(computer language) generates the String
representation of the enum value as output. This means that the Built-In expression ${season?c}
generates the same output as the expression ${season}
. The Built-In is helpful if different types are permitted in an expression, some of which require a Built-In c
.
The BuiltIn
implementation h
(human language) generates an I18N representation of the enum value. This means that the words Frühling, Sommer, Herbst and Winter are used in German and spring, summer, autumn and winter in English.
private static TemplateString getSeasonName(TemplateSeason x, ProcessContext c) { return new TemplateString(ResourceBundle.getBundle("astro", c.getLocale()).getString("season." + x.getValue())); }
The getSeasonName
method retrieves the corresponding name from the ResourceBundle
astro for the current locale and returns it as a TemplateString
.
The BuiltIn
implementations previous
and next
provide the previous season and the following season respectively. They are both implemented by the getAdjacentSeason
method.
private TemplateSeason getAdjacentSeason(TemplateSeason x, int offset) { AstronomicalSeason season = x.getValue(); return TemplateSeason.of(AstronomicalSeason.values()[(season.ordinal() + (offset < 0 ? offset + 4 : offset) % 4]); }
Here, the appropriate enum constant is selected with the help of the modulo operator and the corresponding AstronomicalSeason
object is returned.
The BuiltIn
implementations season
allows you to calculate an AstronomicalSeason
from a ZonedDateTime
or an Instant
instanz. At this point, things get a little more complicated.
My birthday falls in summer, so a ZonedDateTime
variable with my birthday in the expression ${birthday?season?h}
should return the value summer
. But this is, in the truest sense of the word, only half the truth. Because my birthday is only in summer in the northern hemisphere. In the southern hemisphere, my birthday falls in winter. So for a proper implementation, we need to know for which hemisphere the calculation is being carried out.
Unfortunately, there is no reference to the hemishere in FreshMarker or Java so far, so we’ll make do with a solution using the on-board tools of the Extension API. For use in the southern hemisphere, we provide the feature AstroFeatures.AT_SOUTHERN_HEMISPHERE
.
Configuration configuration = new Configuration(AstroFeatures.AT_SOUTHERN_HEMISPHERE);
This configuration ensures that the calculations are carried out for the southern hemisphere. As a TemplateFeatureProvider
, our extension registers the feature with FreshMarker.
@Override public Set<TemplateFeature> provideFeatures() { return Set.of(AstroFeatures.values()); }
To generate an AstronomicalSeason
from a Temporal
, the getAstronomicalSeason
method is called, which allows the calculated AstronomicalSeason
value to be manipulated again by the byHemishere
method.
private TemplateSeason getAstronomicalSeason(Instant x, ProcessContext c) { return new TemplateSeason(byHemisphere(AstronomicalSeason.of(Moment.from(x)),c )); } private AstronomicalSeason byHemisphere(AstronomicalSeason season, ProcessContext context) { if (computeHemisphere(context) == Hemisphere.NORTH) { return season; } return season.onSouthernHemisphere(); }
The byHemishere
method uses the computeHemisphere
method to check whether the AstronomicalSeason
needs to be changed. If the result of computeHemisphere
is Hemisphere.NORTH
, then the value remains unchanged, otherwise it is changed.
private static Hemisphere computeHemisphere(ProcessContext context) { return context.getFeatureSet().isEnabled(AstroFeatures.AT_SOUTHERN_HEMISPHERE) ? Hemisphere.SOUTH : Hemisphere.NORTH; }
The computeHemisphere
method is the reason why the new Extension cannot be used with FreshMarker 1.10.0, but you have to wait for version 1.11.0. The FreshMarker features are not yet accessible via the ProcessContext.
Anyone who remembers the interfaces from the first code example will miss the BuiltInVariableProvider
interface. This interface can be used to integrate your own BuiltInVariables
into FreshMarker. Since we can now configure the hemisphere, it would also be nice if we could also use it in our expressions. We provide a separate Built-In Variable .hemispher
e for this purpose.
@Override public Map<String, BuiltInVariable> provideBuiltInVariables() { return Map.of("hemisphere", context -> new TemplateEnum<>(computeHemisphere(context))); }
Sie berechnet eine TemplateEnum Instanz basierend auf dem Ergebnis unserer computeHemisphere Methode.
The following example can be processed using the implementation discussed here. Seasons can be generated from dates and manipulated further. It is also possible to specify the hemisphere for season calculations.
${season?h} ${season?next?h} ${season?next?next?h} ${season?previous?h} ${day?season?h} ${.hemisphere}
Template template = new Configuration(AT_SOUTHERN_HEMISPHERE) .builder().withLocale(Locale.GERMANY).withZoneId(CET) .get("example", contentFromLeftColumn); Map<String, Object> model = Map.of( "season", AstronomicalSeason.VERNAL_EQUINOX, "date", myBirthday.atZone(CET) ); String contentOfRightColumn = template.process(model);
Herbst Winter Frühling Sommer Winter SOUTH
We have now completed our Extension and discovered and fixed a weakness in the former Extension API. If you want to use the new Extension, you will have to be patient until FreshMarker 1.11.0 is available.