feat: Theme temporals supports time zone (#2297)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind feature
/area core
/milestone 2.0
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
主题可以通过 temporals 表达式方言来格式化日期,格式化时会根据时区显示,例如:`${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}`
访问主题时通过携带 cookie 如 cookie:time_zone=Africa/Accra 来切换时区

支持的时区列表参考:https://jenkov.com/tutorials/java-date-time/java-util-timezone.html
表达式更多用法参考:https://github.com/thymeleaf/thymeleaf-extras-java8time
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #2293

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2321/head
guqing 2022-08-03 18:24:15 +08:00 committed by GitHub
parent 86f9daf421
commit 3d38979d80
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 208 additions and 10 deletions

View File

@ -11,8 +11,10 @@ import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.FilePathUtils; import run.halo.app.infra.utils.FilePathUtils;
import run.halo.app.theme.dialect.ThemeJava8TimeDialect;
/** /**
* @author guqing * @author guqing
@ -59,4 +61,9 @@ public class ThemeConfiguration {
return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(), return FilePathUtils.combinePath(haloProperties.getWorkDir().toString(),
"themes", themeName, "templates", "assets", resource); "themes", themeName, "templates", "assets", resource);
} }
@Bean
Java8TimeDialect java8TimeDialect() {
return new ThemeJava8TimeDialect();
}
} }

View File

@ -0,0 +1,28 @@
package run.halo.app.theme.dialect;
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_REQUEST_ATTRIBUTE_NAME;
import java.util.TimeZone;
import org.thymeleaf.context.IExpressionContext;
import org.thymeleaf.extras.java8time.dialect.Java8TimeExpressionFactory;
import org.thymeleaf.extras.java8time.expression.Temporals;
/**
* @author guqing
* @since 2.0.0
*/
public class DefaultJava8TimeExpressionFactory extends Java8TimeExpressionFactory {
private static final String TEMPORAL_EVALUATION_VARIABLE_NAME = "temporals";
@Override
public Object buildObject(IExpressionContext context, String expressionObjectName) {
TimeZone timeZone = (TimeZone) context.getVariable(TIME_ZONE_REQUEST_ATTRIBUTE_NAME);
if (timeZone == null) {
timeZone = TimeZone.getDefault();
}
if (TEMPORAL_EVALUATION_VARIABLE_NAME.equals(expressionObjectName)) {
return new Temporals(context.getLocale(), timeZone.toZoneId());
}
return null;
}
}

View File

@ -0,0 +1,18 @@
package run.halo.app.theme.dialect;
import org.thymeleaf.expression.IExpressionObjectFactory;
import org.thymeleaf.extras.java8time.dialect.Java8TimeDialect;
/**
* @author guqing
* @since 2.0.0
*/
public class ThemeJava8TimeDialect extends Java8TimeDialect {
private final IExpressionObjectFactory expressionObjectFactory =
new DefaultJava8TimeExpressionFactory();
@Override
public IExpressionObjectFactory getExpressionObjectFactory() {
return expressionObjectFactory;
}
}

View File

@ -0,0 +1,147 @@
package run.halo.app.theme.dialect;
import static org.assertj.core.api.Assertions.assertThat;
import static run.halo.app.theme.ThemeLocaleContextResolver.TIME_ZONE_COOKIE_NAME;
import java.io.FileNotFoundException;
import java.net.URL;
import java.nio.file.Paths;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.function.Function;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.ResourceUtils;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.extras.java8time.expression.Temporals;
import run.halo.app.theme.ThemeContext;
import run.halo.app.theme.ThemeResolver;
/**
* Tests for {@link ThemeJava8TimeDialect}.
*
* @author guqing
* @since 2.0.0
*/
@SpringBootTest
@AutoConfigureWebTestClient
class ThemeJava8TimeDialectIntegrationTest {
private static final Instant INSTANT = Instant.now();
@Autowired
private ThemeResolver themeResolver;
private URL defaultThemeUrl;
Function<ServerHttpRequest, ThemeContext> themeContextFunction;
@Autowired
private WebTestClient webTestClient;
private TimeZone defaultTimeZone;
@BeforeEach
void setUp() throws FileNotFoundException {
themeContextFunction = themeResolver.getThemeContextFunction();
themeResolver.setThemeContextFunction(request -> createDefaultContext());
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
defaultTimeZone = TimeZone.getDefault();
}
@AfterEach
void tearDown() {
TimeZone.setDefault(defaultTimeZone);
this.themeResolver.setThemeContextFunction(themeContextFunction);
}
@Test
void temporalsInAmerica() {
TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
TimeZone.setDefault(timeZone);
assertTemporals(timeZone);
}
@Test
void temporalsInChina() {
TimeZone timeZone = TimeZone.getTimeZone("Asia/Shanghai");
TimeZone.setDefault(timeZone);
assertTemporals(timeZone);
}
@Test
void timeZoneFromCookie() {
TimeZone timeZone = TimeZone.getTimeZone("Africa/Accra");
String formatTime = timeZoneTemporalFormat(timeZone.toZoneId());
webTestClient.get()
.uri("/timezone?language=zh")
.cookie(TIME_ZONE_COOKIE_NAME, timeZone.toZoneId().toString())
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(String.format("<p>%s</p>\n", formatTime));
}
@Test
void invalidTimeZone() {
TimeZone timeZone = TimeZone.getTimeZone("invalid/timezone");
//the GMT zone if the given ID cannot be understood.
assertThat(timeZone.toZoneId().toString()).isEqualTo("GMT");
}
private void assertTemporals(TimeZone timeZone) {
String formatTime = timeZoneTemporalFormat(timeZone.toZoneId());
webTestClient.get()
.uri("/timezone?language=zh")
.exchange()
.expectStatus()
.isOk()
.expectBody(String.class)
.isEqualTo(String.format("<p>%s</p>\n", formatTime));
}
private String timeZoneTemporalFormat(ZoneId zoneId) {
Temporals temporals = new Temporals(Locale.CHINESE, zoneId);
return temporals.format(INSTANT, "yyyy-MM-dd HH:mm:ss");
}
ThemeContext createDefaultContext() {
return ThemeContext.builder()
.name("default")
.path(Paths.get(defaultThemeUrl.getPath()))
.active(true)
.build();
}
@TestConfiguration
static class MessageResolverConfig {
@Bean
RouterFunction<ServerResponse> routeTestIndex() {
return RouterFunctions
.route(RequestPredicates.GET("/timezone")
.and(RequestPredicates.accept(MediaType.TEXT_HTML)),
request -> ServerResponse.ok().render("timezone", Map.of("instants", INSTANT)));
}
}
}

View File

@ -3,15 +3,14 @@ package run.halo.app.theme.message;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.net.URL; import java.net.URL;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.time.Duration;
import java.util.function.Function; import java.util.function.Function;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpRequest;
@ -30,27 +29,25 @@ import run.halo.app.theme.ThemeResolver;
* @author guqing * @author guqing
* @since 2.0.0 * @since 2.0.0
*/ */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @SpringBootTest
@AutoConfigureWebTestClient
public class ThemeMessageResolverIntegrationTest { public class ThemeMessageResolverIntegrationTest {
@Autowired
private ApplicationContext applicationContext;
@Autowired @Autowired
private ThemeResolver themeResolver; private ThemeResolver themeResolver;
private URL defaultThemeUrl; private URL defaultThemeUrl;
private URL otherThemeUrl; private URL otherThemeUrl;
Function<ServerHttpRequest, ThemeContext> themeContextFunction; Function<ServerHttpRequest, ThemeContext> themeContextFunction;
@Autowired
private WebTestClient webTestClient; private WebTestClient webTestClient;
@BeforeEach @BeforeEach
void setUp() throws FileNotFoundException { void setUp() throws FileNotFoundException {
themeContextFunction = themeResolver.getThemeContextFunction(); themeContextFunction = themeResolver.getThemeContextFunction();
webTestClient = WebTestClient
.bindToApplicationContext(applicationContext)
.configureClient()
.responseTimeout(Duration.ofMinutes(1))
.build();
defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default"); defaultThemeUrl = ResourceUtils.getURL("classpath:themes/default");
otherThemeUrl = ResourceUtils.getURL("classpath:themes/other"); otherThemeUrl = ResourceUtils.getURL("classpath:themes/other");

View File

@ -0,0 +1 @@
<p th:text="${#temporals.format(instants, 'yyyy-MM-dd HH:mm:ss')}"></p>