Internationalisation of a Complex R Shiny App

I am developing a complex R shiny app that is being progressively rolled out at 15 hospitals across 9 Asian and African countries. The app offers data management and visualisation of critical antimicrobial resistance data in various settings and should be available in five different languages.

Fortunately, the R package shiny.i18n provides tools for the internationalisation of shiny applications. In a nutshell, the package requires that you place elements (i.e. strings) to translate in the i18$t function in your app. i18$t can be used in the UI or in the server:

# UI: 
fluidRow(
    column(6, 
        h4(i18n$t("Welcome to the ACORN app!"))
    )
)
# server:
if (inherits(connect, "try-error")) {
        showNotification(i18n$t("Couldn't connect to server."), type = "error")
        return()
      }

To make it work, one simply need the following:

  • A call to shinyjs::useShinyjs() at the top in the UI part of the app.
  • A user input to allow for language selection e.g. pickerInput("selected_language", label = "Language", choices = c("en", "vn", "la"), selected = "en")
  • A dictionary in the form of multiple .csv files or a JSON file with all elements translated.

The requirements for this app were

  1. to make things as easy as possible for our end user to translate the app.
  2. to anticipate frequent changes in the app and allow updated translations to be provided on an irregular basis.

The app is of significantly size with, as of today, close to 6,000 lines of R code! We found the strategy below to work best in our context with 5 languages, at least 5 different translators working asynchronously and about 200 elements to translate.

We started by screening all R code to find elements to translate. A little finesse: we use regex to find elements inside both i18n$t("") and i18n$t('').

files <- list.files(path = "./inst/acorn/www/", pattern = "\\.R$", recursive = TRUE)
files <- c("./inst/acorn/app.R", paste0("./inst/acorn/www/", files))
script <- map(files, read_lines, n_max = -1L) |> unlist()
vec_double <- str_extract_all(script, '(?<=n\\$t\\(")(.*?)(?=\")') |> unlist() |> unique() |> sort()
vec_single <- str_extract_all(script, "(?<=n\\$t\\(')(.*?)(?=\')") |> unlist() |> unique() |> sort()
all_elements <- c(vec_double, vec_single) |> as_tibble() |> dplyr::rename(en = value)

We have created an Excel file per language. Each file has two columns: “en” with the original element in “en” and an extra column that is either “kh” for Khmer translation file, “fr” for French translation file… The update_translation function will take this file (e.g. en_kh.xlsx) as an input and create two new files: en_kh_updated.xlsx and en_kh_elements_to_update.xlsx.

The en_kh_updated.xlsx is ready to be used in the app. English elements that were not found in en_kh.xlsx have been added with “TBT” (To Be Translated) in the matching “kh” column. They will appear as “TBT” in the app. In the en_kh_elements_to_update.xlsx file, there is a third column indicating if an element has been deleted (and the translation is of no use) or if it is a new element to translate. This file should be shared with the translator. Once this file is completed by the translator, it replaces our en_kh.xlsx file.

update_translation <- function(file_provided, file_updated, file_to_share) {
  original <- readxl::read_excel(path = file_provided)
  
  update <- full_join(all_elements, original, by = "en") |> 
    mutate(status = case_when(
      en %in% setdiff(as.vector(original$en), all_elements$en) ~ "deleted",  # elements in original$en that are not in all_elements
      en %in% setdiff(all_elements$en, as.vector(original$en)) ~ "new",  # elements in all_elements that are not in original$en
      TRUE ~ ""
    )) 
  
  update[which(is.na(update[, 2])), 2] <- "TBT"
  update |> filter(status != "deleted") |> select(-status) |> mutate() |> write_xlsx(file_updated)
  update |> write_xlsx(file_to_share)
}

update_translation(
  file_provided  = "/Users/olivier/.../en_kh.xlsx",
  file_updated   = "/Users/olivier/.../en_kh_updated.xlsx",
  file_to_share  = "/Users/olivier/.../en_kh_elements_to_update.xlsx"
)

# ...

update_translation(
  file_provided  = "/Users/olivier/.../en_ba.xlsx",
  file_updated   = "/Users/olivier/.../en_ba_updated.xlsx",
  file_to_share  = "/Users/olivier/.../en_ba_elements_to_update.xlsx"
)

Build a JSON file from all Excel files and save into the www folder of your app:

en_fr <- readxl::read_excel(path = "/Users/olivier/.../en_fr_updated.xlsx")
en_la <- readxl::read_excel(path = "/Users/olivier/.../en_la_updated.xlsx")
en_kh <- readxl::read_excel(path = "/Users/olivier/.../en_kh_updated.xlsx")
en_vn <- readxl::read_excel(path = "/Users/olivier/.../en_vn_updated.xlsx")
en_ba <- readxl::read_excel(path = "/Users/olivier/.../en_ba_updated.xlsx")

if(any(en_fr$en != en_la$en) | any(en_la$en != en_kh$en) | any(en_kh$en != en_vn$en) | any(en_vn$en != en_ba$en))  warning("Excel file en columns do not match!")

list(languages = c("en", "fr", "la", "kh", "vn", "ba"),
     translation = bind_cols(en_fr, 
                             en_la %>% select(la),
                             en_kh %>% select(kh),
                             en_vn %>% select(vn),
                             en_ba %>% select(ba)) |> 
       purrr::transpose()) |> 
  RJSONIO::toJSON(pretty = TRUE) |> 
  write(file = "/Users/.../www/translations/translation.json")

You can now use your shiny app with an up-to-date translation!