diff --git a/CRM/Custom/Form/Field.php b/CRM/Custom/Form/Field.php
index 6b683875370b..a8f574e74ac3 100644
--- a/CRM/Custom/Form/Field.php
+++ b/CRM/Custom/Form/Field.php
@@ -111,6 +111,7 @@ public function preProcess() {
}
// Add crm-options-repeat web component. FIXME: need an autoloader for web components.
+ \Civi::resources()->addScriptFile('civicrm', 'elements/civi-ts.js');
\Civi::resources()->addScriptFile('civicrm', 'js/CrmOptionsRepeat.js');
if ($this->_gid) {
diff --git a/Civi/Api4/Action/System/Translate.php b/Civi/Api4/Action/System/Translate.php
new file mode 100644
index 000000000000..20ea1cee6deb
--- /dev/null
+++ b/Civi/Api4/Action/System/Translate.php
@@ -0,0 +1,39 @@
+strings as $string) {
+ $dictionary[$string] = ts($string);
+ }
+
+ $result['dictionary'] = $dictionary;
+ }
+
+}
diff --git a/elements/civi-ts.js b/elements/civi-ts.js
new file mode 100644
index 000000000000..8f30975bf05e
--- /dev/null
+++ b/elements/civi-ts.js
@@ -0,0 +1,106 @@
+/**
+ * TODO:
+ * - integrate with/reuse CRM.ts and CRM.strings
+ * - handle multiple domains
+ * - handle parameters?
+ * - handle attributes
+ * - use a bespoke endpoint for fetching strings
+ */
+
+CRM = CRM || {};
+
+CRM.babel = {
+
+ toFetch: [],
+
+ nextFetch: null,
+
+ get: (src) => localStorage.getItem('civi-babel-' + src),
+
+ // TODO: add cache key?
+ set: (src, translation) => localStorage.setItem('civi-babel-' + src, translation),
+
+ ts: (string) => {
+ const translation = CRM.babel.get(string);
+ if (!translation) {
+ CRM.babel.queueFetch(string);
+ return null;
+ }
+
+ return translation;
+ },
+
+ queueFetch: (string) => {
+ if (CRM.babel.toFetch.includes(string)) {
+ return;
+ }
+
+ CRM.babel.toFetch.push(string);
+
+ // if we have 50 strings backed up, fetch immediately
+ if (CRM.babel.toFetch.length > 100) {
+ clearTimeout(CRM.babel.nextFetch);
+ CRM.babel.doFetch();
+ }
+ // otherwise debounce the fetch for 1s
+ else {
+ clearTimeout(CRM.babel.nextFetch);
+ CRM.babel.nextFetch = setTimeout(() => CRM.babel.doFetch(), 1000);
+ }
+ },
+
+ doFetch: () => {
+ // get the batch
+ const thisBatch = CRM.babel.toFetch;
+ CRM.babel.toFetch = [];
+
+ // prep the batch for best cacheability
+ thisBatch.sort();
+// const batchHash = md5(thisBatch);
+
+ // TODO: use a bespoke endpoint to allow caching and/or rate handling
+ CRM.api4('System', 'translate', {
+ strings: thisBatch,
+ // hash: batchHash
+ })
+ // add
+ .then((response) => {
+ if (response.is_error) {
+ // fetch failed - requeue
+ thisBatch.forEach((string) => CRM.babel.queueFetch(string));
+ return;
+ }
+
+ // add returned strings to local storage
+ Object.entries(response.dictionary).forEach((e) => CRM.babel.set(e[0], e[1]));
+
+ CRM.babel.propogate(thisBatch);
+ })
+ },
+
+
+ propogate: (strings) => {
+ strings.forEach((string) => {
+ const translation = CRM.babel.get(string);
+ document.querySelectorAll(`civi-ts[src="${string}"]`).forEach((e) => e.innerText = translation);
+ });
+ }
+
+}
+
+class CiviTs extends HTMLElement {
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ // if a translation exists it will be added immediately
+ // if not babel will fetch and propogate
+ this.innerText = CRM.babel.ts(this.getAttribute('src'));
+ }
+}
+
+// Register the custom element
+customElements.define('civi-ts', CiviTs);
+
diff --git a/js/CrmOptionsRepeat.js b/js/CrmOptionsRepeat.js
index c2be6bcbbd60..56ff43e30fc7 100644
--- a/js/CrmOptionsRepeat.js
+++ b/js/CrmOptionsRepeat.js
@@ -5,23 +5,23 @@ class CrmOptionsRepeat extends HTMLElement {
@@ -30,7 +30,7 @@ class CrmOptionsRepeat extends HTMLElement {
- {ts}Default{/ts}
+
- {ts}Label{/ts}
+
- {ts}Value{/ts}
+
- {ts}Enabled{/ts}
+