Skip to content

Commit

Permalink
Atlas localization (OHDSI#1821)
Browse files Browse the repository at this point in the history
Implementation of internationalization.
Supported Lanauges:
- English
- Russian
- Korean
- Chinese
  • Loading branch information
anton-abushkevich authored Apr 6, 2021
1 parent 6ab5c74 commit b72cbec
Show file tree
Hide file tree
Showing 20 changed files with 14,602 additions and 26 deletions.
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,4 @@ It was chosen to use embedded PG instead of H2 for unit tests since H2 doesn't s
- Only Non-SNAPSHOT dependencies should be presented in POM.xml on release branches/tags.

## License
OHDSI WebAPI is licensed under Apache License 2.0


OHDSI WebAPI is licensed under Apache License 2.0
105 changes: 105 additions & 0 deletions i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Atlas localization

## Backend

Localization files directory: src/main/resources/i18n

Localization file naming convention: messages_{lang}.json, where {lang} is two-letters (ISO 639-1) language code.

List of supported localizations is presented in locales.json file in the same directory:
```
[
{
"code": "en",
"name": "English",
"default": true
},
{
"code": "ru",
"name": "Русский"
},
{
"code": "ko",
"name": "한국어"
},
{
"code": "zh",
"name": "中文"
}
]
```

Messages file is in JSON format and contains localized messages organized as a tree:
```
{
"section1": {
"subsection1": {
"message": "Localized message1"
},
"subsection2": {
"message": "Localized message2"
},
},
"section2": {
"subsection1": {
"message": "Localized message3 <%=param%>"
}
}
}
```

so each localized message has a unique path. For example: ``section1.subsection2.message`` points to "Localized message1".

## Frontend

Languages listed in locales.json are shown in the selector on the upper part of the page.

When choosing a language, all text on the page changes dynamically without the need to reload the page.

Reference to localized message can be placed whereever ko object is available.

There are two methods in ko for localized messages (see js/extensions/bindings/i18nBinding.js):

- `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.

- `ko.i18nformat(path, defaultMessage, parameters)` - same as above, but with parameters.
For example:
message (in localization file and/or default): `Localized message3 <%=param%>`
parameters object: `{"param": "some text"}`
result: `Localized message3 some text`
Parameter values can be localized as well with any nesting level.

Both methods return ko.pureComputed() function, that can be unwrapped or called to receive a localized message (see examples below).

Examples:

1. In HTML files through `data-bind="text: ..."`
```
<span data-bind="text: ko.i18n('section1.subsection2.message', 'Localized message1')"></span>
```

2. Passed as parameter, provided that it will be used as it is in data-bind attribute, or unwrapped in any other way:
```
<atlas-modal params="
showModal: $component.isExecutionDesignShown,
title: ko.i18n('components.analysisExecution.designModal.title', 'Design'),
...
```

3. In .js:
```
alert(
ko.i18n('cohortDefinitions.cohortDefinitionManager.confirms.save',
'Cohort definition cannot be deleted because it is referenced in some analysis.')()
)
```

or
```
alert(
ko.unwrap(
ko.i18n('cohortDefinitions.cohortDefinitionManager.confirms.save',
'Cohort definition cannot be deleted because it is referenced in some analysis.')
)
)
```
8 changes: 8 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,9 @@
<buildinfo.webapi.release.tag>*</buildinfo.webapi.release.tag>

<gis.enabled>false</gis.enabled>

<!-- I18n -->
<i18n.defaultLocale>en</i18n.defaultLocale>
</properties>
<build>
<finalName>WebAPI</finalName>
Expand Down Expand Up @@ -299,6 +302,11 @@
</testResource>
</testResources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.0.1</version>
</plugin>
<plugin>
<groupId>pl.project13.maven</groupId>
<artifactId>git-commit-id-plugin</artifactId>
Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/ohdsi/webapi/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ interface Variables {

interface Headers {
String AUTH_PROVIDER = "x-auth-provider";
String USER_LANGAUGE = "User-Language";
}

interface SecurityProviders {
Expand Down
30 changes: 30 additions & 0 deletions src/main/java/org/ohdsi/webapi/I18nConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.ohdsi.webapi;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;

import java.util.Locale;

@Configuration
public class I18nConfig {

@Bean
public LocaleResolver localeResolver() {

AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(new Locale("en"));
return localeResolver;
}

public void addInterceptors(InterceptorRegistry registry) {

LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
registry.addInterceptor(localeChangeInterceptor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.ohdsi.webapi.feanalysis.domain.FeAnalysisWithCriteriaEntity;
import org.ohdsi.webapi.feanalysis.domain.FeAnalysisWithStringEntity;
import org.ohdsi.webapi.feanalysis.event.FeAnalysisChangedEvent;
import org.ohdsi.webapi.i18n.I18nService;
import org.ohdsi.webapi.job.GeneratesNotification;
import org.ohdsi.webapi.job.JobExecutionResource;
import org.ohdsi.webapi.service.FeatureExtractionService;
Expand Down Expand Up @@ -186,6 +187,7 @@ public class CcServiceImpl extends AbstractDaoService implements CcService, Gene
private final EntityManager entityManager;
private final ApplicationEventPublisher eventPublisher;

private final I18nService i18nService;
private final JobRepository jobRepository;
private final SourceAwareSqlRender sourceAwareSqlRender;
private final JobService jobService;
Expand All @@ -205,6 +207,7 @@ public CcServiceImpl(
final FeatureExtractionService featureExtractionService,
final ConversionService conversionService,
final DesignImportService designImportService,
final I18nService i18nService,
final JobRepository jobRepository,
final AnalysisGenerationInfoEntityRepository analysisGenerationInfoEntityRepository,
final SourceService sourceService,
Expand All @@ -225,6 +228,7 @@ public CcServiceImpl(
this.ccGenerationRepository = ccGenerationRepository;
this.featureExtractionService = featureExtractionService;
this.designImportService = designImportService;
this.i18nService = i18nService;
this.jobRepository = jobRepository;
this.analysisGenerationInfoEntityRepository = analysisGenerationInfoEntityRepository;
this.sourceService = sourceService;
Expand Down Expand Up @@ -818,17 +822,18 @@ private List<Report> prepareReportData(Map<Integer, AnalysisItem> analysisMap, S
}
}
if (!ignoreSummary) {
final String translatedName = i18nService.translate("cc.viewEdit.results.allPrevalenceCovariates", "All prevalence covariates");
// summary comparative reports are only available for prevalence type
if (!simpleResultSummary.isEmpty()) {
Report simpleSummaryData = new Report("All prevalence covariates", simpleResultSummary);
Report simpleSummaryData = new Report(translatedName, simpleResultSummary);
simpleSummaryData.header = executionPrevalenceHeaderLines;
simpleSummaryData.isSummary = true;
simpleSummaryData.resultType = PREVALENCE;
reports.add(simpleSummaryData);
}
// comparative mode
if (!comparativeResultSummary.isEmpty()) {
Report comparativeSummaryData = new Report("All prevalence covariates", comparativeResultSummary);
Report comparativeSummaryData = new Report(translatedName, comparativeResultSummary);
comparativeSummaryData.header = executionComparativeHeaderLines;
comparativeSummaryData.isSummary = true;
comparativeSummaryData.isComparative = true;
Expand Down
59 changes: 59 additions & 0 deletions src/main/java/org/ohdsi/webapi/i18n/I18nController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package org.ohdsi.webapi.i18n;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ohdsi.circe.helper.ResourceHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;

import javax.annotation.PostConstruct;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.net.URL;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

@Path("/i18n/")
@Controller
public class I18nController {

@Value("${i18n.defaultLocale}")
private String defaultLocale = "en";

@Autowired
private I18nService i18nService;

@GET
@Path("/")
@Produces(MediaType.APPLICATION_JSON)
public Response getResources(@Context ContainerRequestContext requestContext) {

Locale locale = (Locale) requestContext.getProperty("language");
if (locale == null || !isLocaleSupported(locale.getLanguage())) {
locale = Locale.forLanguageTag(defaultLocale);
}
String messages = i18nService.getLocaleResource(locale);
return Response.ok(messages).build();
}

private boolean isLocaleSupported(String code) {

return i18nService.getAvailableLocales().stream().anyMatch(l -> Objects.equals(code, l.getCode()));
}

@GET
@Path("/locales")
@Produces(MediaType.APPLICATION_JSON)
public List<LocaleDTO> getAvailableLocales() {

return i18nService.getAvailableLocales();
}
}
13 changes: 13 additions & 0 deletions src/main/java/org/ohdsi/webapi/i18n/I18nService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package org.ohdsi.webapi.i18n;

import java.util.List;
import java.util.Locale;

public interface I18nService {
List<LocaleDTO> getAvailableLocales();

String translate(String key);
String translate(String key, String defaultValue);

String getLocaleResource(Locale locale);
}
71 changes: 71 additions & 0 deletions src/main/java/org/ohdsi/webapi/i18n/I18nServiceImpl.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package org.ohdsi.webapi.i18n;

import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ohdsi.circe.helper.ResourceHelper;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.ws.rs.InternalServerErrorException;
import java.io.IOException;
import java.net.URL;
import java.util.Collections;
import java.util.List;
import java.util.Locale;

@Component
public class I18nServiceImpl implements I18nService {

private List<LocaleDTO> availableLocales;

@PostConstruct
public void init() throws IOException {

String json = ResourceHelper.GetResourceAsString("/i18n/locales.json");
ObjectMapper objectMapper = new ObjectMapper();
JavaType type = objectMapper.getTypeFactory().constructCollectionType(List.class, LocaleDTO.class);
availableLocales = objectMapper.readValue(json, type);
}

@Override
public List<LocaleDTO> getAvailableLocales() {

return Collections.unmodifiableList(availableLocales);
}

@Override
public String translate(String key) {

return translate(key, key);
}

@Override
public String translate(String key, String defaultValue) {

try {
Locale locale = LocaleContextHolder.getLocale();
String messages = getLocaleResource(locale);
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(messages);
String pointer = "/" + key.replaceAll("\\.", "/");
JsonNode node = root.at(pointer);
return node.isValueNode() ? node.asText() : defaultValue;
}catch (IOException e) {
throw new InternalServerErrorException(e);
}
}

@Override
public String getLocaleResource(Locale locale) {

String resourcePath = String.format("/i18n/messages_%s.json", locale.getLanguage());
URL resourceURL = this.getClass().getResource(resourcePath);
String messages = "";
if (resourceURL != null) {
messages = ResourceHelper.GetResourceAsString(resourcePath);
}
return messages;
}
}
Loading

0 comments on commit b72cbec

Please sign in to comment.