Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support named arguments in localization #3181

Merged
merged 6 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Changes since v2.33
### Additions
- (Experimental) Workflow can be configured to enable voting for the approval. Currently all handlers can vote (including bots). Use `:enable-voting`. (#3174)
- There is now a Danish language translation (#3176). We are considering supporting a limited set of languages officially, and improving support for community maintained translations (see #3179).
- Added experimental support for named format parameters in translations. (#3183)

### Fixes
- Email template parameters for `:application-expiration-notification` event are now documented. The parameters are different from standard event email parameters, which may have caused confusion.
Expand Down
6 changes: 6 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ for a list of all translation keys and their format parameters. Format
parameters are pieces of text like `%3` that get replaced with certain
information.

### Localization format parameters

Localizations may use format parameters for dynamic translations, e.g. emails. Format parameters are pieces of text that get replaced with certain information. Vector format parameters are default, which typically look like `"Application %1"`, where `"%1"` refers to specific position in the translation arguments.

Experimental support exists for named format parameters, which can be used alternatively. Named format parameters are included in text like `"Application %:application/id%"`, and unlike vector format parameters, do not rely on specific ordering of translation arguments.

## Themes

Custom themes can be used by creating a file, for example `my-custom-theme.edn`, and specifying its location in the `:theme-path` configuration parameter. The theme file can override some or all of the theme attributes (see `:theme` in [config-defaults.edn](https://github.com/CSCfi/rems/blob/master/resources/config-defaults.edn)). Static resources can be placed in a `public` directory next to the theme configuration file. See [example-theme/theme.edn](https://github.com/CSCfi/rems/blob/master/example-theme/theme.edn) for an example.
Expand Down
5 changes: 4 additions & 1 deletion example-theme/extra-translations/en.edn
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@
;; the extra %1 and the :unused-key are for rems.test-locales
:create-license {:license-text "Text %1"}
:unused-key "Unused"
:login {:errors {:group "group"}}}}
:login {:errors {:group "group"}}
;; example of using named parameters instead of vector index params
:applications {:view-application-without-description "View application %:application/id% for resources: %:catalogue-item-names%"
:view-application-with-description "View application %:application/id%: %:application/description%"}}}
2 changes: 0 additions & 2 deletions src/clj/rems/context.clj
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@

(def ^:dynamic ^{:doc "Set of roles for user (or nil)"} *roles*)

(def ^:dynamic ^{:doc "Tempura object initialized with user's preferred language."} *tempura*)

(def ^:dynamic ^{:doc "User's preferred language."} *lang*)

(def ^:dynamic ^{:doc "Ongoing HTTP request if any."} *request*)
96 changes: 96 additions & 0 deletions src/cljc/rems/tempura.cljc
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
(ns rems.tempura
(:require [clojure.string]
[clojure.test :refer [deftest is testing]]
[clojure.walk]
#?(:cljs [re-frame.core :as rf])
#?@(:clj [[rems.context :as context]
[rems.locales]])
[taoensso.tempura]))

;; backtick (`) is used to escape vector argument (%)
(defonce ^:private +map-args+ #"(?<!`)%:([^\s`%]+)%")
(defonce ^:private +vector-args+ #"(?<!`)%\d")

(deftest test-map-args
(is (= ["key" "my.special/namespace?"]
(->> "%:key% for (%:my.special/namespace?%) `%:ignored% %:also-ignored`%"
(re-seq +map-args+)
(mapv last)))))

(defn- replace-map-args [resource]
(let [res-keys (atom {})
idx (atom 0)
upsert! (fn [k]
(when-not (contains? @res-keys k)
(swap! res-keys assoc k (swap! idx inc)))
(get @res-keys k))
resource (->> resource
(clojure.walk/postwalk
(fn [node]
(if (string? node)
(clojure.string/replace node +map-args+ #(let [map-arg (keyword (second %))
vec-arg (upsert! map-arg)]
(str "%" vec-arg)))
node))))]
{:resource resource
:resource-keys (->> (sort-by val @res-keys)
(mapv key))}))

(def ^:private memoized-replace-map-args (memoize replace-map-args))

(deftest test-replace-map-args
(testing "string transformation"
(is (= {:resource "{:x %1 :y %2}"
:resource-keys [:x :y]}
(replace-map-args "{:x %:x% :y %:y%}"))))
(testing "memoized"
(let [memoized-f (memoize replace-map-args)]
(is (= {:resource "{:x %1 :y %2}"
:resource-keys [:x :y]}
(memoized-f "{:x %:x% :y %:y%}")
(memoized-f "{:x %:x% :y %:y%}")))))
(testing "hiccup transformation"
(is (= {:resource [:div {:aria-label "argument x is %1, argument y is %2"} "{:x %1 :y %2}"]
:resource-keys [:x :y]}
(replace-map-args [:div {:aria-label "argument x is %:x%, argument y is %:y%"} "{:x %:x% :y %:y%}"])))))

(def ^:private get-resource-compiler (:resource-compiler taoensso.tempura/default-tr-opts))

(defn- compile-vec-args [resource vargs]
(let [compile-vargs (get-resource-compiler resource)]
(compile-vargs (vec vargs))))

(defn- compile-map-args [resource arg-map]
(let [res-map (memoized-replace-map-args resource)]
(compile-vec-args (:resource res-map)
(map arg-map (:resource-keys res-map)))))

(defn- tempura-config []
{:dict #?(:clj rems.locales/translations
:cljs @(rf/subscribe [:translations]))
:resource-compiler (fn [resource]
(fn [vargs]
(cond
(map? (first vargs)) (if (re-find +vector-args+ resource)
(compile-vec-args resource (rest vargs))
(compile-map-args resource (first vargs)))
:else (compile-vec-args resource vargs))))})

(defn get-language []
#?(:clj context/*lang*
:cljs @(rf/subscribe [:language])))

(defn tr
"When translation function is called with both map and vector arguments,
custom resource compiler can use either argument format for translation.
Argument formats cannot be mixed. When using both argument formats,
map argument must be given first followed by vector arguments:

(tr [:key] [{:k :v} x1 x2])"
([ks args] (taoensso.tempura/tr (tempura-config)
[(get-language)]
(vec ks)
(vec args)))
([ks] (taoensso.tempura/tr (tempura-config)
[(get-language)]
(vec ks))))
69 changes: 31 additions & 38 deletions src/cljc/rems/text.cljc
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,13 @@
:cljs [cljs-time.format :as time-format])
#?(:cljs [cljs-time.coerce :as time-coerce])
[clojure.string :as str]
#?(:cljs [re-frame.core :as rf])
[rems.common.application-util :as application-util]
#?(:clj [rems.context :as context])
#?(:clj [rems.locales :as locales])
[taoensso.tempura :refer [tr]]))
[rems.tempura]))

#?(:clj
(defmacro with-language [lang & body]
`(binding [rems.context/*lang* ~lang
rems.context/*tempura* (partial tr (rems.locales/tempura-config) [~lang])]
`(binding [rems.context/*lang* ~lang]
(assert (keyword? ~lang) {:lang ~lang})
~@body)))

Expand All @@ -27,53 +24,50 @@
(cons k args)))))

(defn text-format
"Return the tempura translation for a given key & time arguments"
"Return the tempura translation for a given key and arguments.
Map can be used for named parameters as `(first args)`,
when the localization resource supports them. `(rest args)`
are then used as vector arguments if localization resource does not support
named parameters. See `rems.tempura/tr`

(text-format :key {:arg :value} varg-1 varg-2 ...)"
[k & args]
#?(:clj (context/*tempura* [k :t/missing] (vec args))
:cljs (let [translations (rf/subscribe [:translations])
language (rf/subscribe [:language])]
(tr {:dict @translations}
[@language]
[k :t/missing (failsafe-fallback k args)]
(vec args)))))
#?(:clj (rems.tempura/tr [k :t/missing]
args)
:cljs (rems.tempura/tr [k :t/missing (failsafe-fallback k args)]
args)))

(defn text-no-fallback
"Return the tempura translation for a given key. Additional fallback
keys can be given but there is no default fallback text."
[& ks]
#?(:clj (context/*tempura* (vec ks))
:cljs (let [translations (rf/subscribe [:translations])
language (rf/subscribe [:language])]
(try
(tr {:dict @translations}
[@language]
(vec ks))
(catch js/Object e
;; fail gracefully if the re-frame state is incomplete
(.error js/console e)
(str (vec ks)))))))
#?(:clj (rems.tempura/tr ks)
:cljs (try
(rems.tempura/tr ks)
(catch js/Object e
;; fail gracefully if the re-frame state is incomplete
(.error js/console e)
(str (vec ks))))))

(defn text
"Return the tempura translation for a given key. Additional fallback
keys can be given."
[& ks]
#?(:clj (apply text-no-fallback (conj (vec ks) (text-format :t/missing (vec ks))))
#?(:clj (apply text-no-fallback
(conj (vec ks)
(text-format :t/missing (vec ks))))
;; NB: we can't call the text-no-fallback here as in CLJS
;; we can both call this as function or use as a React component
:cljs (let [translations (rf/subscribe [:translations])
language (rf/subscribe [:language])]
(try
(tr {:dict @translations}
[@language]
(conj (vec ks) (text-format :t/missing (vec ks))))
(catch js/Object e
;; fail gracefully if the re-frame state is incomplete
(.error js/console e)
(str (vec ks)))))))
:cljs (try
(rems.tempura/tr (conj (vec ks)
(text-format :t/missing (vec ks))))
(catch js/Object e
;; fail gracefully if the re-frame state is incomplete
(.error js/console e)
(str (vec ks))))))

(defn localized [m]
(let [lang #?(:clj context/*lang*
:cljs @(rf/subscribe [:language]))]
(let [lang (rems.tempura/get-language)]
(or (get m lang)
(first (vals m)))))

Expand Down Expand Up @@ -153,7 +147,6 @@
(is (= "2020-09-29" (localize-utc-date (time/to-time-zone (time/date-time 2020 9 29 1 1)
(time/time-zone-for-offset -5))))))))


(def ^:private event-types
{:application.event/applicant-changed :t.applications.events/applicant-changed
:application.event/approved :t.applications.events/approved
Expand Down
16 changes: 9 additions & 7 deletions src/cljs/rems/application_list.cljs
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,18 @@

(defn- view-button [app]
(let [config @(rf/subscribe [:rems.config/config])
id (format-application-id config app)]
id (format-application-id config app)
description (:application/description app)]
[atoms/link
{:class "btn btn-primary"
:aria-label (if (str/blank? (:application/description app))
(text-format :t.applications/view-application-without-description
id (format-catalogue-items app))
:aria-label (if (str/blank? description)
(let [catalogue-items (format-catalogue-items app)]
(text-format :t.applications/view-application-without-description
{:application/id id :catalogue-item-names catalogue-items}
id catalogue-items))
(text-format :t.applications/view-application-with-description
id (:application/description app)))}
{:application/id id :application/description description}
id description))}
(str "/application/" (:application/id app))
(text :t.applications/view)]))

Expand Down Expand Up @@ -69,7 +73,6 @@
threshold (time/plus start seconds)]
(time/after? (time/now) threshold))))


(rf/reg-sub
::table-rows
(fn [[_ apps-sub] _]
Expand Down Expand Up @@ -131,7 +134,6 @@
:view {:display-value [:div.commands.justify-content-end [view-button app]]}})
apps)))


(defn list [{:keys [id applications visible-columns default-sort-column default-sort-order]
:or {visible-columns (constantly true)}}]
(let [all-columns [{:key :id
Expand Down
5 changes: 2 additions & 3 deletions test/clj/rems/test_db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@
[rems.db.roles :as roles]
[rems.service.test-data :as test-data]
[rems.db.test-data-helpers :as test-helpers]
[rems.db.testing :refer [test-db-fixture rollback-db-fixture]]
[rems.testing-tempura :refer [fake-tempura-fixture]])
[rems.db.testing :refer [test-db-fixture rollback-db-fixture]])
(:import (rems.auth ForbiddenException)))

(use-fixtures :once fake-tempura-fixture test-db-fixture)
(use-fixtures :once test-db-fixture)
(use-fixtures :each rollback-db-fixture)

(deftest test-get-catalogue-items
Expand Down
50 changes: 28 additions & 22 deletions test/clj/rems/test_locales.clj
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
[clojure.java.shell :as sh]
[clojure.set :as set]
[clojure.string :as str]
[clojure.test :refer :all]
[clojure.test :refer [deftest is testing]]
[clojure.tools.logging]
[rems.common.util :refer [recursive-keys]]
[rems.locales :as locales]
[rems.tempura]
[rems.testing-util :refer [create-temp-dir]]
[rems.util :refer [getx-in delete-directory-recursively]]
[taoensso.tempura.impl :refer [compile-dictionary]])
Expand Down Expand Up @@ -47,27 +48,32 @@
(is (= #{"%3" "%5" "%7"} (locales/extract-format-parameters "user %3 has made %7 alterations in %5!"))))

(deftest test-format-parameters-match
#_(testing "[:en vs :da]"
(let [en (loc-en)
da (loc-da)]
(doseq [k (recursive-keys en)] ;; we check that keys match separately
(testing k
(is (= (locales/extract-format-parameters (getx-in en (vec k)))
(locales/extract-format-parameters (getx-in da (vec k)))))))))
(testing "[:en vs :fi]"
(let [en (loc-en)
fi (loc-fi)]
(doseq [k (recursive-keys en)] ;; we check that keys match separately
(testing k
(is (= (locales/extract-format-parameters (getx-in en (vec k)))
(locales/extract-format-parameters (getx-in fi (vec k)))))))))
(testing "[:en vs :sv]"
(let [en (loc-en)
sv (loc-sv)]
(doseq [k (recursive-keys en)]
(testing k
(is (= (locales/extract-format-parameters (getx-in en (vec k)))
(locales/extract-format-parameters (getx-in sv (vec k))))))))))
(let [en (loc-en)
da (loc-da)
fi (loc-fi)
sv (loc-sv)
translation-keys (recursive-keys en)]
#_(testing "[:en vs :da]"
(doseq [ks translation-keys]
(testing ks
(is (= (locales/extract-format-parameters (getx-in en (vec ks)))
(locales/extract-format-parameters (getx-in da (vec ks)))))
(is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
(set (:resource-keys (#'rems.tempura/replace-map-args (getx-in da (vec ks))))))))))
(testing "[:en vs :fi]"
(doseq [ks translation-keys]
(testing ks
(is (= (locales/extract-format-parameters (getx-in en (vec ks)))
(locales/extract-format-parameters (getx-in fi (vec ks)))))
(is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
(set (:resource-keys (#'rems.tempura/replace-map-args (getx-in fi (vec ks))))))))))
(testing "[:en vs :sv]"
(doseq [ks translation-keys]
(testing ks
(is (= (locales/extract-format-parameters (getx-in en (vec ks)))
(locales/extract-format-parameters (getx-in sv (vec ks)))))
(is (= (set (:resource-keys (#'rems.tempura/replace-map-args (getx-in en (vec ks)))))
(set (:resource-keys (#'rems.tempura/replace-map-args (getx-in sv (vec ks))))))))))))

(defn- translation-keywords-in-use []
;; git grep would be nice, but circleci's git grep doesn't have -o
Expand Down
13 changes: 0 additions & 13 deletions test/clj/rems/testing_tempura.clj

This file was deleted.