Skip to content

Commit

Permalink
Merge custom translations into client app i18n (#592)
Browse files Browse the repository at this point in the history
* Translations UI in system settings (wip)

* Merge custom translations to app's i18n

* Added/remove translations upon Locale changes, fixed bugs saving props and TRs

* Let the user enter translations for all locales at once

* Removed waning and save upon each change

* One row per translation key, one cell for the translations in each language

---------

Co-authored-by: Ramin Haeri Azad <[email protected]>
Co-authored-by: Yannick Marcon <[email protected]>
  • Loading branch information
3 people authored Jan 19, 2025
1 parent 132202e commit 6db37b8
Show file tree
Hide file tree
Showing 14 changed files with 548 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.obiba.agate.config.MetricsConfiguration;
import org.obiba.agate.domain.AgateRealm;
import org.obiba.agate.domain.Configuration;
import org.obiba.agate.domain.LocalizedString;
Expand All @@ -54,12 +55,17 @@
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;

import jakarta.inject.Inject;

import java.io.File;
import java.io.IOException;
import java.security.Key;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Set;

import static com.jayway.jsonpath.Configuration.defaultConfiguration;

Expand Down Expand Up @@ -90,12 +96,12 @@ public class ConfigurationService {

@Inject
public ConfigurationService(
AgateConfigRepository agateConfigRepository,
RealmConfigService realmConfigService,
EventBus eventBus,
Environment env,
ApplicationContext applicationContext,
ObjectMapper objectMapper) {
AgateConfigRepository agateConfigRepository,
RealmConfigService realmConfigService,
EventBus eventBus,
Environment env,
ApplicationContext applicationContext,
ObjectMapper objectMapper) {
this.agateConfigRepository = agateConfigRepository;
this.realmConfigService = realmConfigService;
this.eventBus = eventBus;
Expand Down Expand Up @@ -150,8 +156,8 @@ public Configuration getConfiguration() {
public void save(@Valid Configuration configuration) {
Configuration savedConfiguration = getOrCreateConfiguration();
BeanUtils
.copyProperties(configuration, savedConfiguration, "id", "version", "createdBy", "createdDate", "lastModifiedBy",
"lastModifiedDate", "secretKey", "agateVersion");
.copyProperties(configuration, savedConfiguration, "id", "version", "createdBy", "createdDate", "lastModifiedBy",
"lastModifiedDate", "secretKey", "agateVersion");
if (configuration.getAgateVersion() != null) savedConfiguration.setAgateVersion(configuration.getAgateVersion());
agateConfigRepository.save(savedConfiguration);
cachedConfiguration = null;
Expand Down Expand Up @@ -223,14 +229,14 @@ public JSONObject getJoinConfiguration(String locale, String application) throws
public JSONObject getJoinConfiguration(String locale, String application, boolean forEditing) throws JSONException, IOException {
Configuration config = getConfiguration();
List<RealmConfig> realms = Strings.isNullOrEmpty(application)
? realmConfigService.findAllRealmsForSignup()
: realmConfigService.findAllRealmsForSignupAndApplication(application);
? realmConfigService.findAllRealmsForSignup()
: realmConfigService.findAllRealmsForSignupAndApplication(application);

JSONObject form = UserFormBuilder.newBuilder(config, locale, applicationContext.getResource("classpath:join/formDefinition.json"))
.realms(realms)
.attributes(config.getUserAttributes())
.addUsername(forEditing || config.isJoinWithUsername())
.build();
.realms(realms)
.attributes(config.getUserAttributes())
.addUsername(forEditing || config.isJoinWithUsername())
.build();

return new TranslationUtils().translate(form, getTranslator(locale));
}
Expand All @@ -247,9 +253,9 @@ public JSONObject getProfileConfiguration(String locale) throws JSONException, I
List<RealmConfig> realms = realmConfigService.findAllRealmsForSignup();

JSONObject form = UserFormBuilder.newBuilder(config, locale, applicationContext.getResource("classpath:join/formDefinition.json"))
.realms(realms)
.attributes(config.getUserAttributes())
.build();
.realms(realms)
.attributes(config.getUserAttributes())
.build();

return new TranslationUtils().translate(form, getTranslator(locale));
}
Expand All @@ -260,32 +266,32 @@ public JSONObject getRealmFormConfiguration(String locale, boolean forEditing) t
JSONObject form = new JSONObject();

form.put(
"form",
translationUtils.translate(RealmConfigFormBuilder.newBuilder(forEditing).build(), translator).toString()
"form",
translationUtils.translate(RealmConfigFormBuilder.newBuilder(forEditing).build(), translator).toString()
);
form.put(
"userInfoMapping",
translationUtils.translate(RealmUserInfoFormBuilder.newBuilder(extractUserInfoFieldsToMap(forEditing)).build(), translator).toString()
"userInfoMapping",
translationUtils.translate(RealmUserInfoFormBuilder.newBuilder(extractUserInfoFieldsToMap(forEditing)).build(), translator).toString()
);
form.put(
"userInfoMappingDefaults",
UserInfoFieldsMappingDefaultsFactory.create()
"userInfoMappingDefaults",
UserInfoFieldsMappingDefaultsFactory.create()
);
form.put(
AgateRealm.AGATE_LDAP_REALM.getName(),
translationUtils.translate(LdapRealmConfigFormBuilder.newBuilder().build(), translator).toString()
AgateRealm.AGATE_LDAP_REALM.getName(),
translationUtils.translate(LdapRealmConfigFormBuilder.newBuilder().build(), translator).toString()
);
form.put(
AgateRealm.AGATE_JDBC_REALM.getName(),
translationUtils.translate(JdbcRealmConfigFormBuilder.newBuilder().build().toString(), translator)
AgateRealm.AGATE_JDBC_REALM.getName(),
translationUtils.translate(JdbcRealmConfigFormBuilder.newBuilder().build().toString(), translator)
);
form.put(
AgateRealm.AGATE_AD_REALM.getName(),
translationUtils.translate(ActiveDirectoryRealmConfigFormBuilder.newBuilder().build().toString(), translator)
AgateRealm.AGATE_AD_REALM.getName(),
translationUtils.translate(ActiveDirectoryRealmConfigFormBuilder.newBuilder().build().toString(), translator)
);
form.put(
AgateRealm.AGATE_OIDC_REALM.getName(),
translationUtils.translate(OidcRealmConfigFormBuilder.newBuilder().build().toString(), translator)
AgateRealm.AGATE_OIDC_REALM.getName(),
translationUtils.translate(OidcRealmConfigFormBuilder.newBuilder().build().toString(), translator)
);

return form;
Expand Down Expand Up @@ -420,6 +426,46 @@ private Resource getTranslationsResource(String locale) {
return applicationContext.getResource(String.format("classpath:/i18n/%s.json", locale));
}

/**
* Update translations based on additions and removals of locales.
*
* @param savedConfig
* @param updatedConfig
*/
private void updateTranslations(Configuration savedConfig, Configuration updatedConfig) {
LocalizedString translations = updatedConfig.getTranslations();
if (translations == null) return;

HashSet<Locale> saved = new HashSet<>(savedConfig.getLocales());
HashSet<Locale> updated = new HashSet<>(updatedConfig.getLocales());

Set<Locale> addedLocales = new HashSet<>(updated);
addedLocales.removeAll(saved);
Set<Locale> removedLocales = new HashSet<>(saved);
removedLocales.removeAll(updated);
Set<Locale> commonLocales = new HashSet<>(saved);
commonLocales.retainAll(updated);

if (!removedLocales.isEmpty()) {
updatedConfig.getTranslations().keySet().removeIf(locale -> removedLocales.contains(Locale.forLanguageTag(locale)));
}

if (!addedLocales.isEmpty()) {
Locale defaultLocale = commonLocales.stream()
.filter(locale -> locale.getLanguage().equalsIgnoreCase("en"))
.findFirst()
.orElse(commonLocales.isEmpty() ? null : commonLocales.iterator().next());

for (Locale locale : addedLocales) {
if (defaultLocale != null) {
translations.put(locale.toLanguageTag(), updatedConfig.getTranslations().get(defaultLocale.toLanguageTag()));
} else {
translations.put(locale.toLanguageTag(), "{}");
}
}
}
}

/**
* Apply settings that are modified only internally.
*
Expand All @@ -431,6 +477,7 @@ public Configuration applyInternalSettings(Configuration updatedConfig) {
updatedConfig.setSecretOtp(savedConfig.getSecretOtp());
updatedConfig.setGroupsSeeded(savedConfig.isGroupsSeeded());
updatedConfig.setApplicationsSeeded(savedConfig.isApplicationsSeeded());
updateTranslations(savedConfig, updatedConfig);
return updatedConfig;
}
}
35 changes: 35 additions & 0 deletions agate-ui/src/boot/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { boot } from 'quasar/wrappers';
import { createI18n } from 'vue-i18n';
import messages from 'src/i18n';
import { Quasar, Cookies } from 'quasar';
import { translationAsMap } from 'src/utils/translations';
import type { AttributeDto } from 'src/models/Agate';

export type MessageLanguages = keyof typeof messages;
// Type-define 'en-US' as the master schema for the resource
Expand Down Expand Up @@ -40,6 +42,28 @@ function getCurrentLocale(): string {
return detectedLocale || locales[0] || 'en';
}

function mergeWithCustomMessages() {
const serverTranslations = translationAsMap(systemStore.configuration.translations || []);

Object.keys(serverTranslations).forEach((lang) => {
const existingMessages = i18n.global.getLocaleMessage(lang) || {};
const newMessages = (serverTranslations[lang] || ([] as AttributeDto[])).reduce(
(acc, tr) => {
if (tr.name && tr.value) {
acc[tr.name] = tr.value;
}
return acc;
},
{} as Record<string, string>,
);

i18n.global.setLocaleMessage(lang, {
...existingMessages,
...newMessages,
});
});
}

const i18n = createI18n<{ message: MessageSchema }, MessageLanguages>({
locale: getCurrentLocale(),
fallbackLocale: locales[0] || 'en',
Expand All @@ -53,6 +77,17 @@ export default boot(({ app }) => {
app.use(i18n);
});

const systemStore = useSystemStore();

watch(
() => systemStore.configuration.translations,
(newValue) => {
if (newValue) {
mergeWithCustomMessages();
}
},
);

const t = i18n.global.t;

export { i18n, t, locales, getCurrentLocale };
Loading

0 comments on commit 6db37b8

Please sign in to comment.