From 75fc7f3351bdc4791a8be8a8d16977f1ea25499f Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 07:38:16 +0000 Subject: [PATCH 1/3] add civi-ts component --- Civi/Api4/Action/System/Translate.php | 39 ++++++++++ elements/civi-ts.js | 102 ++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 Civi/Api4/Action/System/Translate.php create mode 100644 elements/civi-ts.js 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..39f855875d22 --- /dev/null +++ b/elements/civi-ts.js @@ -0,0 +1,102 @@ +/** + * 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 = { + + dictionary: {}, + + toFetch: [], + + nextFetch: null, + + ts: (string) => { + if (!CRM.babel.dictionary[string]) { + CRM.babel.queueFetch(string); + return null; + } + + return CRM.babel.dictionary[string]; + }, + + 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 dictionary + Object.assign(CRM.babel.dictionary, response.dictionary); + + CRM.babel.propogate(thisBatch); + }) + }, + + + propogate: (strings) => { + strings.forEach((string) => { + const translation = CRM.babel.dictionary[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); + From 179cb74aaf4c092f9a4147a14166e0f37099fa11 Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 07:38:38 +0000 Subject: [PATCH 2/3] use civi-ts component for translations --- CRM/Custom/Form/Field.php | 1 + js/CrmOptionsRepeat.js | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) 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/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 { - {ts}Default{/ts} + - {ts}Label{/ts} + - {ts}Sort by label{/ts} + - {ts}Value{/ts} + - {ts}Sort by value{/ts} + - {ts}Enabled{/ts} + @@ -30,7 +30,7 @@ class CrmOptionsRepeat extends HTMLElement { - {ts}Change order{/ts} + @@ -40,7 +40,7 @@ class CrmOptionsRepeat extends HTMLElement { - {ts}Delete{/ts} + @@ -50,7 +50,7 @@ class CrmOptionsRepeat extends HTMLElement { From db4cc5a33a19ca28cb514efe87f6ef7512a467fa Mon Sep 17 00:00:00 2001 From: benjamin Date: Thu, 29 Jan 2026 09:00:39 +0000 Subject: [PATCH 3/3] civi-ts - use local storage --- elements/civi-ts.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/elements/civi-ts.js b/elements/civi-ts.js index 39f855875d22..8f30975bf05e 100644 --- a/elements/civi-ts.js +++ b/elements/civi-ts.js @@ -11,19 +11,23 @@ CRM = CRM || {}; CRM.babel = { - dictionary: {}, - 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) => { - if (!CRM.babel.dictionary[string]) { + const translation = CRM.babel.get(string); + if (!translation) { CRM.babel.queueFetch(string); return null; } - return CRM.babel.dictionary[string]; + return translation; }, queueFetch: (string) => { @@ -67,8 +71,8 @@ CRM.babel = { return; } - // add returned strings to dictionary - Object.assign(CRM.babel.dictionary, response.dictionary); + // add returned strings to local storage + Object.entries(response.dictionary).forEach((e) => CRM.babel.set(e[0], e[1])); CRM.babel.propogate(thisBatch); }) @@ -77,7 +81,7 @@ CRM.babel = { propogate: (strings) => { strings.forEach((string) => { - const translation = CRM.babel.dictionary[string]; + const translation = CRM.babel.get(string); document.querySelectorAll(`civi-ts[src="${string}"]`).forEach((e) => e.innerText = translation); }); }