From aa211a57565b8aea4c7d2d0e38e684713f2a1f5a Mon Sep 17 00:00:00 2001
From: Cam Saul <1455846+camsaul@users.noreply.github.com>
Date: Wed, 17 Mar 2021 14:13:28 -0700
Subject: [PATCH] Port code for building i18n resources to Clojure build
 scripts (#15189)

* Port code for building i18n resources to Clojure build scripts; remove dep on gettext

* Use Metabase classloader

* Test fixes :wrench:

* Bump adoptopenjdk version

* Delete gettext dependency from Dockerfile

* Update developers-guide.md

* Address PR feedback

* Fix frontend singular msgstr format

Co-authored-by: Luis Paolini <paoliniluis@gmail.com>
---
 .circleci/config.yml                          |  5 ++
 .github/workflows/i18n.yml                    |  5 ++
 .github/workflows/uberjar.yml                 |  2 -
 .gitignore                                    |  1 +
 Dockerfile                                    |  9 +--
 bin/build-mb/deps.edn                         |  1 +
 bin/build-mb/src/build.clj                    | 13 +---
 bin/common/src/metabuild_common/java.clj      | 18 -----
 bin/i18n/build-translation-frontend-resource  | 72 ------------------
 bin/i18n/build-translation-resources          | 52 ++-----------
 bin/i18n/deps.edn                             | 13 ++++
 bin/i18n/src/i18n/common.clj                  | 59 +++++++++++++++
 bin/i18n/src/i18n/create_artifacts.clj        | 38 ++++++++++
 .../src/i18n/create_artifacts/backend.clj     | 48 ++++++++++++
 .../src/i18n/create_artifacts/frontend.clj    | 61 ++++++++++++++++
 .../i18n/create_artifacts/backend_test.clj    | 15 ++++
 .../i18n/create_artifacts/frontend_test.clj   | 26 +++++++
 .../i18n/create_artifacts/test_common.clj     | 46 ++++++++++++
 bin/release/README.md                         |  2 +-
 bin/release/src/release/check_prereqs.clj     |  9 +--
 docs/developers-guide.md                      |  7 +-
 resources/locales.clj                         | 10 +--
 src/metabase/util/i18n.clj                    |  2 +-
 src/metabase/util/i18n/impl.clj               | 73 +++++++++++--------
 src/metabase/util/schema.clj                  | 12 +--
 test/metabase/test/util/i18n.clj              | 18 ++---
 test/metabase/util/i18n/impl_test.clj         | 23 +++---
 27 files changed, 408 insertions(+), 232 deletions(-)
 delete mode 100644 bin/common/src/metabuild_common/java.clj
 delete mode 100755 bin/i18n/build-translation-frontend-resource
 create mode 100644 bin/i18n/deps.edn
 create mode 100644 bin/i18n/src/i18n/common.clj
 create mode 100644 bin/i18n/src/i18n/create_artifacts.clj
 create mode 100644 bin/i18n/src/i18n/create_artifacts/backend.clj
 create mode 100644 bin/i18n/src/i18n/create_artifacts/frontend.clj
 create mode 100644 bin/i18n/test/i18n/create_artifacts/backend_test.clj
 create mode 100644 bin/i18n/test/i18n/create_artifacts/frontend_test.clj
 create mode 100644 bin/i18n/test/i18n/create_artifacts/test_common.clj

diff --git a/.circleci/config.yml b/.circleci/config.yml
index abcf5f766e8..c8a6f9f26e6 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -679,6 +679,11 @@ jobs:
                 command: |
                   cd /home/circleci/metabase/metabase/bin/build-drivers && clojure -M:test
                 no_output_timeout: 15m
+            - run:
+                name: Run i18n script tests
+                command: |
+                  cd /home/circleci/metabase/metabase/bin/i18n && clojure -M:test
+                no_output_timeout: 15m
             - run:
                 name: Run build-mb build script tests
                 command: |
diff --git a/.github/workflows/i18n.yml b/.github/workflows/i18n.yml
index 7e14cf7369d..04606c99a52 100644
--- a/.github/workflows/i18n.yml
+++ b/.github/workflows/i18n.yml
@@ -36,6 +36,11 @@ jobs:
 
     - run: sudo apt install gettext
 
+    - name: Install Clojure CLI
+      run: |
+        curl -O https://download.clojure.org/install/linux-install-1.10.1.708.sh &&
+        sudo bash ./linux-install-1.10.1.708.sh
+
     - run: ./bin/i18n/update-translation-template
       name: Check i18n tags/make sure template can be built
     - run: ./bin/i18n/build-translation-resources
diff --git a/.github/workflows/uberjar.yml b/.github/workflows/uberjar.yml
index 5088c88e677..6b96b7fe98b 100644
--- a/.github/workflows/uberjar.yml
+++ b/.github/workflows/uberjar.yml
@@ -33,8 +33,6 @@ jobs:
       uses: actions/setup-java@v1
       with:
         java-version: 8
-    - name: Install gettext
-      run: sudo apt install gettext
     - name: Install Clojure CLI
       run: |
         curl -O https://download.clojure.org/install/linux-install-1.10.1.708.sh &&
diff --git a/.gitignore b/.gitignore
index 5f4fe89267b..1676473ceb0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,7 @@
 /resources/frontend_client/embed.html
 /resources/frontend_client/index.html
 /resources/frontend_client/public.html
+/resources/i18n/*.edn
 /resources/namespaces.edn
 /resources/sample-dataset.db.trace.db
 /resources/version.properties
diff --git a/Dockerfile b/Dockerfile
index 7a9c4c6c391..a5b3b7019f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,8 +18,7 @@ RUN yarn install --frozen-lockfile
 # STAGE 1.2: builder backend
 ###################
 
-# Build currently doesn't work on > Java 11 (i18n utils are busted) so build on 8 until we fix this
-FROM adoptopenjdk/openjdk8:alpine as backend
+FROM adoptopenjdk/openjdk11:alpine as backend
 
 ARG MB_EDITION=oss
 
@@ -45,8 +44,7 @@ RUN lein deps
 # STAGE 1.3: main builder
 ###################
 
-# Build currently doesn't work on > Java 11 (i18n utils are busted) so build on 8 until we fix this
-FROM adoptopenjdk/openjdk8:alpine as builder
+FROM adoptopenjdk/openjdk11:alpine as builder
 
 ARG MB_EDITION=oss
 
@@ -58,10 +56,9 @@ ENV FC_LANG en-US LC_CTYPE en_US.UTF-8
 # curl:    needed by script that installs Clojure CLI
 # git:     ./bin/version
 # yarn:    frontend building
-# gettext: translations
 # java-cacerts: installs updated cacerts to /etc/ssl/certs/java/cacerts
 
-RUN apk add --no-cache coreutils bash yarn git curl gettext java-cacerts
+RUN apk add --no-cache coreutils bash yarn git curl java-cacerts
 
 # lein:    backend dependencies and building
 RUN curl https://raw.githubusercontent.com/technomancy/leiningen/stable/bin/lein -o /usr/local/bin/lein && \
diff --git a/bin/build-mb/deps.edn b/bin/build-mb/deps.edn
index 746faa697ca..9de9f869f40 100644
--- a/bin/build-mb/deps.edn
+++ b/bin/build-mb/deps.edn
@@ -3,6 +3,7 @@
  :deps
  {common/common               {:local/root "../common"}
   build-drivers/build-drivers {:local/root "../build-drivers"}
+  i18n/i18n                   {:local/root "../i18n"}
   org.flatland/ordered        {:mvn/version "1.5.7"}}
 
  :aliases
diff --git a/bin/build-mb/src/build.clj b/bin/build-mb/src/build.clj
index 016ce9ee6e8..ebbba32cb3b 100644
--- a/bin/build-mb/src/build.clj
+++ b/bin/build-mb/src/build.clj
@@ -4,15 +4,8 @@
             [clojure.string :as str]
             [environ.core :as env]
             [flatland.ordered.map :as ordered-map]
-            [metabuild-common.core :as u]
-            [metabuild-common.java :as java]))
-
-(defn- build-translation-resources!
-  []
-  (u/step "Build translation resources"
-    (java/check-java-8)
-    (u/sh {:dir u/project-root-directory} "./bin/i18n/build-translation-resources")
-    (u/announce "Translation resources built successfully.")))
+            [i18n.create-artifacts :as i18n]
+            [metabuild-common.core :as u]))
 
 (defn- edition-from-env-var []
   (case (env/env :mb-edition)
@@ -66,7 +59,7 @@
    :version      (fn [{:keys [version]}]
                    (version-info/generate-version-info-file! version))
    :translations (fn [_]
-                   (build-translation-resources!))
+                   (i18n/create-all-artifacts!))
    :frontend     (fn [{:keys [edition]}]
                    (build-frontend! edition))
    :drivers      (fn [{:keys [edition]}]
diff --git a/bin/common/src/metabuild_common/java.clj b/bin/common/src/metabuild_common/java.clj
deleted file mode 100644
index 970c842cebb..00000000000
--- a/bin/common/src/metabuild_common/java.clj
+++ /dev/null
@@ -1,18 +0,0 @@
-(ns metabuild-common.java
-  (:require [metabuild-common.core :as u]))
-
-(defn java-version
-  "Get `major.minor` version of the `java` command, e.g. `14.0` or `1.8` (Java 8)."
-  []
-  (when-let [[_ version] (re-find #"version \"(\d+\.\d+)\..*\"" (first (u/sh "java" "-version")))]
-    (Double/parseDouble version)))
-
-(defn check-java-8 []
-  (u/step "Verify Java version is Java 8"
-    (let [version (or (java-version)
-                      (throw (Exception. "Unable to determine Java major version.")))]
-      ;; TODO -- is it possible to invoke `jabba` or some other command programmatically, or prompt for a different
-      ;; `JAVA_HOME`/`PATH` to use?
-      (when-not (#{1.8 8} version)
-        (throw (Exception. "The Metabase build script currently requires Java 8 to run. Please change your Java version and try again.")))
-      (u/announce "Java version is Java 8."))))
diff --git a/bin/i18n/build-translation-frontend-resource b/bin/i18n/build-translation-frontend-resource
deleted file mode 100755
index a1b5179014e..00000000000
--- a/bin/i18n/build-translation-frontend-resource
+++ /dev/null
@@ -1,72 +0,0 @@
-#!/usr/bin/env node
-
-// This program compiles a ".po" translations file to a JSON version suitable for use on the frontend
-// It removes strings that aren't used on the frontend, and other extraneous information like comments
-
-const fs = require("fs");
-const _ = require("underscore");
-const gParser = require("gettext-parser");
-
-// NOTE: this function replace xgettext "{0}" style references with ttag "${ 0 }" style references
-function replaceReferences(str) {
-  return str.replace(/\{ *(\d+) *\}/g, "${ $1 }");
-}
-
-if (process.argv.length !== 4) {
-  console.log(
-    "USAGE: build-translation-frontend-resource input.po output.json"
-  );
-  process.exit(1);
-}
-
-const inputFile = process.argv[2];
-const outputFile = process.argv[3];
-
-console.log(`Compiling ${inputFile} for frontend...`);
-
-const translationObject = gParser.po.parse(fs.readFileSync(inputFile, "utf-8"));
-
-// NOTE: unsure why the headers are duplicated in a translation for "", but we don't need it
-delete translationObject.translations[""][""];
-
-let fuzzyCount = 0;
-let emptyCount = 0;
-for (const id in translationObject.translations[""]) {
-  const translation = translationObject.translations[""][id];
-  const { reference, flag } = translation.comments || {};
-  // delete the translation, we'll add it back in if needed
-  delete translationObject.translations[""][id];
-  if (
-    // only include translations used on the frontend
-    !/(^|\n)frontend\//.test(reference)
-  ) {
-    continue;
-  }
-  // don't include empty translations
-  if (_.isEqual(translation.msgstr, [""])) {
-    emptyCount++;
-    continue;
-  }
-  // don't include fuzzy translations
-  if (flag === "fuzzy") {
-    fuzzyCount++;
-    continue;
-  }
-  // remove comments
-  delete translation.comments;
-  // delete msgid since it's redundant, we have to add it back in on the frontend though
-  delete translation.msgid;
-  // replace references in translations
-  translation.msgstr = translation.msgstr.map(str => replaceReferences(str));
-  // replace references in msgid
-  translationObject.translations[""][replaceReferences(id)] = translation;
-}
-
-if (emptyCount > 0) {
-  console.warn(`+ Warning: removed ${emptyCount} empty translations`);
-}
-if (fuzzyCount > 0) {
-  console.warn(`+ Warning: removed ${fuzzyCount} fuzzy translations`);
-}
-
-fs.writeFileSync(outputFile, JSON.stringify(translationObject), "utf-8");
diff --git a/bin/i18n/build-translation-resources b/bin/i18n/build-translation-resources
index c6cec6a8a8a..bcac1a5d173 100755
--- a/bin/i18n/build-translation-resources
+++ b/bin/i18n/build-translation-resources
@@ -1,49 +1,9 @@
-#!/bin/sh
+#! /usr/bin/env bash
 
-set -eu
+set -euo pipefail
 
-# gettext installed via homebrew is "keg-only", add it to the PATH
-if [ -d "/usr/local/opt/gettext/bin" ]; then
-  export PATH="/usr/local/opt/gettext/bin:$PATH"
-fi
+source "./bin/check-clojure-cli.sh"
+check_clojure_cli
 
-POT_NAME="locales/metabase.pot"
-LOCALES=$(find locales -type f -name "*.po" -exec basename {} .po \;)
-
-if [ -z "$LOCALES" ]; then
-  LOCALES_QUOTED=""
-else
-  LOCALES_QUOTED=" $(echo "$LOCALES" | awk '{ printf "\"%s\" ", $0 }')"
-fi
-
-FRONTEND_LANG_DIR="resources/frontend_client/app/locales"
-
-# backend
-# NOTE: include "en" even though we don't have a .po file for it because it's the default?
-cat << EOF > "resources/locales.clj"
-{
-  :locales  #{"en"$LOCALES_QUOTED}
-  :packages ["metabase"]
-  :bundle   "metabase.Messages"
-}
-EOF
-
-mkdir -p "$FRONTEND_LANG_DIR"
-
-for LOCALE in $LOCALES; do
-  LOCALE_FILE="locales/$LOCALE.po"
-  LOCALE_WITH_UNDERSCORE=$(echo "$LOCALE" | tr '-' '_')
-  # frontend
-  # NOTE: just copy these for now, but eventially precompile from .po to .json
-  ./bin/i18n/build-translation-frontend-resource \
-    "$LOCALE_FILE" \
-    "$FRONTEND_LANG_DIR/$LOCALE_WITH_UNDERSCORE.json"
-
-  # backend
-  msgfmt                            \
-    --java2                         \
-    -d "resources"                  \
-    -r "metabase.Messages" \
-    -l "$LOCALE_WITH_UNDERSCORE"    \
-    "$LOCALE_FILE"
-done
+cd bin/i18n
+clojure -M -m i18n.create-artifacts $@
diff --git a/bin/i18n/deps.edn b/bin/i18n/deps.edn
new file mode 100644
index 00000000000..5b6dc746eae
--- /dev/null
+++ b/bin/i18n/deps.edn
@@ -0,0 +1,13 @@
+{:paths ["src"]
+
+ :deps
+ {common/common                     {:local/root "../common"}
+  cheshire/cheshire                 {:mvn/version "5.8.1"}
+  clj-http/clj-http                 {:mvn/version "3.9.1"}
+  org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}}
+
+ :aliases
+ {:test {:extra-paths ["test"]
+         :extra-deps  {com.cognitect/test-runner {:git/url "https://github.com/cognitect-labs/test-runner.git"
+                                                  :sha     "209b64504cb3bd3b99ecfec7937b358a879f55c1"}}
+         :main-opts   ["-m" "cognitect.test-runner"]}}}
diff --git a/bin/i18n/src/i18n/common.clj b/bin/i18n/src/i18n/common.clj
new file mode 100644
index 00000000000..28d0c123e07
--- /dev/null
+++ b/bin/i18n/src/i18n/common.clj
@@ -0,0 +1,59 @@
+(ns i18n.common
+  (:require [clojure.java.io :as io]
+            [clojure.string :as str]
+            [metabuild-common.core :as u])
+  (:import [org.fedorahosted.tennera.jgettext Catalog HeaderFields Message PoParser]))
+
+(defn locales
+  "Set of all locales for which we have i18n bundles.
+
+    (locales) ; -> #{\"nl\" \"pt\" \"zh\" \"tr\" \"it\" \"fa\" ...}"
+  []
+  (set (for [^java.io.File file (.listFiles (io/file (u/filename u/project-root-directory "locales")))
+             :let               [file-name (.getName file)]
+             :when              (str/ends-with? file-name ".po")]
+         (str/replace file-name #"\.po$" ""))))
+
+(defn locale-source-po-filename [locale]
+  (u/filename u/project-root-directory "locales" (format "%s.po" locale)))
+
+;; see https://github.com/zanata/jgettext/tree/master/src/main/java/org/fedorahosted/tennera/jgettext
+
+(defn- catalog ^Catalog [locale]
+  (let [parser (PoParser.)]
+    (.parseCatalog parser (io/file (locale-source-po-filename "es")))))
+
+(defn po-headers [locale]
+  (when-let [^Message message (.locateHeader (catalog locale))]
+    (let [header-fields (HeaderFields/wrap (.getMsgstr message))]
+      (into {} (for [^String k (.getKeys header-fields)]
+                 [k (.getValue header-fields k)])))))
+
+(defn po-messages-seq [locale]
+  (for [^Message message (iterator-seq (.iterator (catalog locale)))
+        ;; remove any empty translations
+        :when            (not (str/blank? (.getMsgid message)))]
+    {:id                (.getMsgid message)
+     :id-plural         (.getMsgidPlural message)
+     :str               (.getMsgstr message)
+     :str-plural        (seq (remove str/blank? (.getMsgstrPlural message)))
+     :fuzzy?            (.isFuzzy message)
+     :plural?           (.isPlural message)
+     :source-references (seq (remove str/blank? (.getSourceReferences message)))
+     :comment           (.getMsgctxt message)}))
+
+(defn po-contents [locale]
+  {:headers  (po-headers locale)
+   :messages (po-messages-seq locale)})
+
+(defn print-message-count-xform [rf]
+  (let [num-messages (volatile! 0)]
+    (fn
+      ([]
+       (rf))
+      ([result]
+       (u/announce "Wrote %d messages." @num-messages)
+       (rf result))
+      ([result message]
+       (vswap! num-messages inc)
+       (rf result message)))))
diff --git a/bin/i18n/src/i18n/create_artifacts.clj b/bin/i18n/src/i18n/create_artifacts.clj
new file mode 100644
index 00000000000..23cf7de8ad3
--- /dev/null
+++ b/bin/i18n/src/i18n/create_artifacts.clj
@@ -0,0 +1,38 @@
+(ns i18n.create-artifacts
+  (:require [clojure.pprint :as pprint]
+            [i18n.common :as i18n]
+            [i18n.create-artifacts.backend :as backend]
+            [i18n.create-artifacts.frontend :as frontend]
+            [metabuild-common.core :as u]))
+
+;; TODO -- shouldn't this be `locales.edn`?
+(defn- locales-dot-clj []
+  {:locales  (conj (i18n/locales) "en")
+   :packages ["metabase"]
+   :bundle   "metabase.Messages"})
+
+(defn- generate-locales-dot-clj! []
+  (u/step "Create resources/locales.clj"
+    (let [file (u/filename u/project-root-directory "resources" "locales.clj")]
+      (u/delete-file-if-exists! file)
+      (spit file (with-out-str (pprint/pprint (locales-dot-clj))))
+      (u/assert-file-exists file))))
+
+(defn- create-artifacts-for-locale! [locale]
+  (u/step (format "Create artifacts for locale %s" (pr-str locale))
+    (frontend/create-artifact-for-locale! locale)
+    (backend/create-artifact-for-locale! locale)
+    (u/announce "Artifacts for locale %s created successfully." (pr-str locale))))
+
+(defn- create-artifacts-for-all-locales! []
+  (doseq [locale (i18n/locales)]
+    (create-artifacts-for-locale! locale)))
+
+(defn create-all-artifacts! []
+  (u/step "Create i18n artifacts"
+    (generate-locales-dot-clj!)
+    (create-artifacts-for-all-locales!)
+    (u/announce "Translation resources built successfully.")))
+
+(defn -main []
+  (create-all-artifacts!))
diff --git a/bin/i18n/src/i18n/create_artifacts/backend.clj b/bin/i18n/src/i18n/create_artifacts/backend.clj
new file mode 100644
index 00000000000..80cd9bb6d28
--- /dev/null
+++ b/bin/i18n/src/i18n/create_artifacts/backend.clj
@@ -0,0 +1,48 @@
+(ns i18n.create-artifacts.backend
+  (:require [clojure.java.io :as io]
+            [clojure.string :as str]
+            [i18n.common :as i18n]
+            [metabuild-common.core :as u])
+  (:import [java.io FileOutputStream OutputStreamWriter]
+           java.nio.charset.StandardCharsets))
+
+(defn- backend-message? [{:keys [source-references]}]
+  (some (fn [path]
+          (some
+           (fn [dir]
+             (str/starts-with? path dir))
+           ["src" "backend" "enterprise/backend" "shared"]))
+        source-references))
+
+(defn- ->edn [{:keys [messages]}]
+  (eduction
+   (filter backend-message?)
+   (remove :plural?)
+   i18n/print-message-count-xform
+   messages))
+
+(def target-directory
+  (u/filename u/project-root-directory "resources" "i18n"))
+
+(defn- target-filename [locale]
+  (u/filename target-directory (format "%s.edn" locale)))
+
+(defn- write-edn-file! [po-contents target-file]
+  (u/step "Write EDN file"
+    (with-open [os (FileOutputStream. (io/file target-file))
+                w  (OutputStreamWriter. os StandardCharsets/UTF_8)]
+      (.write w "{\n")
+      (doseq [{msg-id :id, msg-str :str} (->edn po-contents)]
+        (.write w (pr-str msg-id))
+        (.write w "\n")
+        (.write w (pr-str msg-str))
+        (.write w "\n\n"))
+      (.write w "}\n"))))
+
+(defn create-artifact-for-locale! [locale]
+  (let [target-file (target-filename locale)]
+    (u/step (format "Create backend artifact %s from %s" target-file (i18n/locale-source-po-filename locale))
+      (u/create-directory-unless-exists! target-directory)
+      (u/delete-file-if-exists! target-file)
+      (write-edn-file! (i18n/po-contents locale) target-file)
+      (u/assert-file-exists target-file))))
diff --git a/bin/i18n/src/i18n/create_artifacts/frontend.clj b/bin/i18n/src/i18n/create_artifacts/frontend.clj
new file mode 100644
index 00000000000..cfa3ddd6d92
--- /dev/null
+++ b/bin/i18n/src/i18n/create_artifacts/frontend.clj
@@ -0,0 +1,61 @@
+(ns i18n.create-artifacts.frontend
+  (:require [cheshire.core :as json]
+            [clojure.java.io :as io]
+            [clojure.string :as str]
+            [i18n.common :as i18n]
+            [metabuild-common.core :as u])
+  (:import [java.io FileOutputStream OutputStreamWriter]
+           java.nio.charset.StandardCharsets))
+
+(defn- frontend-message?
+  "Whether this i18n `message` comes from a frontend source file."
+  [{:keys [source-references]}]
+  (some #(str/includes? % "frontend")
+        source-references))
+
+(defn- ->ttag-reference
+  "Replace an xgettext `{0}` style reference with a ttag `${ 0 }` style reference."
+  [message-id]
+  (str/replace message-id #"\{\s*(\d+)\s*\}" "\\${ $1 }"))
+
+(defn- ->translations-map [messages]
+  {"" (into {}
+            (comp
+             ;; filter out i18n messages that aren't used on the FE client
+             (filter frontend-message?)
+             i18n/print-message-count-xform
+             (map (fn [message]
+                    [(->ttag-reference (:id message))
+                     (if (:plural? message)
+                       {:msgid_plural (:id-plural message)
+                        :msgstr       (:str-plural message)}
+                       {:msgstr [(:str message)]})])))
+            messages)})
+
+(defn- ->i18n-map
+  "Convert the contents of a `.po` file to map format used in the frontend client."
+  [po-contents]
+  {:charset      "utf-8"
+   :headers      (into {} (for [[k v] (:headers po-contents)]
+                            [(str/lower-case k) v]))
+   :translations (->translations-map (:messages po-contents))})
+
+(defn- i18n-map [locale]
+  (->i18n-map (i18n/po-contents locale)))
+
+(def ^:private target-directory
+  (u/filename u/project-root-directory "resources" "frontend_client" "app" "locales"))
+
+(defn- target-filename [locale]
+  (u/filename target-directory (format "%s.json" (str/replace locale #"-" "_"))))
+
+(defn create-artifact-for-locale! [locale]
+  (let [target-file (target-filename locale)]
+    (u/step (format "Create frontend artifact %s from %s" target-file (i18n/locale-source-po-filename locale))
+      (u/create-directory-unless-exists! target-directory)
+      (u/delete-file-if-exists! target-file)
+      (u/step "Write JSON"
+        (with-open [os (FileOutputStream. (io/file target-file))
+                    w  (OutputStreamWriter. os StandardCharsets/UTF_8)]
+          (json/generate-stream (i18n-map locale) w)))
+      (u/assert-file-exists target-file))))
diff --git a/bin/i18n/test/i18n/create_artifacts/backend_test.clj b/bin/i18n/test/i18n/create_artifacts/backend_test.clj
new file mode 100644
index 00000000000..929a4b9a17c
--- /dev/null
+++ b/bin/i18n/test/i18n/create_artifacts/backend_test.clj
@@ -0,0 +1,15 @@
+(ns i18n.create-artifacts.backend-test
+  (:require [clojure.string :as str]
+            [clojure.test :refer :all]
+            [i18n.create-artifacts.backend :as backend]
+            [i18n.create-artifacts.test-common :as test-common]))
+
+(deftest edn-test
+  (#'backend/write-edn-file! test-common/po-contents "/tmp/out.edn")
+  (is (= ["{"
+          "\"No table description yet\""
+          "\"No hay una descripción de la tabla\""
+          ""
+          "}"]
+         (some-> (slurp "/tmp/out.edn")
+                 (str/split-lines)))))
diff --git a/bin/i18n/test/i18n/create_artifacts/frontend_test.clj b/bin/i18n/test/i18n/create_artifacts/frontend_test.clj
new file mode 100644
index 00000000000..0fe37b03bd3
--- /dev/null
+++ b/bin/i18n/test/i18n/create_artifacts/frontend_test.clj
@@ -0,0 +1,26 @@
+(ns i18n.create-artifacts.frontend-test
+  (:require [clojure.test :refer :all]
+            [i18n.create-artifacts.frontend :as frontend]
+            [i18n.create-artifacts.test-common :as test-common]))
+
+(deftest ->ttag-reference-test
+  (is (= "${ 0 } schemas"
+         (#'frontend/->ttag-reference "{0} schemas"))))
+
+(deftest ->i18n-map-test
+  (is (= {:charset      "utf-8"
+          :headers      {"mime-version"              "1.0"
+                         "content-type"              "text/plain; charset=UTF-8"
+                         "content-transfer-encoding" "8bit"
+                         "x-generator"               "POEditor.com"
+                         "project-id-version"        "Metabase"
+                         "language"                  "es"
+                         "plural-forms"              "nplurals=2; plural=(n != 1);"}
+          :translations {""
+                         {"No table description yet"
+                          {:msgstr ["No hay una descripción de la tabla"]}
+
+                          "${ 0 } Queryable Table"
+                          {:msgid_plural "{0} Queryable Tables"
+                           :msgstr       ["{0] Tabla Consultable" "{0] Tablas consultables"]}}}}
+         (#'frontend/->i18n-map test-common/po-contents))))
diff --git a/bin/i18n/test/i18n/create_artifacts/test_common.clj b/bin/i18n/test/i18n/create_artifacts/test_common.clj
new file mode 100644
index 00000000000..26b18a17158
--- /dev/null
+++ b/bin/i18n/test/i18n/create_artifacts/test_common.clj
@@ -0,0 +1,46 @@
+(ns i18n.create-artifacts.test-common)
+
+(def singular-message-frontend
+  {:id                "No table description yet",
+   :id-plural         nil
+   :str               "No hay una descripción de la tabla"
+   :str-plural        nil
+   :fuzzy?            false
+   :plural?           false
+   :source-references ["frontend/src/metabase/admin/datamodel/components/database/MetadataTable.jsx:136"]
+   :comment           nil})
+
+(def singular-message-backend
+  {:id                "No table description yet",
+   :id-plural         nil
+   :str               "No hay una descripción de la tabla"
+   :str-plural        nil
+   :fuzzy?            false
+   :plural?           false
+   :source-references ["src/metabase/models/table.clj"]
+   :comment           nil})
+
+(def plural-message-frontend
+  {:id                "{0} Queryable Table"
+   :id-plural         "{0} Queryable Tables"
+   :str               nil
+   :str-plural        ["{0] Tabla Consultable" "{0] Tablas consultables"]
+   :fuzzy?            false
+   :plural?           true
+   :source-references ["frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx:77"]
+   :comment           nil})
+
+(def messages
+  [singular-message-frontend
+   singular-message-backend
+   plural-message-frontend])
+
+(def po-contents
+  {:headers  {"MIME-Version"              "1.0",
+              "Content-Type"              "text/plain; charset=UTF-8",
+              "Content-Transfer-Encoding" "8bit",
+              "X-Generator"               "POEditor.com",
+              "Project-Id-Version"        "Metabase",
+              "Language"                  "es",
+              "Plural-Forms"              "nplurals=2; plural=(n != 1);"}
+   :messages messages})
diff --git a/bin/release/README.md b/bin/release/README.md
index be1876e9916..ec8777ee258 100644
--- a/bin/release/README.md
+++ b/bin/release/README.md
@@ -5,7 +5,7 @@
 1. Install Clojure CLI -- see [https://clojure.org/guides/getting_started]. Don't use `apt install clojure` as this
    installs a version that doesn't understand `deps.edn`.
 
-1. Install `git`, `node`, `yarn`, `awscli`, `docker`, `java`, `wget` `shasum`, and `gettext`
+1. Install `git`, `node`, `yarn`, `awscli`, `docker`, `java`, and `wget``
 
    1. For installing Docker on macOS you should use [Docker Desktop](https://docs.docker.com/docker-for-mac/install/).
       Make sure `docker ps` works from the terminal
diff --git a/bin/release/src/release/check_prereqs.clj b/bin/release/src/release/check_prereqs.clj
index 5c922f71d7c..74c8236d0d5 100644
--- a/bin/release/src/release/check_prereqs.clj
+++ b/bin/release/src/release/check_prereqs.clj
@@ -1,12 +1,10 @@
 (ns release.check-prereqs
   (:require [clojure.string :as str]
             [environ.core :as env]
-            [metabuild-common
-             [core :as u]
-             [java :as java]]))
+            [metabuild-common.core :as u]))
 
 (def ^:private required-commands
-  ["git" "node" "yarn" "aws" "docker" "java" "wget" "shasum" "gettext" "zip"])
+  ["git" "node" "yarn" "aws" "docker" "java" "wget" "zip"])
 
 (defn- check-for-required-commands []
   (u/step "Verify required external commands are available"
@@ -49,5 +47,4 @@
   (u/step "Check prereqs"
     (check-for-required-commands)
     (check-for-required-env-vars)
-    (check-docker-is-running)
-    (java/check-java-8)))
+    (check-docker-is-running)))
diff --git a/docs/developers-guide.md b/docs/developers-guide.md
index 2d57c0d109b..a5b8a651fb1 100644
--- a/docs/developers-guide.md
+++ b/docs/developers-guide.md
@@ -25,18 +25,17 @@ If you have problems with your development environment, make sure that you are n
 These are the set of tools which are required in order to complete any build of the Metabase code. Follow the links to download and install them on your own before continuing.
 
 1. [Clojure (ttps://clojure.org)](https://clojure.org/guides/getting_started) - install the latest release by following the guide depending on your OS
-2. [Java Development Kit JDK (https://adoptopenjdk.net/releases.html)](https://adoptopenjdk.net/releases.html) - you need to install JDK 8 (./operations-guide/java-versions.md)
+2. [Java Development Kit JDK (https://adoptopenjdk.net/releases.html)](https://adoptopenjdk.net/releases.html) - you need to install JDK 11 (./operations-guide/java-versions.md)
 3. [Node.js (http://nodejs.org/)](http://nodejs.org/) - latest LTS release
 4. [Yarn package manager for Node.js](https://yarnpkg.com/) - latest release of version 1.x - you can install it in any OS by doing `npm install --global yarn`
 5. [Leiningen (http://leiningen.org/)](http://leiningen.org/) - latest release
-6. [GetText package (https://www.gnu.org/software/gettext/)](https://www.gnu.org/software/gettext/) - latest
 
 On a most recent stable Ubuntu/Debian, all the tools above, with the exception of Clojure, can be installed by using:
 
 ```
-sudo apt install gettext openjdk-8-jdk nodejs leiningen && sudo npm install --global yarn
+sudo apt install openjdk-11-jdk nodejs leiningen && sudo npm install --global yarn
 ```
-If you have multiple JDK versions installed in your machine, be sure to switch your JDK before building by doing `sudo update-alternatives --config java` and selecting Java 8 in the menu
+If you have multiple JDK versions installed in your machine, be sure to switch your JDK before building by doing `sudo update-alternatives --config java` and selecting Java 11 in the menu
 
 If you are developing on Windows, make sure to use Ubuntu on Windows and follow instructions for Ubuntu/Linux instead of installing ordinary Windows versions.
 
diff --git a/resources/locales.clj b/resources/locales.clj
index 6e37aa8f477..ee96c61f308 100644
--- a/resources/locales.clj
+++ b/resources/locales.clj
@@ -1,5 +1,5 @@
-{
-  :locales  #{"en" "nl" "zh" "zh-HK" "fr" "ja" "bg" "de" "nb" "es" "cs" "ca" "pl" "zh-TW" "vi" "it" "ru" "sv" "fa" "sk" "pt" "tr" }
-  :packages ["metabase"]
-  :bundle   "metabase.Messages"
-}
+{:locales
+ #{"nl" "pt" "en" "zh" "tr" "it" "fa" "vi" "zh-TW" "pl" "ca" "sv"
+   "zh-HK" "fr" "de" "nb" "ru" "sk" "es" "ja" "cs" "bg"},
+ :packages ["metabase"],
+ :bundle "metabase.Messages"}
diff --git a/src/metabase/util/i18n.clj b/src/metabase/util/i18n.clj
index 0e8c667c0ab..7b321700f19 100644
--- a/src/metabase/util/i18n.clj
+++ b/src/metabase/util/i18n.clj
@@ -78,7 +78,7 @@
 
 (defn- localized-to-json
   "Write a UserLocalizedString or SiteLocalizedString to the `json-generator`. This is intended for
-  `json-gen/add-encoder`. Ideallys we'd implement those protocols directly as it's faster, but currently that doesn't
+  `json-gen/add-encoder`. Ideally we'd implement those protocols directly as it's faster, but currently that doesn't
   work with Cheshire"
   [localized-string json-generator]
   (json-gen/write-string json-generator (str localized-string)))
diff --git a/src/metabase/util/i18n/impl.clj b/src/metabase/util/i18n/impl.clj
index a43b05ed86f..3ddae4ae0e0 100644
--- a/src/metabase/util/i18n/impl.clj
+++ b/src/metabase/util/i18n/impl.clj
@@ -8,7 +8,7 @@
             [metabase.plugins.classloader :as classloader]
             [potemkin.types :as p.types])
   (:import java.text.MessageFormat
-           [java.util Locale MissingResourceException ResourceBundle]
+           java.util.Locale
            org.apache.commons.lang3.LocaleUtils))
 
 (p.types/defprotocol+ CoerceToLocale
@@ -66,43 +66,56 @@
     (when (seq (.getCountry a-locale))
       (locale (.getLanguage a-locale)))))
 
-(def ^:private ^:const ^String i18n-bundle-name "metabase.Messages")
+(defn- locale-edn-resource
+  "The resource URL for the edn file containing translations for `locale-or-name`. These files are built by the
+  scripts in `bin/i18n` from `.po` files from POEditor.
 
-(defn- bundle* [^Locale locale]
-  (try
-    (ResourceBundle/getBundle i18n-bundle-name locale (classloader/the-classloader))
-    (catch MissingResourceException _
-      (log/error (format "Error translating to %s: no resource bundle" locale)))))
+    (locale-edn-resources \"es\") ;-> #object[java.net.URL \"file:/home/cam/metabase/resources/metabase/es.edn\"]"
+  ^java.net.URL [locale-or-name]
+  (when-let [a-locale (locale locale-or-name)]
+    (let [locale-name (-> (normalized-locale-string (str a-locale))
+                          (str/replace #"_" "-"))
+          filename    (format "i18n/%s.edn" locale-name)]
+      (io/resource filename (classloader/the-classloader)))))
+
+(defn- translations* [a-locale]
+  (when-let [resource (locale-edn-resource a-locale)]
+    (edn/read-string (slurp resource))))
 
-(defn- bundle
-  "Get the Metabase i18n resource bundle associated with `locale`. Returns `nil` if no such bundle can be found."
-  ^ResourceBundle [locale-or-name]
-  (when-let [locale (locale locale-or-name)]
-    (bundle* locale)))
+(def ^:private ^{:arglists '([locale-or-name])} translations
+  "Fetch a map of original untranslated message format string -> translated message format string for `locale-or-name`
+  by reading the corresponding EDN resource file. Does not include translations for parent locale(s). Memoized.
 
-(defn translated-format-string
-  "Find the translated version of `format-string` in the bundle for `locale-or-name`, or `nil` if none can be found.
-  Does not search 'parent' (country-only) locale bundle."
+    (translations \"es\") ;-> {\"Username\" \"Nombre Usuario\", ...}"
+  (comp (memoize translations*) locale))
+
+(defn- translated-format-string*
+  "Find the translated version of `format-string` for `locale-or-name`, or `nil` if none can be found.
+  Does not search 'parent' (language-only) translations."
   ^String [locale-or-name format-string]
   (when (seq format-string)
     (when-let [locale (locale locale-or-name)]
-      (when-let [bundle (bundle locale)]
-        (try
-          (.getString bundle format-string)
-          ;; no translated version available
-          (catch MissingResourceException _))))))
+      (when-let [translations (translations locale)]
+        (get translations format-string)))))
+
+(defn- translated-format-string
+  "Find the translated version of `format-string` for `locale-or-name`, or `nil` if none can be found. Searches parent
+  (language-only) translations if none exist for a language + country locale."
+  ^String [locale-or-name format-string]
+  (when-let [a-locale (locale locale-or-name)]
+    (or (when (= (.getLanguage a-locale) "en")
+          format-string)
+        (translated-format-string* a-locale format-string)
+        (when-let [parent-locale (parent-locale a-locale)]
+          (log/tracef "No translated string found, trying parent locale %s" (pr-str parent-locale))
+          (translated-format-string* parent-locale format-string))
+        format-string)))
 
 (defn- message-format ^MessageFormat [locale-or-name ^String format-string]
-  (if-let [locale (locale locale-or-name)]
-    (let [^String translated (or (when (= (.getLanguage locale) "en")
-                                   format-string)
-                                 (translated-format-string locale format-string)
-                                 (when-let [parent-locale (parent-locale locale)]
-                                   (log/tracef "No translated string found, trying parent locale %s" (pr-str parent-locale))
-                                   (translated-format-string parent-locale format-string))
-                                 format-string)]
-      (MessageFormat. translated locale))
-    (MessageFormat. format-string)))
+  (or (when-let [a-locale (locale locale-or-name)]
+        (when-let [^String translated (translated-format-string a-locale format-string)]
+          (MessageFormat. translated a-locale)))
+      (MessageFormat. format-string)))
 
 (defn translate
   "Find the translated version of `format-string` for a `locale-or-name`, then format it. Translates using the resource
diff --git a/src/metabase/util/schema.clj b/src/metabase/util/schema.clj
index f142c2fbe82..8a488615a62 100644
--- a/src/metabase/util/schema.clj
+++ b/src/metabase/util/schema.clj
@@ -40,7 +40,7 @@
 (defn with-api-error-message
   "Return `schema` with an additional `api-error-message` that will be used to explain the error if a parameter fails
   validation."
-  {:style/indent 1}
+  {:style/indent [:defn]}
   [schema api-error-message]
   (if-not (record? schema)
     ;; since this only works for record types, if `schema` isn't already one just wrap it in `s/named` to make it one
@@ -173,26 +173,26 @@
 (def IntGreaterThanOrEqualToZero
   "Schema representing an integer than must also be greater than or equal to zero."
   (with-api-error-message
-      (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
+    (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
     (deferred-tru "value must be an integer greater than or equal to zero.")))
 
 ;; TODO - rename this to `PositiveInt`?
 (def IntGreaterThanZero
   "Schema representing an integer than must also be greater than zero."
   (with-api-error-message
-      (s/constrained s/Int (partial < 0) (deferred-tru "Integer greater than zero"))
+    (s/constrained s/Int (partial < 0) (deferred-tru "Integer greater than zero"))
     (deferred-tru "value must be an integer greater than zero.")))
 
 (def NonNegativeInt
   "Schema representing an integer 0 or greater"
   (with-api-error-message
-      (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
+    (s/constrained s/Int (partial <= 0) (deferred-tru "Integer greater than or equal to zero"))
     (deferred-tru "value must be an integer zero or greater.")))
 
 (def PositiveNum
   "Schema representing a numeric value greater than zero. This allows floating point numbers and integers."
   (with-api-error-message
-      (s/constrained s/Num (partial < 0) (deferred-tru "Number greater than zero"))
+    (s/constrained s/Num (partial < 0) (deferred-tru "Number greater than zero"))
     (deferred-tru "value must be a number greater than zero.")))
 
 (def KeywordOrString
@@ -220,7 +220,7 @@
 (def EntityTypeKeywordOrString
   "Validates entity type derivatives of `:entity/*`. Allows strings or keywords"
   (with-api-error-message (s/pred #(isa? (keyword %) :entity/*) (deferred-tru "Valid entity type (keyword or string)"))
-   (deferred-tru "value must be a valid entity type (keyword or string).")))
+    (deferred-tru "value must be a valid entity type (keyword or string).")))
 
 (def Map
   "Schema for a valid map."
diff --git a/test/metabase/test/util/i18n.clj b/test/metabase/test/util/i18n.clj
index 594b7aa0345..f9adc9f86ae 100644
--- a/test/metabase/test/util/i18n.clj
+++ b/test/metabase/test/util/i18n.clj
@@ -3,27 +3,19 @@
             [metabase.util.i18n :as i18n]
             [metabase.util.i18n.impl :as impl]))
 
-(defn map-bundle
-  "Convert a Clojure map to a java `ResourceBundle`."
-  ^java.util.ListResourceBundle [m]
-  (when m
-    (let [contents (to-array-2d (seq m))]
-      (proxy [java.util.ListResourceBundle] []
-        (getContents [] contents)))))
-
 (defn do-with-mock-i18n-bundles [bundles thunk]
   (t/testing (format "\nwith mock i18n bundles %s\n" (pr-str bundles))
-    (let [locale->bundle (into {} (for [[locale m] bundles]
-                                    [(impl/locale locale) (map-bundle m)]))]
-      (with-redefs [impl/bundle* locale->bundle]
+    (let [locale->bundle (into {} (for [[locale-name bundle] bundles]
+                                    [(i18n/locale locale-name) bundle]))]
+      (with-redefs [impl/translations (comp locale->bundle i18n/locale)]
         (thunk)))))
 
 (defmacro with-mock-i18n-bundles
   "Mock the i18n resource bundles for the duration of `body`.
 
     (with-mock-i18n-bundles {\"es\"    {\"Your database has been added!\" \"¡Tu base de datos ha sido añadida!\"}
-                               \"es-MX\" {\"I''m good thanks\" \"Está bien, gracias\"}}
-      (/translate \"es-MX\" \"Your database has been added!\"))
+                             \"es-MX\" {\"I''m good thanks\" \"Está bien, gracias\"}}
+      (translate \"es-MX\" \"Your database has been added!\"))
     ;; -> \"¡Tu base de datos ha sido añadida!\""
   [bundles & body]
   `(do-with-mock-i18n-bundles ~bundles (fn [] ~@body)))
diff --git a/test/metabase/util/i18n/impl_test.clj b/test/metabase/util/i18n/impl_test.clj
index 0e0e946a847..cc3cc60283a 100644
--- a/test/metabase/util/i18n/impl_test.clj
+++ b/test/metabase/util/i18n/impl_test.clj
@@ -1,7 +1,8 @@
 (ns metabase.util.i18n.impl-test
   (:require [clojure.test :refer :all]
             [metabase.test :as mt]
-            [metabase.util.i18n.impl :as impl]))
+            [metabase.util.i18n.impl :as impl])
+  (:import java.util.Locale))
 
 (deftest normalized-locale-string-test
   (doseq [[s expected] {"en"      "en"
@@ -29,11 +30,11 @@
                            :str     s
                            :keyword (keyword s))]]
       (testing (pr-str (list 'locale x))
-        (is (= (java.util.Locale/forLanguageTag (if language "en-US" "en"))
+        (is (= (Locale/forLanguageTag (if language "en-US" "en"))
                (impl/locale x)))))
 
     (testing "If something is already a Locale, `locale` should act as an identity fn"
-      (is (= (java.util.Locale/forLanguageTag "en-US")
+      (is (= (Locale/forLanguageTag "en-US")
              (impl/locale #locale "en-US")))))
 
   (testing "nil"
@@ -58,11 +59,11 @@
   (doseq [[locale expected] {nil                                       nil
                              :es                                       nil
                              "es"                                      nil
-                             (java.util.Locale/forLanguageTag "es")    nil
-                             "es-MX"                                   (java.util.Locale/forLanguageTag "es")
-                             "es_MX"                                   (java.util.Locale/forLanguageTag "es")
-                             :es/MX                                    (java.util.Locale/forLanguageTag "es")
-                             (java.util.Locale/forLanguageTag "es-MX") (java.util.Locale/forLanguageTag "es")}]
+                             (Locale/forLanguageTag "es")    nil
+                             "es-MX"                                   (Locale/forLanguageTag "es")
+                             "es_MX"                                   (Locale/forLanguageTag "es")
+                             :es/MX                                    (Locale/forLanguageTag "es")
+                             (Locale/forLanguageTag "es-MX") (Locale/forLanguageTag "es")}]
     (testing locale
       (is (= expected
              (impl/parent-locale locale))))))
@@ -103,10 +104,8 @@
   (mt/with-mock-i18n-bundles {"ba-DD" {"Bad translation {0}" "BaD TrAnSlAtIoN {a}"}}
     (testing "Should fall back to original format string if translated one is busted"
       (is (= "Bad translation 100"
-             (mt/suppress-output
-               (impl/translate "ba-DD" "Bad translation {0}" 100)))))
+             (impl/translate "ba-DD" "Bad translation {0}" 100))))
 
     (testing "if the original format string is busted, should just return format-string as-is (better than nothing)"
       (is (= "Bad original {a}"
-             (mt/suppress-output
-               (impl/translate "ba-DD" "Bad original {a}" 100)))))))
+             (impl/translate "ba-DD" "Bad original {a}" 100))))))
-- 
GitLab