1#![forbid(unsafe_code)]
2use std::path::Path;
30
31use serde_json::Value;
32
33#[derive(Debug, Clone)]
39pub struct ExtractedString {
40 pub key: String,
42 pub value: String,
44 pub source_file: std::path::PathBuf,
46 pub json_path: String,
48}
49
50const TRANSLATABLE_FIELDS: &[&str] = &[
56 "text",
57 "title",
58 "placeholder",
59 "label",
60 "altText",
61 "errorMessage",
62 "value", "fallbackText",
64 "speak",
65];
66
67const CONTAINER_FIELDS: &[&str] = &[
71 "body",
72 "actions",
73 "items",
74 "columns",
75 "inlines",
76 "card", "inlineAction",
78];
79
80pub 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
252pub 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 if skip_i18n_patterns && (trimmed.contains("$t(") || trimmed.contains("$tp(")) {
266 return false;
267 }
268
269 if trimmed.starts_with("{{") && trimmed.ends_with("}}") {
271 return false;
272 }
273
274 if trimmed.starts_with("${") && trimmed.ends_with('}') {
276 return false;
277 }
278
279 true
280}
281
282pub 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
291pub 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#[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); }
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}