Skip to content

Commit b72cbec

Browse files
Atlas localization (#1821)
Implementation of internationalization. Supported Lanauges: - English - Russian - Korean - Chinese
1 parent 6ab5c74 commit b72cbec

20 files changed

+14602
-26
lines changed

README.md

+1-3
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,4 @@ It was chosen to use embedded PG instead of H2 for unit tests since H2 doesn't s
7575
- Only Non-SNAPSHOT dependencies should be presented in POM.xml on release branches/tags.
7676

7777
## License
78-
OHDSI WebAPI is licensed under Apache License 2.0
79-
80-
78+
OHDSI WebAPI is licensed under Apache License 2.0

i18n.md

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Atlas localization
2+
3+
## Backend
4+
5+
Localization files directory: src/main/resources/i18n
6+
7+
Localization file naming convention: messages_{lang}.json, where {lang} is two-letters (ISO 639-1) language code.
8+
9+
List of supported localizations is presented in locales.json file in the same directory:
10+
```
11+
[
12+
{
13+
"code": "en",
14+
"name": "English",
15+
"default": true
16+
},
17+
{
18+
"code": "ru",
19+
"name": "Русский"
20+
},
21+
{
22+
"code": "ko",
23+
"name": "한국어"
24+
},
25+
{
26+
"code": "zh",
27+
"name": "中文"
28+
}
29+
]
30+
```
31+
32+
Messages file is in JSON format and contains localized messages organized as a tree:
33+
```
34+
{
35+
"section1": {
36+
"subsection1": {
37+
"message": "Localized message1"
38+
},
39+
"subsection2": {
40+
"message": "Localized message2"
41+
},
42+
},
43+
"section2": {
44+
"subsection1": {
45+
"message": "Localized message3 <%=param%>"
46+
}
47+
}
48+
}
49+
```
50+
51+
so each localized message has a unique path. For example: ``section1.subsection2.message`` points to "Localized message1".
52+
53+
## Frontend
54+
55+
Languages listed in locales.json are shown in the selector on the upper part of the page.
56+
57+
When choosing a language, all text on the page changes dynamically without the need to reload the page.
58+
59+
Reference to localized message can be placed whereever ko object is available.
60+
61+
There are two methods in ko for localized messages (see js/extensions/bindings/i18nBinding.js):
62+
63+
- `ko.i18n(path, defaultMessage)` where path is a path to the message in the localization file `messages_{lang}.json` and `defaultMessage` is a text, that will be used if there is no such path in localization file.
64+
65+
- `ko.i18nformat(path, defaultMessage, parameters)` - same as above, but with parameters.
66+
For example:
67+
message (in localization file and/or default): `Localized message3 <%=param%>`
68+
parameters object: `{"param": "some text"}`
69+
result: `Localized message3 some text`
70+
Parameter values can be localized as well with any nesting level.
71+
72+
Both methods return ko.pureComputed() function, that can be unwrapped or called to receive a localized message (see examples below).
73+
74+
Examples:
75+
76+
1. In HTML files through `data-bind="text: ..."`
77+
```
78+
<span data-bind="text: ko.i18n('section1.subsection2.message', 'Localized message1')"></span>
79+
```
80+
81+
2. Passed as parameter, provided that it will be used as it is in data-bind attribute, or unwrapped in any other way:
82+
```
83+
<atlas-modal params="
84+
showModal: $component.isExecutionDesignShown,
85+
title: ko.i18n('components.analysisExecution.designModal.title', 'Design'),
86+
...
87+
```
88+
89+
3. In .js:
90+
```
91+
alert(
92+
ko.i18n('cohortDefinitions.cohortDefinitionManager.confirms.save',
93+
'Cohort definition cannot be deleted because it is referenced in some analysis.')()
94+
)
95+
```
96+
97+
or
98+
```
99+
alert(
100+
ko.unwrap(
101+
ko.i18n('cohortDefinitions.cohortDefinitionManager.confirms.save',
102+
'Cohort definition cannot be deleted because it is referenced in some analysis.')
103+
)
104+
)
105+
```

pom.xml

+8
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@
251251
<buildinfo.webapi.release.tag>*</buildinfo.webapi.release.tag>
252252

253253
<gis.enabled>false</gis.enabled>
254+
255+
<!-- I18n -->
256+
<i18n.defaultLocale>en</i18n.defaultLocale>
254257
</properties>
255258
<build>
256259
<finalName>WebAPI</finalName>
@@ -299,6 +302,11 @@
299302
</testResource>
300303
</testResources>
301304
<plugins>
305+
<plugin>
306+
<groupId>org.apache.maven.plugins</groupId>
307+
<artifactId>maven-dependency-plugin</artifactId>
308+
<version>3.0.1</version>
309+
</plugin>
302310
<plugin>
303311
<groupId>pl.project13.maven</groupId>
304312
<artifactId>git-commit-id-plugin</artifactId>

src/main/java/org/ohdsi/webapi/Constants.java

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ interface Variables {
6969

7070
interface Headers {
7171
String AUTH_PROVIDER = "x-auth-provider";
72+
String USER_LANGAUGE = "User-Language";
7273
}
7374

7475
interface SecurityProviders {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package org.ohdsi.webapi;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.web.servlet.LocaleResolver;
6+
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
7+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
8+
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
9+
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
10+
11+
import java.util.Locale;
12+
13+
@Configuration
14+
public class I18nConfig {
15+
16+
@Bean
17+
public LocaleResolver localeResolver() {
18+
19+
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
20+
localeResolver.setDefaultLocale(new Locale("en"));
21+
return localeResolver;
22+
}
23+
24+
public void addInterceptors(InterceptorRegistry registry) {
25+
26+
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
27+
localeChangeInterceptor.setParamName("lang");
28+
registry.addInterceptor(localeChangeInterceptor);
29+
}
30+
}

src/main/java/org/ohdsi/webapi/cohortcharacterization/CcServiceImpl.java

+7-2
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
import org.ohdsi.webapi.feanalysis.domain.FeAnalysisWithCriteriaEntity;
5252
import org.ohdsi.webapi.feanalysis.domain.FeAnalysisWithStringEntity;
5353
import org.ohdsi.webapi.feanalysis.event.FeAnalysisChangedEvent;
54+
import org.ohdsi.webapi.i18n.I18nService;
5455
import org.ohdsi.webapi.job.GeneratesNotification;
5556
import org.ohdsi.webapi.job.JobExecutionResource;
5657
import org.ohdsi.webapi.service.FeatureExtractionService;
@@ -186,6 +187,7 @@ public class CcServiceImpl extends AbstractDaoService implements CcService, Gene
186187
private final EntityManager entityManager;
187188
private final ApplicationEventPublisher eventPublisher;
188189

190+
private final I18nService i18nService;
189191
private final JobRepository jobRepository;
190192
private final SourceAwareSqlRender sourceAwareSqlRender;
191193
private final JobService jobService;
@@ -205,6 +207,7 @@ public CcServiceImpl(
205207
final FeatureExtractionService featureExtractionService,
206208
final ConversionService conversionService,
207209
final DesignImportService designImportService,
210+
final I18nService i18nService,
208211
final JobRepository jobRepository,
209212
final AnalysisGenerationInfoEntityRepository analysisGenerationInfoEntityRepository,
210213
final SourceService sourceService,
@@ -225,6 +228,7 @@ public CcServiceImpl(
225228
this.ccGenerationRepository = ccGenerationRepository;
226229
this.featureExtractionService = featureExtractionService;
227230
this.designImportService = designImportService;
231+
this.i18nService = i18nService;
228232
this.jobRepository = jobRepository;
229233
this.analysisGenerationInfoEntityRepository = analysisGenerationInfoEntityRepository;
230234
this.sourceService = sourceService;
@@ -818,17 +822,18 @@ private List<Report> prepareReportData(Map<Integer, AnalysisItem> analysisMap, S
818822
}
819823
}
820824
if (!ignoreSummary) {
825+
final String translatedName = i18nService.translate("cc.viewEdit.results.allPrevalenceCovariates", "All prevalence covariates");
821826
// summary comparative reports are only available for prevalence type
822827
if (!simpleResultSummary.isEmpty()) {
823-
Report simpleSummaryData = new Report("All prevalence covariates", simpleResultSummary);
828+
Report simpleSummaryData = new Report(translatedName, simpleResultSummary);
824829
simpleSummaryData.header = executionPrevalenceHeaderLines;
825830
simpleSummaryData.isSummary = true;
826831
simpleSummaryData.resultType = PREVALENCE;
827832
reports.add(simpleSummaryData);
828833
}
829834
// comparative mode
830835
if (!comparativeResultSummary.isEmpty()) {
831-
Report comparativeSummaryData = new Report("All prevalence covariates", comparativeResultSummary);
836+
Report comparativeSummaryData = new Report(translatedName, comparativeResultSummary);
832837
comparativeSummaryData.header = executionComparativeHeaderLines;
833838
comparativeSummaryData.isSummary = true;
834839
comparativeSummaryData.isComparative = true;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.ohdsi.webapi.i18n;
2+
3+
import com.fasterxml.jackson.databind.JavaType;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import org.ohdsi.circe.helper.ResourceHelper;
6+
import org.springframework.beans.factory.annotation.Autowired;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Controller;
9+
10+
import javax.annotation.PostConstruct;
11+
import javax.ws.rs.GET;
12+
import javax.ws.rs.Path;
13+
import javax.ws.rs.Produces;
14+
import javax.ws.rs.container.ContainerRequestContext;
15+
import javax.ws.rs.core.Context;
16+
import javax.ws.rs.core.MediaType;
17+
import javax.ws.rs.core.Response;
18+
import java.io.IOException;
19+
import java.net.URL;
20+
import java.util.List;
21+
import java.util.Locale;
22+
import java.util.Objects;
23+
24+
@Path("/i18n/")
25+
@Controller
26+
public class I18nController {
27+
28+
@Value("${i18n.defaultLocale}")
29+
private String defaultLocale = "en";
30+
31+
@Autowired
32+
private I18nService i18nService;
33+
34+
@GET
35+
@Path("/")
36+
@Produces(MediaType.APPLICATION_JSON)
37+
public Response getResources(@Context ContainerRequestContext requestContext) {
38+
39+
Locale locale = (Locale) requestContext.getProperty("language");
40+
if (locale == null || !isLocaleSupported(locale.getLanguage())) {
41+
locale = Locale.forLanguageTag(defaultLocale);
42+
}
43+
String messages = i18nService.getLocaleResource(locale);
44+
return Response.ok(messages).build();
45+
}
46+
47+
private boolean isLocaleSupported(String code) {
48+
49+
return i18nService.getAvailableLocales().stream().anyMatch(l -> Objects.equals(code, l.getCode()));
50+
}
51+
52+
@GET
53+
@Path("/locales")
54+
@Produces(MediaType.APPLICATION_JSON)
55+
public List<LocaleDTO> getAvailableLocales() {
56+
57+
return i18nService.getAvailableLocales();
58+
}
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.ohdsi.webapi.i18n;
2+
3+
import java.util.List;
4+
import java.util.Locale;
5+
6+
public interface I18nService {
7+
List<LocaleDTO> getAvailableLocales();
8+
9+
String translate(String key);
10+
String translate(String key, String defaultValue);
11+
12+
String getLocaleResource(Locale locale);
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.ohdsi.webapi.i18n;
2+
3+
import com.fasterxml.jackson.databind.JavaType;
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import org.ohdsi.circe.helper.ResourceHelper;
7+
import org.springframework.context.i18n.LocaleContextHolder;
8+
import org.springframework.stereotype.Component;
9+
10+
import javax.annotation.PostConstruct;
11+
import javax.ws.rs.InternalServerErrorException;
12+
import java.io.IOException;
13+
import java.net.URL;
14+
import java.util.Collections;
15+
import java.util.List;
16+
import java.util.Locale;
17+
18+
@Component
19+
public class I18nServiceImpl implements I18nService {
20+
21+
private List<LocaleDTO> availableLocales;
22+
23+
@PostConstruct
24+
public void init() throws IOException {
25+
26+
String json = ResourceHelper.GetResourceAsString("/i18n/locales.json");
27+
ObjectMapper objectMapper = new ObjectMapper();
28+
JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, LocaleDTO.class);
29+
availableLocales = objectMapper.readValue(json, type);
30+
}
31+
32+
@Override
33+
public List<LocaleDTO> getAvailableLocales() {
34+
35+
return Collections.unmodifiableList(availableLocales);
36+
}
37+
38+
@Override
39+
public String translate(String key) {
40+
41+
return translate(key, key);
42+
}
43+
44+
@Override
45+
public String translate(String key, String defaultValue) {
46+
47+
try {
48+
Locale locale = LocaleContextHolder.getLocale();
49+
String messages = getLocaleResource(locale);
50+
ObjectMapper mapper = new ObjectMapper();
51+
JsonNode root = mapper.readTree(messages);
52+
String pointer = "/" + key.replaceAll("\\.", "/");
53+
JsonNode node = root.at(pointer);
54+
return node.isValueNode() ? node.asText() : defaultValue;
55+
}catch (IOException e) {
56+
throw new InternalServerErrorException(e);
57+
}
58+
}
59+
60+
@Override
61+
public String getLocaleResource(Locale locale) {
62+
63+
String resourcePath = String.format("/i18n/messages_%s.json", locale.getLanguage());
64+
URL resourceURL = this.getClass().getResource(resourcePath);
65+
String messages = "";
66+
if (resourceURL != null) {
67+
messages = ResourceHelper.GetResourceAsString(resourcePath);
68+
}
69+
return messages;
70+
}
71+
}

0 commit comments

Comments
 (0)