Skip to main content

packc/i18n_build/
extract.rs

1#![forbid(unsafe_code)]
2//! Extract translatable strings from Adaptive Cards.
3//!
4//! This module provides the recursive extraction logic for translatable text fields
5//! and generates i18n key-value pairs for translation bundles.
6//!
7//! Ported from `greentic-cards2pack/src/i18n_extract/extractor.rs`
8//! so that `greentic-pack` has no cross-crate dependency on `greentic-cards2pack`.
9//!
10//! # Extractable Fields
11//!
12//! - `text` - TextBlock, RichTextBlock text content
13//! - `title` - Action titles, card titles
14//! - `placeholder` - Input placeholders
15//! - `label` - Input labels
16//! - `altText` - Image alt text
17//! - `errorMessage` - Validation error messages
18//! - `inlineAction.title` - Inline action titles
19//!
20//! # Generated Key Format
21//!
22//! Keys follow the pattern: `{card_id}.{json_path}.{field}`
23//!
24//! Examples:
25//! - `incident.body_0.text`
26//! - `incident.actions_0.title`
27//! - `greeting.body_1_items_0.text`
28
29use std::path::Path;
30
31use serde_json::Value;
32
33// ---------------------------------------------------------------------------
34// Public types
35// ---------------------------------------------------------------------------
36
37/// An extracted translatable string.
38#[derive(Debug, Clone)]
39pub struct ExtractedString {
40    /// Generated i18n key.
41    pub key: String,
42    /// Original text value.
43    pub value: String,
44    /// Source file path.
45    pub source_file: std::path::PathBuf,
46    /// JSON path to the field (e.g., "body[0].text").
47    pub json_path: String,
48}
49
50// ---------------------------------------------------------------------------
51// Constants
52// ---------------------------------------------------------------------------
53
54/// Text fields that should be extracted for translation.
55const TRANSLATABLE_FIELDS: &[&str] = &[
56    "text",
57    "title",
58    "placeholder",
59    "label",
60    "altText",
61    "errorMessage",
62    "value", // For TextBlock with value
63    "fallbackText",
64    "speak",
65];
66
67/// Fields that contain nested elements with translatable content.
68/// Note: "facts" and "choices" are excluded here because they have
69/// dedicated extraction logic below (FactSet, ChoiceSet).
70const CONTAINER_FIELDS: &[&str] = &[
71    "body",
72    "actions",
73    "items",
74    "columns",
75    "inlines",
76    "card", // For Action.ShowCard
77    "inlineAction",
78];
79
80// ---------------------------------------------------------------------------
81// Recursive extractor (from extractor.rs)
82// ---------------------------------------------------------------------------
83
84/// Extract strings from a JSON value recursively.
85pub fn extract_from_value(
86    value: &Value,
87    prefix: &str,
88    path: &str,
89    source_file: &Path,
90    skip_i18n_patterns: bool,
91) -> Vec<ExtractedString> {
92    let mut strings = Vec::new();
93
94    match value {
95        Value::Object(obj) => {
96            extract_translatable_fields(
97                obj,
98                prefix,
99                path,
100                source_file,
101                skip_i18n_patterns,
102                &mut strings,
103            );
104            extract_container_fields(
105                obj,
106                prefix,
107                path,
108                source_file,
109                skip_i18n_patterns,
110                &mut strings,
111            );
112            extract_factset(
113                obj,
114                prefix,
115                path,
116                source_file,
117                skip_i18n_patterns,
118                &mut strings,
119            );
120            extract_choiceset(
121                obj,
122                prefix,
123                path,
124                source_file,
125                skip_i18n_patterns,
126                &mut strings,
127            );
128        }
129        Value::Array(arr) => {
130            for (i, item) in arr.iter().enumerate() {
131                let item_path = format!("{}_{}", path, i);
132                strings.extend(extract_from_value(
133                    item,
134                    prefix,
135                    &item_path,
136                    source_file,
137                    skip_i18n_patterns,
138                ));
139            }
140        }
141        _ => {}
142    }
143
144    strings
145}
146
147fn extract_translatable_fields(
148    obj: &serde_json::Map<String, Value>,
149    prefix: &str,
150    path: &str,
151    source_file: &Path,
152    skip_i18n_patterns: bool,
153    strings: &mut Vec<ExtractedString>,
154) {
155    for field in TRANSLATABLE_FIELDS {
156        if let Some(Value::String(text)) = obj.get(*field)
157            && should_extract(text, skip_i18n_patterns)
158        {
159            strings.push(ExtractedString {
160                key: build_key(prefix, path, field),
161                value: text.clone(),
162                source_file: source_file.to_path_buf(),
163                json_path: build_json_path(path, field),
164            });
165        }
166    }
167}
168
169fn extract_container_fields(
170    obj: &serde_json::Map<String, Value>,
171    prefix: &str,
172    path: &str,
173    source_file: &Path,
174    skip_i18n_patterns: bool,
175    strings: &mut Vec<ExtractedString>,
176) {
177    for field in CONTAINER_FIELDS {
178        if let Some(child) = obj.get(*field) {
179            let child_path = if path.is_empty() {
180                field.to_string()
181            } else {
182                format!("{}_{}", path, field)
183            };
184            strings.extend(extract_from_value(
185                child,
186                prefix,
187                &child_path,
188                source_file,
189                skip_i18n_patterns,
190            ));
191        }
192    }
193}
194
195fn extract_factset(
196    obj: &serde_json::Map<String, Value>,
197    prefix: &str,
198    path: &str,
199    source_file: &Path,
200    skip_i18n_patterns: bool,
201    strings: &mut Vec<ExtractedString>,
202) {
203    let Some(facts) = obj.get("facts").and_then(|v| v.as_array()) else {
204        return;
205    };
206    for (i, fact) in facts.iter().enumerate() {
207        let fact_path = format!("{}_facts_{}", path, i);
208        if let Some(fact_obj) = fact.as_object() {
209            for field in ["title", "value"] {
210                if let Some(Value::String(text)) = fact_obj.get(field)
211                    && should_extract(text, skip_i18n_patterns)
212                {
213                    strings.push(ExtractedString {
214                        key: build_key(prefix, &fact_path, field),
215                        value: text.clone(),
216                        source_file: source_file.to_path_buf(),
217                        json_path: build_json_path(&fact_path, field),
218                    });
219                }
220            }
221        }
222    }
223}
224
225fn extract_choiceset(
226    obj: &serde_json::Map<String, Value>,
227    prefix: &str,
228    path: &str,
229    source_file: &Path,
230    skip_i18n_patterns: bool,
231    strings: &mut Vec<ExtractedString>,
232) {
233    let Some(choices) = obj.get("choices").and_then(|v| v.as_array()) else {
234        return;
235    };
236    for (i, choice) in choices.iter().enumerate() {
237        let choice_path = format!("{}_choices_{}", path, i);
238        if let Some(choice_obj) = choice.as_object()
239            && let Some(Value::String(title)) = choice_obj.get("title")
240            && should_extract(title, skip_i18n_patterns)
241        {
242            strings.push(ExtractedString {
243                key: build_key(prefix, &choice_path, "title"),
244                value: title.clone(),
245                source_file: source_file.to_path_buf(),
246                json_path: build_json_path(&choice_path, "title"),
247            });
248        }
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Key / path helpers (private)
254// ---------------------------------------------------------------------------
255
256/// Check if a string should be extracted.
257pub fn should_extract(text: &str, skip_i18n_patterns: bool) -> bool {
258    let trimmed = text.trim();
259
260    if trimmed.is_empty() {
261        return false;
262    }
263
264    // Skip existing i18n patterns
265    if skip_i18n_patterns && (trimmed.contains("$t(") || trimmed.contains("$tp(")) {
266        return false;
267    }
268
269    // Skip pure template expressions (Handlebars)
270    if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
271        return false;
272    }
273
274    // Skip variable references
275    if trimmed.starts_with("${") && trimmed.ends_with('}') {
276        return false;
277    }
278
279    true
280}
281
282/// Build an i18n key from prefix, path, and field.
283pub fn build_key(prefix: &str, path: &str, field: &str) -> String {
284    if path.is_empty() {
285        format!("{}.{}", prefix, field)
286    } else {
287        format!("{}.{}.{}", prefix, path, field)
288    }
289}
290
291/// Build a JSON path string for documentation.
292pub fn build_json_path(path: &str, field: &str) -> String {
293    if path.is_empty() {
294        return field.to_string();
295    }
296
297    let parts: Vec<&str> = path.split('_').collect();
298    let mut result = String::new();
299    for (i, part) in parts.iter().enumerate() {
300        if part.parse::<usize>().is_ok() {
301            result.push_str(&format!("[{}]", part));
302        } else {
303            if i > 0 {
304                result.push('.');
305            }
306            result.push_str(part);
307        }
308    }
309    format!("{}.{}", result, field)
310}
311
312// ---------------------------------------------------------------------------
313// Tests — from extractor.rs
314// ---------------------------------------------------------------------------
315
316#[cfg(test)]
317mod tests {
318    use serde_json::json;
319
320    use super::*;
321
322    #[test]
323    fn test_should_extract() {
324        assert!(should_extract("Hello World", false));
325        assert!(!should_extract("", false));
326        assert!(!should_extract("   ", false));
327        assert!(!should_extract("$t(key)", true));
328        assert!(!should_extract("{{variable}}", false));
329        assert!(!should_extract("${var}", false));
330        assert!(should_extract("$t(key)", false));
331    }
332
333    #[test]
334    fn test_build_key() {
335        assert_eq!(build_key("card", "", "text"), "card.text");
336        assert_eq!(build_key("card", "body_0", "text"), "card.body_0.text");
337    }
338
339    #[test]
340    fn test_build_json_path() {
341        assert_eq!(build_json_path("", "text"), "text");
342        assert_eq!(build_json_path("body_0", "text"), "body[0].text");
343        assert_eq!(
344            build_json_path("body_1_items_0", "text"),
345            "body[1].items[0].text"
346        );
347    }
348
349    #[test]
350    fn test_extract_from_simple_card() {
351        let card = json!({
352            "type": "AdaptiveCard",
353            "body": [
354                { "type": "TextBlock", "text": "Hello World" }
355            ],
356            "actions": [
357                { "type": "Action.Submit", "title": "Submit" }
358            ]
359        });
360
361        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
362
363        assert_eq!(strings.len(), 2);
364        assert!(
365            strings
366                .iter()
367                .any(|s| s.key == "test.body_0.text" && s.value == "Hello World")
368        );
369        assert!(
370            strings
371                .iter()
372                .any(|s| s.key == "test.actions_0.title" && s.value == "Submit")
373        );
374    }
375
376    #[test]
377    fn test_extract_skips_i18n_patterns() {
378        let card = json!({
379            "type": "AdaptiveCard",
380            "body": [
381                { "type": "TextBlock", "text": "$t(card.greeting)" },
382                { "type": "TextBlock", "text": "Regular text" }
383            ]
384        });
385
386        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
387        assert_eq!(strings.len(), 1);
388        assert_eq!(strings[0].value, "Regular text");
389    }
390
391    #[test]
392    fn test_extract_input_fields() {
393        let card = json!({
394            "type": "AdaptiveCard",
395            "body": [{
396                "type": "Input.Text",
397                "id": "name",
398                "label": "Your Name",
399                "placeholder": "Enter your name",
400                "errorMessage": "Name is required"
401            }]
402        });
403
404        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
405        assert_eq!(strings.len(), 3);
406        assert!(strings.iter().any(|s| s.key.ends_with(".label")));
407        assert!(strings.iter().any(|s| s.key.ends_with(".placeholder")));
408        assert!(strings.iter().any(|s| s.key.ends_with(".errorMessage")));
409    }
410
411    #[test]
412    fn test_extract_factset() {
413        let card = json!({
414            "type": "AdaptiveCard",
415            "body": [{
416                "type": "FactSet",
417                "facts": [
418                    {"title": "Name", "value": "John Doe"},
419                    {"title": "Email", "value": "john@example.com"}
420                ]
421            }]
422        });
423
424        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
425        assert_eq!(strings.len(), 4);
426    }
427
428    #[test]
429    fn test_extract_choice_set() {
430        let card = json!({
431            "type": "AdaptiveCard",
432            "body": [{
433                "type": "Input.ChoiceSet",
434                "id": "choice",
435                "label": "Select an option",
436                "choices": [
437                    {"title": "Option A", "value": "a"},
438                    {"title": "Option B", "value": "b"}
439                ]
440            }]
441        });
442
443        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
444        assert_eq!(strings.len(), 3); // label + 2 choice titles
445    }
446
447    #[test]
448    fn test_extract_nested_column_items() {
449        let card = json!({
450            "type": "AdaptiveCard",
451            "body": [{
452                "type": "ColumnSet",
453                "columns": [
454                    { "type": "Column", "items": [{ "type": "TextBlock", "text": "Left column" }] },
455                    { "type": "Column", "items": [{ "type": "TextBlock", "text": "Right column" }] }
456                ]
457            }]
458        });
459
460        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
461        assert!(strings.iter().any(|s| s.value == "Left column"));
462        assert!(strings.iter().any(|s| s.value == "Right column"));
463    }
464
465    #[test]
466    fn test_extract_show_card_action() {
467        let card = json!({
468            "type": "AdaptiveCard",
469            "actions": [{
470                "type": "Action.ShowCard",
471                "title": "Show Details",
472                "card": {
473                    "type": "AdaptiveCard",
474                    "body": [{ "type": "TextBlock", "text": "Hidden detail" }]
475                }
476            }]
477        });
478
479        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
480        assert!(strings.iter().any(|s| s.value == "Show Details"));
481        assert!(strings.iter().any(|s| s.value == "Hidden detail"));
482    }
483
484    #[test]
485    fn test_extract_skips_pure_handlebars() {
486        let card = json!({
487            "type": "AdaptiveCard",
488            "body": [
489                { "type": "TextBlock", "text": "{{variable}}" },
490                { "type": "TextBlock", "text": "Hello {{name}}" }
491            ]
492        });
493
494        let strings = extract_from_value(&card, "test", "", Path::new("test.json"), true);
495        assert!(!strings.iter().any(|s| s.value == "{{variable}}"));
496        assert!(strings.iter().any(|s| s.value == "Hello {{name}}"));
497    }
498}