@nyaruka/temba-components 0.156.11 → 0.156.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/temba-components.js +686 -505
- package/dist/temba-components.js.map +1 -1
- package/package.json +2 -2
- package/src/display/Button.ts +9 -4
- package/src/flow/AutoTranslate.ts +630 -0
- package/src/flow/Editor.ts +49 -299
- package/src/flow/EditorToolbar.ts +38 -2
- package/src/flow/flow-translations.ts +229 -0
- package/src/flow/types.ts +2 -1
- package/src/form/Compose.ts +1 -0
- package/src/layout/Dialog.ts +24 -3
- package/temba-modules.ts +2 -0
package/src/flow/Editor.ts
CHANGED
|
@@ -29,14 +29,18 @@ import {
|
|
|
29
29
|
snapToGrid
|
|
30
30
|
} from './utils';
|
|
31
31
|
import { ACTION_CONFIG, NODE_CONFIG } from './config';
|
|
32
|
-
import { getTranslatableCategoriesForNode } from './categoryLocalization';
|
|
33
32
|
import { PRIMARY_LANGUAGE_OPTION_VALUE } from './EditorToolbar';
|
|
33
|
+
import {
|
|
34
|
+
buildTranslationBundles,
|
|
35
|
+
getTranslationCounts
|
|
36
|
+
} from './flow-translations';
|
|
34
37
|
import { calculateLayeredLayout, placeStickyNotes } from './reflow';
|
|
35
38
|
import type { RevisionsWindow } from './RevisionsWindow';
|
|
36
39
|
|
|
37
40
|
import {
|
|
38
41
|
ACTION_GROUP_METADATA,
|
|
39
42
|
CONTEXT_MENU_SHORTCUTS,
|
|
43
|
+
Features,
|
|
40
44
|
FlowType,
|
|
41
45
|
FlowTypes
|
|
42
46
|
} from './types';
|
|
@@ -85,22 +89,6 @@ export interface SelectionBox {
|
|
|
85
89
|
endY: number;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
type TranslationType = 'property' | 'category';
|
|
89
|
-
|
|
90
|
-
interface TranslationEntry {
|
|
91
|
-
uuid: string;
|
|
92
|
-
type: TranslationType;
|
|
93
|
-
attribute: string;
|
|
94
|
-
from: string;
|
|
95
|
-
to: string | null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
interface TranslationBundle {
|
|
99
|
-
nodeUuid: string;
|
|
100
|
-
actionUuid?: string;
|
|
101
|
-
translations: TranslationEntry[];
|
|
102
|
-
}
|
|
103
|
-
|
|
104
92
|
export type ToolbarAction =
|
|
105
93
|
| { action: 'view-change'; view: 'flow' | 'table' }
|
|
106
94
|
| { action: 'zoom-in' }
|
|
@@ -109,7 +97,8 @@ export type ToolbarAction =
|
|
|
109
97
|
| { action: 'zoom-to-full' }
|
|
110
98
|
| { action: 'revisions' }
|
|
111
99
|
| { action: 'search' }
|
|
112
|
-
| { action: 'language-change'; isPrimary?: boolean; languageCode?: string }
|
|
100
|
+
| { action: 'language-change'; isPrimary?: boolean; languageCode?: string }
|
|
101
|
+
| { action: 'auto-translate' };
|
|
113
102
|
const EMPTY_FLOW_ISSUES: FlowIssue[] = [];
|
|
114
103
|
|
|
115
104
|
// How long the pending-changes auto-save countdown runs (in ms).
|
|
@@ -211,6 +200,10 @@ export class Editor extends RapidElement {
|
|
|
211
200
|
@property({ type: Array })
|
|
212
201
|
public features: string[] = [];
|
|
213
202
|
|
|
203
|
+
private get autoTranslateEnabled(): boolean {
|
|
204
|
+
return this.features?.includes(Features.AUTO_TRANSLATE) ?? false;
|
|
205
|
+
}
|
|
206
|
+
|
|
214
207
|
private activityTimer: number | null = null;
|
|
215
208
|
private activityInterval = 100; // Start with 100ms interval for fast initial load
|
|
216
209
|
|
|
@@ -303,6 +296,9 @@ export class Editor extends RapidElement {
|
|
|
303
296
|
@state()
|
|
304
297
|
public zoom = 1.0;
|
|
305
298
|
|
|
299
|
+
@state()
|
|
300
|
+
private autoTranslating = false;
|
|
301
|
+
|
|
306
302
|
// Non-reactive flag set in willUpdate to suppress the debouncedSave
|
|
307
303
|
// call in updated() when the dirtyDate change comes from a reflow/copy
|
|
308
304
|
private _suppressDirtySave = false;
|
|
@@ -897,54 +893,6 @@ export class Editor extends RapidElement {
|
|
|
897
893
|
transition: opacity 0.2s ease;
|
|
898
894
|
}
|
|
899
895
|
|
|
900
|
-
.auto-translate-button {
|
|
901
|
-
background: var(--color-primary-dark);
|
|
902
|
-
border: none;
|
|
903
|
-
color: #fff;
|
|
904
|
-
padding: 10px 12px;
|
|
905
|
-
border-radius: var(--curvature);
|
|
906
|
-
font-size: 12px;
|
|
907
|
-
font-weight: 600;
|
|
908
|
-
cursor: pointer;
|
|
909
|
-
transition: opacity 0.2s ease;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
.auto-translate-button[disabled] {
|
|
913
|
-
opacity: 0.5;
|
|
914
|
-
cursor: not-allowed;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
.auto-translate-error {
|
|
918
|
-
font-size: 12px;
|
|
919
|
-
color: #b91c1c;
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
.auto-translate-dialog-content {
|
|
923
|
-
padding: 20px;
|
|
924
|
-
display: flex;
|
|
925
|
-
flex-direction: column;
|
|
926
|
-
gap: 12px;
|
|
927
|
-
font-size: 14px;
|
|
928
|
-
color: #374151;
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
.auto-translate-dialog-content p {
|
|
932
|
-
margin: 0;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
.auto-translate-loading {
|
|
936
|
-
display: flex;
|
|
937
|
-
align-items: center;
|
|
938
|
-
gap: 8px;
|
|
939
|
-
font-size: 13px;
|
|
940
|
-
color: #6b7280;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
.auto-translate-empty {
|
|
944
|
-
font-size: 13px;
|
|
945
|
-
color: #6b7280;
|
|
946
|
-
}
|
|
947
|
-
|
|
948
896
|
.localization-empty {
|
|
949
897
|
font-size: 13px;
|
|
950
898
|
color: #9ca3af;
|
|
@@ -1799,239 +1747,8 @@ export class Editor extends RapidElement {
|
|
|
1799
1747
|
total: number;
|
|
1800
1748
|
localized: number;
|
|
1801
1749
|
} {
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
!languageCode ||
|
|
1805
|
-
languageCode === this.definition.language
|
|
1806
|
-
) {
|
|
1807
|
-
return { total: 0, localized: 0 };
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
const bundles = this.buildTranslationBundles(languageCode);
|
|
1811
|
-
return this.getTranslationCounts(bundles);
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
private getLanguageLocalization(languageCode: string): Record<string, any> {
|
|
1815
|
-
if (!this.definition?.localization) {
|
|
1816
|
-
return {};
|
|
1817
|
-
}
|
|
1818
|
-
return this.definition.localization[languageCode] || {};
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
private buildTranslationBundles(
|
|
1822
|
-
languageCode: string = this.languageCode
|
|
1823
|
-
): TranslationBundle[] {
|
|
1824
|
-
if (
|
|
1825
|
-
!this.definition ||
|
|
1826
|
-
!languageCode ||
|
|
1827
|
-
languageCode === this.definition.language
|
|
1828
|
-
) {
|
|
1829
|
-
return [];
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
const languageLocalization = this.getLanguageLocalization(languageCode);
|
|
1833
|
-
const bundles: TranslationBundle[] = [];
|
|
1834
|
-
|
|
1835
|
-
this.definition.nodes.forEach((node) => {
|
|
1836
|
-
node.actions?.forEach((action) => {
|
|
1837
|
-
const config = ACTION_CONFIG[action.type];
|
|
1838
|
-
if (!config?.localizable || config.localizable.length === 0) {
|
|
1839
|
-
return;
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
// For send_msg actions, only count 'text' for progress tracking
|
|
1843
|
-
// (quick_replies and attachments are still localizable but don't count toward progress)
|
|
1844
|
-
const localizableKeys =
|
|
1845
|
-
action.type === 'send_msg'
|
|
1846
|
-
? config.localizable.filter((key) => key === 'text')
|
|
1847
|
-
: config.localizable;
|
|
1848
|
-
|
|
1849
|
-
const translations = this.findTranslations(
|
|
1850
|
-
'property',
|
|
1851
|
-
action.uuid,
|
|
1852
|
-
localizableKeys,
|
|
1853
|
-
action,
|
|
1854
|
-
languageLocalization
|
|
1855
|
-
);
|
|
1856
|
-
|
|
1857
|
-
if (translations.length > 0) {
|
|
1858
|
-
bundles.push({
|
|
1859
|
-
nodeUuid: node.uuid,
|
|
1860
|
-
actionUuid: action.uuid,
|
|
1861
|
-
translations
|
|
1862
|
-
});
|
|
1863
|
-
}
|
|
1864
|
-
});
|
|
1865
|
-
|
|
1866
|
-
const nodeUI = this.definition._ui?.nodes?.[node.uuid];
|
|
1867
|
-
const nodeType = nodeUI?.type;
|
|
1868
|
-
if (!nodeType) {
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
|
|
1872
|
-
// Include rule (case argument) translations when localizeRules is set
|
|
1873
|
-
if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
|
|
1874
|
-
const ruleTranslations = node.router.cases
|
|
1875
|
-
.filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
|
|
1876
|
-
.flatMap((c) =>
|
|
1877
|
-
this.findTranslations(
|
|
1878
|
-
'property',
|
|
1879
|
-
c.uuid,
|
|
1880
|
-
['arguments'],
|
|
1881
|
-
c,
|
|
1882
|
-
languageLocalization
|
|
1883
|
-
)
|
|
1884
|
-
);
|
|
1885
|
-
|
|
1886
|
-
if (ruleTranslations.length > 0) {
|
|
1887
|
-
bundles.push({
|
|
1888
|
-
nodeUuid: node.uuid,
|
|
1889
|
-
translations: ruleTranslations
|
|
1890
|
-
});
|
|
1891
|
-
}
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
const nodeConfig = NODE_CONFIG[nodeType];
|
|
1895
|
-
if (
|
|
1896
|
-
nodeUI?.config?.localizeCategories &&
|
|
1897
|
-
nodeConfig?.localizable === 'categories' &&
|
|
1898
|
-
node.router?.categories?.length
|
|
1899
|
-
) {
|
|
1900
|
-
const translatableCategories = getTranslatableCategoriesForNode(
|
|
1901
|
-
nodeType,
|
|
1902
|
-
node.router.categories
|
|
1903
|
-
);
|
|
1904
|
-
const categoryTranslations = translatableCategories.flatMap(
|
|
1905
|
-
(category) =>
|
|
1906
|
-
this.findTranslations(
|
|
1907
|
-
'category',
|
|
1908
|
-
category.uuid,
|
|
1909
|
-
['name'],
|
|
1910
|
-
category,
|
|
1911
|
-
languageLocalization
|
|
1912
|
-
)
|
|
1913
|
-
);
|
|
1914
|
-
|
|
1915
|
-
if (categoryTranslations.length > 0) {
|
|
1916
|
-
bundles.push({
|
|
1917
|
-
nodeUuid: node.uuid,
|
|
1918
|
-
translations: categoryTranslations
|
|
1919
|
-
});
|
|
1920
|
-
}
|
|
1921
|
-
}
|
|
1922
|
-
});
|
|
1923
|
-
|
|
1924
|
-
return bundles;
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
private findTranslations(
|
|
1928
|
-
type: TranslationType,
|
|
1929
|
-
uuid: string,
|
|
1930
|
-
localizeableKeys: string[],
|
|
1931
|
-
source: any,
|
|
1932
|
-
localization: Record<string, any>
|
|
1933
|
-
): TranslationEntry[] {
|
|
1934
|
-
const translations: TranslationEntry[] = [];
|
|
1935
|
-
|
|
1936
|
-
localizeableKeys.forEach((attribute) => {
|
|
1937
|
-
if (attribute === 'quick_replies') {
|
|
1938
|
-
return;
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
const pathSegments = attribute.split('.');
|
|
1942
|
-
let from: any = source;
|
|
1943
|
-
let to: any = [];
|
|
1944
|
-
|
|
1945
|
-
while (pathSegments.length > 0 && from) {
|
|
1946
|
-
if (from.uuid) {
|
|
1947
|
-
to = localization[from.uuid];
|
|
1948
|
-
}
|
|
1949
|
-
|
|
1950
|
-
const path = pathSegments.shift();
|
|
1951
|
-
if (!path) {
|
|
1952
|
-
break;
|
|
1953
|
-
}
|
|
1954
|
-
|
|
1955
|
-
if (to) {
|
|
1956
|
-
to = to[path];
|
|
1957
|
-
}
|
|
1958
|
-
from = from[path];
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
if (!from) {
|
|
1962
|
-
return;
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const fromValue = this.formatTranslationValue(from);
|
|
1966
|
-
if (!fromValue) {
|
|
1967
|
-
return;
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
const toValue = to ? this.formatTranslationValue(to) : null;
|
|
1971
|
-
|
|
1972
|
-
translations.push({
|
|
1973
|
-
uuid,
|
|
1974
|
-
type,
|
|
1975
|
-
attribute,
|
|
1976
|
-
from: fromValue,
|
|
1977
|
-
to: toValue
|
|
1978
|
-
});
|
|
1979
|
-
});
|
|
1980
|
-
|
|
1981
|
-
return translations;
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
private formatTranslationValue(value: any): string | null {
|
|
1985
|
-
if (value === null || value === undefined) {
|
|
1986
|
-
return null;
|
|
1987
|
-
}
|
|
1988
|
-
|
|
1989
|
-
if (Array.isArray(value)) {
|
|
1990
|
-
const normalized = value
|
|
1991
|
-
.map((entry) => this.formatTranslationValue(entry))
|
|
1992
|
-
.filter((entry) => !!entry) as string[];
|
|
1993
|
-
return normalized.length > 0 ? normalized.join(', ') : null;
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
if (typeof value === 'object') {
|
|
1997
|
-
if ('name' in value && value.name) {
|
|
1998
|
-
return String(value.name);
|
|
1999
|
-
}
|
|
2000
|
-
|
|
2001
|
-
if ('arguments' in value && Array.isArray(value.arguments)) {
|
|
2002
|
-
return value.arguments.join(' ');
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
return null;
|
|
2006
|
-
}
|
|
2007
|
-
|
|
2008
|
-
if (typeof value === 'number') {
|
|
2009
|
-
return value.toString();
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
if (typeof value === 'string') {
|
|
2013
|
-
const trimmed = value.trim();
|
|
2014
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
return null;
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
private getTranslationCounts(bundles: TranslationBundle[]): {
|
|
2021
|
-
total: number;
|
|
2022
|
-
localized: number;
|
|
2023
|
-
} {
|
|
2024
|
-
return bundles.reduce(
|
|
2025
|
-
(counts, bundle) => {
|
|
2026
|
-
bundle.translations.forEach((translation) => {
|
|
2027
|
-
counts.total += 1;
|
|
2028
|
-
if (translation.to && translation.to.trim().length > 0) {
|
|
2029
|
-
counts.localized += 1;
|
|
2030
|
-
}
|
|
2031
|
-
});
|
|
2032
|
-
return counts;
|
|
2033
|
-
},
|
|
2034
|
-
{ total: 0, localized: 0 }
|
|
1750
|
+
return getTranslationCounts(
|
|
1751
|
+
buildTranslationBundles(this.definition, languageCode)
|
|
2035
1752
|
);
|
|
2036
1753
|
}
|
|
2037
1754
|
|
|
@@ -2042,6 +1759,18 @@ export class Editor extends RapidElement {
|
|
|
2042
1759
|
);
|
|
2043
1760
|
}
|
|
2044
1761
|
|
|
1762
|
+
private handleAutoTranslateClick(): void {
|
|
1763
|
+
if (!this.autoTranslateEnabled || this.viewingRevision) {
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
const at = this.querySelector('temba-auto-translate') as any;
|
|
1767
|
+
at?.start();
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
private handleAutoTranslateChanged(e: CustomEvent): void {
|
|
1771
|
+
this.autoTranslating = !!e.detail?.running;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
2045
1774
|
disconnectedCallback(): void {
|
|
2046
1775
|
super.disconnectedCallback();
|
|
2047
1776
|
this.zoomManager.teardownLoupe();
|
|
@@ -3961,6 +3690,12 @@ export class Editor extends RapidElement {
|
|
|
3961
3690
|
})
|
|
3962
3691
|
];
|
|
3963
3692
|
|
|
3693
|
+
const hasPendingTranslations =
|
|
3694
|
+
this.autoTranslateEnabled &&
|
|
3695
|
+
Boolean(activeLanguage) &&
|
|
3696
|
+
progress.total > 0 &&
|
|
3697
|
+
progress.localized < progress.total;
|
|
3698
|
+
|
|
3964
3699
|
return html`
|
|
3965
3700
|
<temba-editor-toolbar
|
|
3966
3701
|
?message-view=${this.showMessageTable}
|
|
@@ -3976,6 +3711,10 @@ export class Editor extends RapidElement {
|
|
|
3976
3711
|
?is-base-language=${isBaseSelected}
|
|
3977
3712
|
.languagePercent=${percent}
|
|
3978
3713
|
?show-localization-tools=${Boolean(activeLanguage)}
|
|
3714
|
+
?has-pending-translations=${hasPendingTranslations ||
|
|
3715
|
+
this.autoTranslating}
|
|
3716
|
+
?auto-translate-disabled=${this.viewingRevision}
|
|
3717
|
+
?auto-translating=${this.autoTranslating}
|
|
3979
3718
|
@temba-button-clicked=${this.handleToolbarAction}
|
|
3980
3719
|
></temba-editor-toolbar>
|
|
3981
3720
|
`;
|
|
@@ -4011,6 +3750,9 @@ export class Editor extends RapidElement {
|
|
|
4011
3750
|
case 'search':
|
|
4012
3751
|
this.openFlowSearch();
|
|
4013
3752
|
break;
|
|
3753
|
+
case 'auto-translate':
|
|
3754
|
+
this.handleAutoTranslateClick();
|
|
3755
|
+
break;
|
|
4014
3756
|
case 'language-change':
|
|
4015
3757
|
if (detail.isPrimary) {
|
|
4016
3758
|
this.handleLanguageChange(this.definition?.language || '');
|
|
@@ -4186,6 +3928,14 @@ export class Editor extends RapidElement {
|
|
|
4186
3928
|
@temba-revision-reverted=${this.handleRevisionReverted}
|
|
4187
3929
|
@temba-revisions-closed=${this.handleRevisionsClosed}
|
|
4188
3930
|
></temba-revisions-window>
|
|
3931
|
+
${this.autoTranslateEnabled
|
|
3932
|
+
? html`<temba-auto-translate
|
|
3933
|
+
.definition=${this.definition}
|
|
3934
|
+
language-code=${this.languageCode}
|
|
3935
|
+
?disabled=${this.viewingRevision}
|
|
3936
|
+
@temba-auto-translate-changed=${this.handleAutoTranslateChanged}
|
|
3937
|
+
></temba-auto-translate>`
|
|
3938
|
+
: ''}
|
|
4189
3939
|
<div id="editor-container">
|
|
4190
3940
|
${this.renderToolbarElement()}
|
|
4191
3941
|
<div id="editor">
|
|
@@ -249,6 +249,15 @@ export class EditorToolbar extends RapidElement {
|
|
|
249
249
|
@property({ type: Boolean, attribute: 'show-localization-tools' })
|
|
250
250
|
showLocalizationTools = false;
|
|
251
251
|
|
|
252
|
+
@property({ type: Boolean, attribute: 'has-pending-translations' })
|
|
253
|
+
hasPendingTranslations = false;
|
|
254
|
+
|
|
255
|
+
@property({ type: Boolean, attribute: 'auto-translate-disabled' })
|
|
256
|
+
autoTranslateDisabled = false;
|
|
257
|
+
|
|
258
|
+
@property({ type: Boolean, attribute: 'auto-translating' })
|
|
259
|
+
autoTranslating = false;
|
|
260
|
+
|
|
252
261
|
@state()
|
|
253
262
|
private showLanguageOptions = false;
|
|
254
263
|
|
|
@@ -559,7 +568,34 @@ export class EditorToolbar extends RapidElement {
|
|
|
559
568
|
}
|
|
560
569
|
|
|
561
570
|
private renderTranslationTools(): TemplateResult {
|
|
562
|
-
|
|
563
|
-
|
|
571
|
+
if (!this.hasPendingTranslations && !this.autoTranslating) {
|
|
572
|
+
return html``;
|
|
573
|
+
}
|
|
574
|
+
const label = this.autoTranslating
|
|
575
|
+
? 'Stop auto translate'
|
|
576
|
+
: 'Auto translate';
|
|
577
|
+
return html`
|
|
578
|
+
<div class="toolbar-translation">
|
|
579
|
+
${this.renderTip(
|
|
580
|
+
label,
|
|
581
|
+
html`
|
|
582
|
+
<button
|
|
583
|
+
class="toolbar-btn language-tool ${this.autoTranslating
|
|
584
|
+
? 'active'
|
|
585
|
+
: ''}"
|
|
586
|
+
@click=${() => this.fireToolbarAction('auto-translate')}
|
|
587
|
+
?disabled=${this.autoTranslateDisabled}
|
|
588
|
+
aria-label=${label}
|
|
589
|
+
>
|
|
590
|
+
<temba-icon
|
|
591
|
+
name=${this.autoTranslating ? 'progress_spinner' : Icon.ai}
|
|
592
|
+
size="1"
|
|
593
|
+
?spin=${this.autoTranslating}
|
|
594
|
+
></temba-icon>
|
|
595
|
+
</button>
|
|
596
|
+
`
|
|
597
|
+
)}
|
|
598
|
+
</div>
|
|
599
|
+
`;
|
|
564
600
|
}
|
|
565
601
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { FlowDefinition } from '../store/flow-definition';
|
|
2
|
+
import { ACTION_CONFIG, NODE_CONFIG } from './config';
|
|
3
|
+
import { getTranslatableCategoriesForNode } from './categoryLocalization';
|
|
4
|
+
|
|
5
|
+
export type TranslationType = 'property' | 'category';
|
|
6
|
+
|
|
7
|
+
export interface TranslationEntry {
|
|
8
|
+
uuid: string;
|
|
9
|
+
type: TranslationType;
|
|
10
|
+
attribute: string;
|
|
11
|
+
from: string;
|
|
12
|
+
to: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TranslationBundle {
|
|
16
|
+
nodeUuid: string;
|
|
17
|
+
actionUuid?: string;
|
|
18
|
+
translations: TranslationEntry[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatTranslationValue(value: any): string | null {
|
|
22
|
+
if (value === null || value === undefined) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
const normalized = value
|
|
28
|
+
.map((entry) => formatTranslationValue(entry))
|
|
29
|
+
.filter((entry) => !!entry) as string[];
|
|
30
|
+
return normalized.length > 0 ? normalized.join(', ') : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (typeof value === 'object') {
|
|
34
|
+
if ('name' in value && value.name) {
|
|
35
|
+
return String(value.name);
|
|
36
|
+
}
|
|
37
|
+
if ('arguments' in value && Array.isArray(value.arguments)) {
|
|
38
|
+
return value.arguments.join(' ');
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof value === 'number') {
|
|
44
|
+
return value.toString();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (typeof value === 'string') {
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findTranslations(
|
|
56
|
+
type: TranslationType,
|
|
57
|
+
uuid: string,
|
|
58
|
+
localizableKeys: string[],
|
|
59
|
+
source: any,
|
|
60
|
+
localization: Record<string, any>
|
|
61
|
+
): TranslationEntry[] {
|
|
62
|
+
const translations: TranslationEntry[] = [];
|
|
63
|
+
|
|
64
|
+
localizableKeys.forEach((attribute) => {
|
|
65
|
+
if (attribute === 'quick_replies') {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const pathSegments = attribute.split('.');
|
|
70
|
+
let from: any = source;
|
|
71
|
+
let to: any = [];
|
|
72
|
+
|
|
73
|
+
while (pathSegments.length > 0 && from) {
|
|
74
|
+
if (from.uuid) {
|
|
75
|
+
to = localization[from.uuid];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const path = pathSegments.shift();
|
|
79
|
+
if (!path) {
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (to) {
|
|
84
|
+
to = to[path];
|
|
85
|
+
}
|
|
86
|
+
from = from[path];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!from) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fromValue = formatTranslationValue(from);
|
|
94
|
+
if (!fromValue) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const toValue = to ? formatTranslationValue(to) : null;
|
|
99
|
+
|
|
100
|
+
translations.push({
|
|
101
|
+
uuid,
|
|
102
|
+
type,
|
|
103
|
+
attribute,
|
|
104
|
+
from: fromValue,
|
|
105
|
+
to: toValue
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return translations;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function buildTranslationBundles(
|
|
113
|
+
definition: FlowDefinition | null | undefined,
|
|
114
|
+
languageCode: string
|
|
115
|
+
): TranslationBundle[] {
|
|
116
|
+
if (!definition || !languageCode || languageCode === definition.language) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const languageLocalization = definition.localization?.[languageCode] || {};
|
|
121
|
+
const bundles: TranslationBundle[] = [];
|
|
122
|
+
|
|
123
|
+
definition.nodes.forEach((node) => {
|
|
124
|
+
node.actions?.forEach((action) => {
|
|
125
|
+
const config = ACTION_CONFIG[action.type];
|
|
126
|
+
if (!config?.localizable || config.localizable.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// For send_msg actions, only count 'text' for progress tracking
|
|
131
|
+
// (quick_replies and attachments are still localizable but don't count toward progress)
|
|
132
|
+
const localizableKeys =
|
|
133
|
+
action.type === 'send_msg'
|
|
134
|
+
? config.localizable.filter((key) => key === 'text')
|
|
135
|
+
: config.localizable;
|
|
136
|
+
|
|
137
|
+
const translations = findTranslations(
|
|
138
|
+
'property',
|
|
139
|
+
action.uuid,
|
|
140
|
+
localizableKeys,
|
|
141
|
+
action,
|
|
142
|
+
languageLocalization
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (translations.length > 0) {
|
|
146
|
+
bundles.push({
|
|
147
|
+
nodeUuid: node.uuid,
|
|
148
|
+
actionUuid: action.uuid,
|
|
149
|
+
translations
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const nodeUI = definition._ui?.nodes?.[node.uuid];
|
|
155
|
+
const nodeType = nodeUI?.type;
|
|
156
|
+
if (!nodeType) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (nodeUI?.config?.localizeRules && node.router?.cases?.length) {
|
|
161
|
+
const ruleTranslations = node.router.cases
|
|
162
|
+
.filter((c) => c.arguments?.length > 0 && c.arguments.some((a) => a))
|
|
163
|
+
.flatMap((c) =>
|
|
164
|
+
findTranslations(
|
|
165
|
+
'property',
|
|
166
|
+
c.uuid,
|
|
167
|
+
['arguments'],
|
|
168
|
+
c,
|
|
169
|
+
languageLocalization
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (ruleTranslations.length > 0) {
|
|
174
|
+
bundles.push({
|
|
175
|
+
nodeUuid: node.uuid,
|
|
176
|
+
translations: ruleTranslations
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const nodeConfig = NODE_CONFIG[nodeType];
|
|
182
|
+
if (
|
|
183
|
+
nodeUI?.config?.localizeCategories &&
|
|
184
|
+
nodeConfig?.localizable === 'categories' &&
|
|
185
|
+
node.router?.categories?.length
|
|
186
|
+
) {
|
|
187
|
+
const translatableCategories = getTranslatableCategoriesForNode(
|
|
188
|
+
nodeType,
|
|
189
|
+
node.router.categories
|
|
190
|
+
);
|
|
191
|
+
const categoryTranslations = translatableCategories.flatMap((category) =>
|
|
192
|
+
findTranslations(
|
|
193
|
+
'category',
|
|
194
|
+
category.uuid,
|
|
195
|
+
['name'],
|
|
196
|
+
category,
|
|
197
|
+
languageLocalization
|
|
198
|
+
)
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
if (categoryTranslations.length > 0) {
|
|
202
|
+
bundles.push({
|
|
203
|
+
nodeUuid: node.uuid,
|
|
204
|
+
translations: categoryTranslations
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return bundles;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getTranslationCounts(bundles: TranslationBundle[]): {
|
|
214
|
+
total: number;
|
|
215
|
+
localized: number;
|
|
216
|
+
} {
|
|
217
|
+
return bundles.reduce(
|
|
218
|
+
(counts, bundle) => {
|
|
219
|
+
bundle.translations.forEach((translation) => {
|
|
220
|
+
counts.total += 1;
|
|
221
|
+
if (translation.to && translation.to.trim().length > 0) {
|
|
222
|
+
counts.localized += 1;
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
return counts;
|
|
226
|
+
},
|
|
227
|
+
{ total: 0, localized: 0 }
|
|
228
|
+
);
|
|
229
|
+
}
|