// SPDX-License-Identifier: AGPL-3.0-or-later use std::collections::HashMap; use std::ops::Range; use std::path::PathBuf; use anyhow::{Context, Result, bail}; use crate::po::{Entry, Translation}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum StaticTsCatalogKind { SimpleMessages, EmailTemplates, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct StaticTsCatalogConfig { pub kind: StaticTsCatalogKind, pub source_path: PathBuf, pub source_export: String, pub locale_function: String, } #[derive(Clone, Debug, Eq, PartialEq)] struct ObjectRange { body: Range, } #[derive(Clone, Debug, Eq, PartialEq)] struct ParsedStringProperty { key: String, value: String, value_range: Range, line_number: usize, } #[derive(Clone, Debug, Eq, PartialEq)] struct ParsedObjectProperty { key: String, body: Range, } #[derive(Clone, Debug, Eq, PartialEq)] struct StaticSourceMessage { context: String, source: String, line_number: usize, } #[derive(Clone, Debug, Eq, PartialEq)] struct StaticTargetCatalog { object_body: Range, values: HashMap, template_objects: HashMap, } pub fn read_static_ts_entries( config: &StaticTsCatalogConfig, source_content: &str, target_content: &str, reset: bool, ) -> Result> { let source_messages = parse_source_messages(config, source_content)?; let target_catalog = parse_target_catalog(config, target_content)?; Ok(source_messages .into_iter() .map(|source| { let msgstr = if reset { String::new() } else { target_catalog .values .get(&source.context) .map(|target| target.value.clone()) .unwrap_or_default() }; Entry { comments: static_comments(config, &source.context), references: vec![format!( "#: {}:{}", config.source_path.display(), source.line_number )], msgctxt: Some(source.context), msgid: source.source, msgstr, line_number: source.line_number, } }) .collect()) } pub fn rebuild_static_ts_allow_replacing( config: &StaticTsCatalogConfig, source_content: &str, target_content: &str, translations: &[Translation], ) -> Result { let source_messages = parse_source_messages(config, source_content)?; let target_catalog = parse_target_catalog(config, target_content)?; let translations_by_context = translations .iter() .filter_map(|translation| { translation .msgctxt .as_ref() .map(|context| (context.clone(), translation)) }) .collect::>(); let mut replacements = Vec::new(); for (context, translation) in &translations_by_context { if let Some(target) = target_catalog.values.get(context) { replacements.push(( target.value_range.clone(), escape_ts_single_quoted(&translation.msgstr), )); } } match config.kind { StaticTsCatalogKind::SimpleMessages => { add_missing_simple_translations( target_content, &target_catalog, &source_messages, &translations_by_context, &mut replacements, ); } StaticTsCatalogKind::EmailTemplates => { add_missing_email_translations( target_content, &target_catalog, &source_messages, &translations_by_context, &mut replacements, )?; } } apply_replacements(target_content, replacements) } pub fn reset_static_ts_translations( config: &StaticTsCatalogConfig, target_content: &str, ) -> Result { let target_catalog = parse_target_catalog(config, target_content)?; let replacements = target_catalog .values .values() .map(|target| (target.value_range.clone(), "''".to_string())) .collect::>(); apply_replacements(target_content, replacements) } fn static_comments(config: &StaticTsCatalogConfig, context: &str) -> Vec { match config.kind { StaticTsCatalogKind::SimpleMessages => { vec![format!("#. Static catalog key: {context}")] } StaticTsCatalogKind::EmailTemplates => { vec![format!("#. Email template field: {context}")] } } } fn parse_source_messages( config: &StaticTsCatalogConfig, source_content: &str, ) -> Result> { let object = find_const_object(source_content, &config.source_export)?; match config.kind { StaticTsCatalogKind::SimpleMessages => { parse_string_properties(source_content, &object.body).map(|properties| { properties .into_iter() .map(|property| StaticSourceMessage { context: property.key, source: property.value, line_number: property.line_number, }) .collect() }) } StaticTsCatalogKind::EmailTemplates => { let mut messages = Vec::new(); for template in parse_object_properties(source_content, &object.body)? { for field in parse_string_properties(source_content, &template.body)? { if field.key == "subject" || field.key == "body" { messages.push(StaticSourceMessage { context: format!("{}.{}", template.key, field.key), source: field.value, line_number: field.line_number, }); } } } Ok(messages) } } } fn parse_target_catalog( config: &StaticTsCatalogConfig, target_content: &str, ) -> Result { let object = find_call_object(target_content, &config.locale_function)?; match config.kind { StaticTsCatalogKind::SimpleMessages => { let values = parse_string_properties(target_content, &object.body)? .into_iter() .map(|property| (property.key.clone(), property)) .collect::>(); Ok(StaticTargetCatalog { object_body: object.body, values, template_objects: HashMap::new(), }) } StaticTsCatalogKind::EmailTemplates => { let mut values = HashMap::new(); let mut template_objects = HashMap::new(); for template in parse_object_properties(target_content, &object.body)? { for field in parse_string_properties(target_content, &template.body)? { if field.key == "subject" || field.key == "body" { values.insert(format!("{}.{}", template.key, field.key), field); } } template_objects.insert(template.key.clone(), template); } Ok(StaticTargetCatalog { object_body: object.body, values, template_objects, }) } } } fn add_missing_simple_translations( target_content: &str, target_catalog: &StaticTargetCatalog, source_messages: &[StaticSourceMessage], translations_by_context: &HashMap, replacements: &mut Vec<(Range, String)>, ) { let lines = source_messages .iter() .filter(|source| !target_catalog.values.contains_key(&source.context)) .filter_map(|source| { translations_by_context .get(&source.context) .map(|translation| { format!( "\t{}: {},", escape_property_key(&source.context), escape_ts_single_quoted(&translation.msgstr) ) }) }) .collect::>(); if !lines.is_empty() { replacements.push(append_to_object_body( target_content, &target_catalog.object_body, lines, )); } } fn add_missing_email_translations( target_content: &str, target_catalog: &StaticTargetCatalog, source_messages: &[StaticSourceMessage], translations_by_context: &HashMap, replacements: &mut Vec<(Range, String)>, ) -> Result<()> { let source_by_context = source_messages .iter() .map(|source| (source.context.clone(), source.source.clone())) .collect::>(); let mut source_template_order = Vec::new(); for source in source_messages { let (template_key, _field) = split_email_context(&source.context)?; if !source_template_order .iter() .any(|candidate| candidate == template_key) { source_template_order.push(template_key.to_string()); } } for template_key in &source_template_order { if let Some(template) = target_catalog.template_objects.get(template_key) { let lines = ["subject", "body"] .into_iter() .filter_map(|field| { let context = format!("{template_key}.{field}"); if target_catalog.values.contains_key(&context) { return None; } translations_by_context.get(&context).map(|translation| { format!( "\t\t{field}: {},", escape_ts_single_quoted(&translation.msgstr) ) }) }) .collect::>(); if !lines.is_empty() { replacements.push(append_to_object_body(target_content, &template.body, lines)); } } } let mut template_blocks = Vec::new(); for template_key in &source_template_order { if target_catalog.template_objects.contains_key(template_key) { continue; } let subject_context = format!("{template_key}.subject"); let body_context = format!("{template_key}.body"); let has_selected_translation = translations_by_context.contains_key(&subject_context) || translations_by_context.contains_key(&body_context); if !has_selected_translation { continue; } let subject = translations_by_context .get(&subject_context) .map(|translation| translation.msgstr.as_str()) .or_else(|| source_by_context.get(&subject_context).map(String::as_str)) .with_context(|| format!("missing source subject for email template {template_key}"))?; let body = translations_by_context .get(&body_context) .map(|translation| translation.msgstr.as_str()) .or_else(|| source_by_context.get(&body_context).map(String::as_str)) .with_context(|| format!("missing source body for email template {template_key}"))?; template_blocks.push(format!( "\t{}: {{\n\t\tsubject: {},\n\t\tbody: {},\n\t}},", escape_identifier_or_key(template_key), escape_ts_single_quoted(subject), escape_ts_single_quoted(body) )); } if !template_blocks.is_empty() { replacements.push(append_to_object_body( target_content, &target_catalog.object_body, template_blocks, )); } Ok(()) } fn split_email_context(context: &str) -> Result<(&str, &str)> { let Some((template, field)) = context.rsplit_once('.') else { bail!("email catalog context must be