diff --git a/output.yaml b/output.yaml
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/output.yaml
@@ -0,0 +1 @@
+{}
diff --git a/scripts/convert.py b/scripts/convert.py
index 3f60754fc..458437ae9 100644
--- a/scripts/convert.py
+++ b/scripts/convert.py
@@ -43,6 +43,7 @@ class ConvertVars:
def check_fix_file_extension(filename: str, file_type: str) -> str:
+ """Fix file extension if missing or incorrect."""
if filename and not filename.endswith(file_type):
filename_split = os.path.splitext(filename)
if filename_split[1].strip(".").isnumeric():
@@ -54,6 +55,7 @@ def check_fix_file_extension(filename: str, file_type: str) -> str:
def check_make_list_into_text(var: List[str]) -> str:
+ """Convert list to comma-separated text string."""
if not isinstance(var, list):
return str(var)
var = group_number_ranges(var)
@@ -86,7 +88,7 @@ def _validate_file_paths(source_filename: str, output_pdf_filename: str) -> Tupl
return True, source_path, output_dir
-def _safe_extractall(archive: zipfile.ZipFile, target_dir: str) -> None:
+def _safe_extract_all(archive: zipfile.ZipFile, target_dir: str) -> None:
"""Extract zip members only if their resolved paths stay within target_dir.
Prevents Zip Slip / path traversal (CWE-22) by resolving symlinks and all
@@ -122,6 +124,7 @@ def _validate_command_args(cmd_args: List[str]) -> bool:
def _convert_with_libreoffice(source_filename: str, output_pdf_filename: str) -> bool:
+ """Convert document to PDF using LibreOffice."""
libreoffice_bin = shutil.which("libreoffice") or shutil.which("soffice")
if not libreoffice_bin and platform.system() == "Windows":
potential_soffice = Path("C:/Program Files/LibreOffice/program/soffice.exe")
@@ -175,6 +178,7 @@ def _convert_with_libreoffice(source_filename: str, output_pdf_filename: str) ->
def _convert_with_docx2pdf(source_filename: str, output_pdf_filename: str) -> bool:
+ """Convert DOCX to PDF using docx2pdf library."""
if source_filename.endswith(".docx") and convert_vars.can_convert_to_pdf:
try:
import docx2pdf # type: ignore
@@ -188,6 +192,7 @@ def _convert_with_docx2pdf(source_filename: str, output_pdf_filename: str) -> bo
def _handle_conversion_failure(source_filename: str) -> None:
+ """Handle conversion failure with appropriate error message."""
error_msg = (
f"Error. A temporary file {source_filename} was created in the output folder but cannot be converted "
f"to pdf on operating system: {platform.system()}.\n"
@@ -202,6 +207,7 @@ def _handle_conversion_failure(source_filename: str) -> None:
def _cleanup_temp_file(filename: str) -> None:
+ """Remove temporary file if not in debug mode."""
if not convert_vars.args.debug:
try:
os.remove(filename)
@@ -210,6 +216,7 @@ def _cleanup_temp_file(filename: str) -> None:
def _rename_libreoffice_output(source_filename: str, output_pdf_filename: str) -> None:
+ """Rename LibreOffice output file to match expected filename."""
# LibreOffice outputs to the same name as source but with .pdf
default_out = str(Path(source_filename).with_suffix(".pdf"))
if os.path.normpath(default_out) != os.path.normpath(output_pdf_filename):
@@ -248,6 +255,7 @@ def convert_to_pdf(source_filename: str, output_pdf_filename: str) -> None:
def create_edition_from_template(
layout: str, language: str = "en", template: str = "bridge", version: str = "3.0", edition: str = "webapp"
) -> None:
+ """Create edition from template with specified parameters."""
# Get the list of available translation files
yaml_files = get_files_from_of_type(os.sep.join([convert_vars.BASE_PATH, "source"]), "yaml")
@@ -309,6 +317,7 @@ def create_edition_from_template(
def valid_meta(meta: Dict[str, Any], language: str, edition: str, version: str, template: str, layout: str) -> bool:
+ """Validate metadata for edition, language, template, and layout."""
if not has_translation_for_edition(meta, language):
logging.warning(
f"Translation in {language} does not exist for edition: {edition}, version: {version} "
@@ -333,18 +342,21 @@ def valid_meta(meta: Dict[str, Any], language: str, edition: str, version: str,
def has_translation_for_edition(meta: Dict[str, Any], language: str) -> bool:
+ """Check if translation exists for the given language."""
if meta and "languages" in meta and language in meta["languages"]:
return True
return False
def has_template_for_edition(meta: Dict[str, Any], template: str) -> bool:
+ """Check if template exists for the given edition."""
if meta and "templates" in meta and template in meta["templates"]:
return True
return False
def has_layout_for_edition(meta: Dict[str, Any], layout: str) -> bool:
+ """Check if layout exists for the given edition."""
if meta and "layouts" in meta and layout in meta["layouts"]:
return True
return False
@@ -357,6 +369,7 @@ def ensure_folder_exists(folder_path: str) -> None:
def main() -> None:
+ """Main entry point for the conversion tool."""
convert_vars.args = parse_arguments(sys.argv[1:])
set_logging()
logging.debug(" --- args = %s", str(convert_vars.args))
@@ -518,19 +531,21 @@ def parse_arguments(input_args: List[str]) -> argparse.Namespace:
def is_valid_string_argument(argument: str) -> str:
+ """Validate string argument length and character set."""
if len(argument) > 255:
- raise argparse.ArgumentTypeError("The option can not have more the 255 char.")
+ raise argparse.ArgumentError(None, "The option can not have more the 255 char.")
if not re.match(
r"^[\u0600-\u06FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\uFDF2\uFDF3\uFDF4\uFDFD\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF66-\uFF9Fー々〆〤\u3400-\u4DBF\uF900-\uFAFF\u0900-\u097F\u0621-\u064A\u0660-\u0669\u4E00-\u9FFF\u0E00-\u0E7F«»฿ฯ๏๚๛\u0400-\u04FF\u0500-\u052F\u2DE0-\u2DFF\uA640-\uA69FЮ́ю́Я́я́\u0370-\u03FF\u1F00-\u1FFFA-Za-zÀ-ÖØ-öø-ÿĀ-ž0-9._\--ءآأؤإئابةتثجحخدذرزسشصضطظعغفقكلمنهوي ًٌٍَُِّْٰﷲﷴﷺﷻ ٠١٢٣٤٥٦٧٨٩ \s]+$", # noqa: E501
argument,
):
- raise argparse.ArgumentTypeError(
- "The option can only contain a-z letters, numbers, periods, dash or underscore"
+ raise argparse.ArgumentError(
+ None, "The option can only contain a-z letters, numbers, periods, dash or underscore"
)
return argument
def is_valid_argument_list(arguments: List[str]) -> Any:
+ """Validate list of string arguments."""
if not isinstance(arguments, List):
return arguments
for argument in arguments:
@@ -539,6 +554,7 @@ def is_valid_argument_list(arguments: List[str]) -> Any:
def get_document_paragraphs(doc: Any) -> List[Any]:
+ """Get all paragraphs from document including tables."""
paragraphs = list(doc.paragraphs)
l1 = len(paragraphs)
for table in doc.tables:
@@ -578,6 +594,7 @@ def get_files_from_of_type(path: str, ext: str) -> List[str]:
def get_find_replace_list(meta: Dict[str, str], template: str, layout: str) -> List[Tuple[str, str]]:
+ """Get list of find/replace pairs for template substitution."""
ll: List[Tuple[str, str]] = [
("_edition", "_" + meta["edition"].lower()),
("_layout", "_" + layout.lower()),
@@ -589,6 +606,7 @@ def get_find_replace_list(meta: Dict[str, str], template: str, layout: str) -> L
def get_full_tag(cat_id: str, id: str, tag: str) -> str:
+ """Generate full tag string for template substitution."""
if cat_id == "Common":
full_tag = "${{{}}}".format("_".join([cat_id, id]))
else:
@@ -599,6 +617,7 @@ def get_full_tag(cat_id: str, id: str, tag: str) -> str:
def get_mapping_for_edition(
yaml_files: List[str], version: str, language: str, edition: str, template: str, layout: str
) -> Dict[str, Any]:
+ """Get mapping data for the specified edition."""
mapping_data: Dict[str, Any] = get_mapping_data_for_edition(yaml_files, language, version, edition)
if not mapping_data:
logging.warning("No mapping file found")
@@ -682,6 +701,7 @@ def build_template_dict(input_data: Dict[str, Any]) -> Dict[str, Any]:
def get_meta_data(data: Dict[str, Any]) -> Dict[str, Any]:
+ """Extract and validate metadata from language data."""
meta: Dict[str, Any] = {}
if not data or "meta" not in data:
logging.error("Could not find meta tag in the language data.")
@@ -707,6 +727,7 @@ def get_meta_data(data: Dict[str, Any]) -> Dict[str, Any]:
def get_paragraphs_from_table_in_doc(doc_table: Any) -> List[Any]:
+ """Extract paragraphs from document table."""
paragraphs: List[Any] = []
for row in doc_table.rows:
for cell in row.cells:
@@ -749,6 +770,7 @@ def get_language_data(
def is_mapping_file_for_version(path: str, version: str, edition: str) -> bool:
+ """Check if file is a mapping file for the specified version and edition."""
return (
os.path.basename(path).find("mappings") >= 0
and os.path.basename(path).find(edition) >= 0
@@ -757,6 +779,7 @@ def is_mapping_file_for_version(path: str, version: str, edition: str) -> bool:
def is_lang_file_for_version(path: str, version: str, lang: str, edition: str) -> bool:
+ """Check if file is a language file for the specified version, language, and edition."""
filename = os.path.basename(path).lower()
# Support both -en. and -en_US. style
lang_patterns = ["-" + lang.lower() + ".", "-" + lang.lower().replace("-", "_") + "."]
@@ -771,10 +794,12 @@ def is_lang_file_for_version(path: str, version: str, lang: str, edition: str) -
def is_yaml_file(path: str) -> bool:
+ """Check if file has YAML extension."""
return os.path.splitext(path)[1] in (".yaml", ".yml")
def map_language_data_to_template(input_data: Dict[str, Any]) -> Dict[str, str]:
+ """Map language data to template format."""
try:
data = build_template_dict(input_data)
except Exception as e:
@@ -792,6 +817,7 @@ def map_language_data_to_template(input_data: Dict[str, Any]) -> Dict[str, str]:
def get_replacement_mapping_value(k: str, v: str, el_text: str) -> str:
+ """Get replacement value for mapping."""
reg_str: str = (
"^(OWASP MASTG|OWASP MASVS|OWASP SCP|OWASP ASVS|OWASP AppSensor|CAPEC™|SAFECODE)\u2028"
+ k.replace("$", "\\$").strip()
@@ -806,6 +832,7 @@ def get_replacement_mapping_value(k: str, v: str, el_text: str) -> str:
def get_replacement_value_from_dict(el_text: str, replacement_values: List[Tuple[str, str]]) -> str:
+ """Get replacement value from dictionary."""
# Fast path: if no $ and no OWASP, likely no tags
if "$" not in el_text and "OWASP" not in el_text:
return el_text
@@ -824,6 +851,7 @@ def get_replacement_value_from_dict(el_text: str, replacement_values: List[Tuple
def get_suit_tags_and_key(key: str, edition: str) -> Tuple[List[str], str]:
+ """Get suit tags and key for the specified edition."""
# Short tags to match the suits in the template documents
suit_tags: List[str] = []
suit_key: str = ""
@@ -840,6 +868,7 @@ def get_suit_tags_and_key(key: str, edition: str) -> Tuple[List[str], str]:
def get_template_for_edition(layout: str = "guide", template: str = "bridge", edition: str = "webapp") -> str:
+ """Get template document for the specified edition."""
template_doc: str
args_input_file: str = convert_vars.args.inputfile
sfile_ext = "idml"
@@ -890,6 +919,7 @@ def get_template_for_edition(layout: str = "guide", template: str = "bridge", ed
def get_valid_layout_choices() -> List[str]:
+ """Get valid layout choices based on arguments."""
layouts = []
if convert_vars.args.layout.lower() == "all" or convert_vars.args.layout == "":
for layout in convert_vars.LAYOUT_CHOICES:
@@ -903,6 +933,7 @@ def get_valid_layout_choices() -> List[str]:
def get_valid_language_choices() -> List[str]:
+ """Get valid language choices based on arguments."""
languages = []
if convert_vars.args.language.lower() == "all":
for language in convert_vars.LANGUAGE_CHOICES:
@@ -916,6 +947,7 @@ def get_valid_language_choices() -> List[str]:
def get_valid_version_choices() -> List[str]:
+ """Get valid version choices based on arguments."""
versions = []
edition: str = convert_vars.args.edition.lower()
if convert_vars.args.version.lower() == "all":
@@ -935,10 +967,12 @@ def get_valid_version_choices() -> List[str]:
def get_valid_mapping_for_version(version: str, edition: str) -> str:
+ """Get valid mapping version for the specified edition."""
return ConvertVars.EDITION_VERSION_MAP.get(edition, {}).get(version, "")
def get_valid_templates() -> List[str]:
+ """Get valid template choices based on arguments."""
templates = []
if convert_vars.args.template.lower() == "all":
for template in [t for t in convert_vars.TEMPLATE_CHOICES if t not in "all"]:
@@ -952,6 +986,7 @@ def get_valid_templates() -> List[str]:
def get_valid_edition_choices() -> List[str]:
+ """Get valid edition choices based on arguments."""
editions = []
if convert_vars.args.edition.lower() == "all" or not convert_vars.args.edition.lower():
for edition in convert_vars.EDITION_CHOICES:
@@ -963,6 +998,7 @@ def get_valid_edition_choices() -> List[str]:
def group_number_ranges(data: List[str]) -> List[str]:
+ """Group consecutive numbers into ranges."""
if len(data) < 2 or len([s for s in data if not str(s).isnumeric()]):
return data
list_ranges: List[str] = []
@@ -978,11 +1014,13 @@ def group_number_ranges(data: List[str]) -> List[str]:
def save_docx_file(doc: Any, output_file: str) -> None:
+ """Save document to DOCX file."""
ensure_folder_exists(os.path.dirname(output_file))
doc.save(output_file)
def save_odt_file(template_doc: str, language_dict: Dict[str, str], output_file: str) -> None:
+ """Save ODT file with replaced text."""
# Get the output path and temp output path to put the temp xml files
output_path = os.path.join(convert_vars.BASE_PATH, "output")
temp_output_path = os.path.join(output_path, "temp_odt")
@@ -992,7 +1030,7 @@ def save_odt_file(template_doc: str, language_dict: Dict[str, str], output_file:
# Unzip source xml files and place in temp output folder
with zipfile.ZipFile(template_doc) as odt_archive:
- _safe_extractall(odt_archive, temp_output_path)
+ _safe_extract_all(odt_archive, temp_output_path)
# ODT text is usually in content.xml and sometimes styles.xml
targets = ["content.xml", "styles.xml"]
@@ -1013,6 +1051,7 @@ def save_odt_file(template_doc: str, language_dict: Dict[str, str], output_file:
def save_idml_file(template_doc: str, language_dict: Dict[str, str], output_file: str) -> None:
+ """Save IDML file with replaced text."""
# Get the output path and temp output path to put the temp xml files
output_path = convert_vars.BASE_PATH + os.sep + "output"
temp_output_path = output_path + os.sep + "temp"
@@ -1022,7 +1061,7 @@ def save_idml_file(template_doc: str, language_dict: Dict[str, str], output_file
# Unzip source xml files and place in temp output folder
with zipfile.ZipFile(template_doc) as idml_archive:
- _safe_extractall(idml_archive, temp_output_path)
+ _safe_extract_all(idml_archive, temp_output_path)
logging.debug(" --- namelist of first few files in archive = %s", str(idml_archive.namelist()[:5]))
xml_files = get_files_from_of_type(temp_output_path, "xml")
@@ -1043,6 +1082,7 @@ def save_idml_file(template_doc: str, language_dict: Dict[str, str], output_file
def set_can_convert_to_pdf() -> bool:
+ """Set PDF conversion capability based on operating system."""
operating_system: str = sys.platform.lower()
can_convert = operating_system.find("win") != -1 or operating_system.find("darwin") != -1
convert_vars.can_convert_to_pdf = can_convert
@@ -1051,6 +1091,7 @@ def set_can_convert_to_pdf() -> bool:
def set_logging() -> None:
+ """Set up logging configuration."""
logging.basicConfig(
format="%(asctime)s %(filename)s | %(levelname)s | %(funcName)s | %(message)s",
)
@@ -1061,11 +1102,13 @@ def set_logging() -> None:
def sort_keys_longest_to_shortest(replacement_dict: Dict[str, str]) -> List[Tuple[str, str]]:
+ """Sort replacement dictionary keys by length, longest first."""
new_list = list((k, v) for k, v in replacement_dict.items())
return sorted(new_list, key=lambda s: len(s[0]), reverse=True)
def remove_short_keys(replacement_dict: Dict[str, str], min_length: int = 8) -> Dict[str, str]:
+ """Remove keys shorter than minimum length."""
data2: Dict[str, str] = {}
for key, value in replacement_dict.items():
if len(key) >= min_length:
@@ -1152,6 +1195,7 @@ def _find_xml_elements(tree: Any) -> List[ElTree.Element]:
def replace_text_in_xml_file(filename: str, replacement_values: List[Tuple[str, str]]) -> None:
+ """Replace text in XML file."""
logging.debug(f" --- starting xml_replace for {filename}")
try:
tree = DefusedElTree.parse(filename)
diff --git a/scripts/convert_asvs.py b/scripts/convert_asvs.py
index eff5868fd..637dc51ed 100644
--- a/scripts/convert_asvs.py
+++ b/scripts/convert_asvs.py
@@ -27,6 +27,7 @@ class ConvertVars:
def create_level_summary(level: int, arr: List[dict[str, Any]]) -> None:
+ """Create summary page for a specific ASVS level."""
topic = ""
category = ""
os.mkdir(Path(convert_vars.args.output_path, f"level-{level}-controls"))
@@ -205,6 +206,7 @@ def create_link_list(requirements: dict[str, Any], capec_version: str) -> str:
def parse_arguments(input_args: list[str]) -> argparse.Namespace:
+ """Parse command line arguments for ASVS conversion."""
parser = argparse.ArgumentParser(description="Convert CAPEC™ JSON to Cornucopia format")
parser.add_argument(
"-o",
@@ -256,6 +258,7 @@ def parse_arguments(input_args: list[str]) -> argparse.Namespace:
def get_valid_capec_version(version: str) -> str:
+ """Get valid CAPEC version from choices."""
for v in ConvertVars.LATEST_CAPEC_VERSION_CHOICES:
if version == v:
return version
@@ -263,6 +266,7 @@ def get_valid_capec_version(version: str) -> str:
def get_valid_asvs_version(version: str) -> str:
+ """Get valid ASVS version from choices."""
for v in ConvertVars.LATEST_ASVS_VERSION_CHOICES:
if version == v:
return version
@@ -270,6 +274,7 @@ def get_valid_asvs_version(version: str) -> str:
def set_logging() -> None:
+ """Set up logging configuration."""
logging.basicConfig(
format="%(asctime)s %(filename)s | %(levelname)s | %(funcName)s | %(message)s",
)
@@ -280,6 +285,7 @@ def set_logging() -> None:
def empty_folder(path: Path) -> None:
+ """Empty the specified folder."""
try:
# Empty the folder
shutil.rmtree(path)
@@ -288,6 +294,7 @@ def empty_folder(path: Path) -> None:
def create_folder(path: Path) -> None:
+ """Create folder if it doesn't exist."""
try:
os.makedirs(path, exist_ok=True)
except Exception as e:
@@ -295,6 +302,7 @@ def create_folder(path: Path) -> None:
def load_json_file(filepath: Path) -> dict[str, Any]:
+ """Load JSON file and return data."""
try:
with open(filepath, encoding="utf8") as f:
data: dict[str, Any] = json.load(f)
@@ -305,6 +313,7 @@ def load_json_file(filepath: Path) -> dict[str, Any]:
def load_asvs_to_capec_mapping(filepath: Path) -> dict[str, dict[str, List[str]]]:
+ """Load ASVS to CAPEC mapping from YAML file."""
data: dict[str, dict[str, List[str]]] = {}
try:
with open(filepath, "r", encoding="utf-8") as f:
@@ -317,6 +326,7 @@ def load_asvs_to_capec_mapping(filepath: Path) -> dict[str, dict[str, List[str]]
def main() -> None:
+ """Main entry point for ASVS conversion."""
convert_vars.args = parse_arguments(sys.argv[1:])
asvs_version = get_valid_asvs_version(convert_vars.args.asvs_version)
capec_version = get_valid_capec_version(convert_vars.args.capec_version)
diff --git a/scripts/convert_capec.py b/scripts/convert_capec.py
index a2fd1f2fa..aa85b85c9 100644
--- a/scripts/convert_capec.py
+++ b/scripts/convert_capec.py
@@ -203,6 +203,7 @@ def load_capec_to_asvs_mapping(filepath: Path) -> dict[int, dict[str, List[str]]
def parse_arguments(input_args: list[str]) -> argparse.Namespace:
+ """Parse command line arguments for CAPEC conversion."""
parser = argparse.ArgumentParser(description="Convert CAPEC JSON to Cornucopia format")
parser.add_argument(
"-o",
@@ -255,6 +256,7 @@ def parse_arguments(input_args: list[str]) -> argparse.Namespace:
def get_valid_version(version: str) -> str:
+ """Get valid version from choices."""
for v in ConvertVars.LATEST_ASVS_VERSION_CHOICES:
if version == v:
return version
@@ -262,7 +264,7 @@ def get_valid_version(version: str) -> str:
def main() -> None:
-
+ """Main entry point for CAPEC conversion."""
convert_vars.args = parse_arguments(sys.argv[1:])
asvs_version = get_valid_version(convert_vars.args.asvs_version)
logging.debug("Using ASVS version: %s", asvs_version)
diff --git a/tests/scripts/convert_utest.py b/tests/scripts/convert_utest.py
index de2614c85..d31af7836 100644
--- a/tests/scripts/convert_utest.py
+++ b/tests/scripts/convert_utest.py
@@ -2176,7 +2176,7 @@ def test_blocks_parent_directory_traversal(self) -> None:
with tempfile.TemporaryDirectory() as td:
with zipfile.ZipFile(buf) as zf:
with self.assertRaises(ValueError) as ctx:
- c._safe_extractall(zf, td)
+ c._safe_extract_all(zf, td)
self.assertIn("Zip Slip blocked", str(ctx.exception))
def test_blocks_absolute_path_member(self) -> None:
@@ -2185,14 +2185,14 @@ def test_blocks_absolute_path_member(self) -> None:
with tempfile.TemporaryDirectory() as td:
with zipfile.ZipFile(buf) as zf:
with self.assertRaises(ValueError):
- c._safe_extractall(zf, td)
+ c._safe_extract_all(zf, td)
def test_allows_legitimate_nested_members(self) -> None:
"""Normal nested paths must extract correctly."""
buf = self._build_zip({"content.xml": "", "subdir/file.xml": ""})
with tempfile.TemporaryDirectory() as td:
with zipfile.ZipFile(buf) as zf:
- c._safe_extractall(zf, td)
+ c._safe_extract_all(zf, td)
self.assertTrue(os.path.isfile(os.path.join(td, "content.xml")))
self.assertTrue(os.path.isfile(os.path.join(td, "subdir", "file.xml")))
@@ -2207,7 +2207,7 @@ def test_skips_root_dot_entry(self) -> None:
buf.seek(0)
with tempfile.TemporaryDirectory() as td:
with zipfile.ZipFile(buf) as zf:
- c._safe_extractall(zf, td)
+ c._safe_extract_all(zf, td)
self.assertTrue(os.path.isfile(os.path.join(td, "content.xml")))
diff --git a/translation_check_report.md b/translation_check_report.md
new file mode 100644
index 000000000..3faa83ac1
--- /dev/null
+++ b/translation_check_report.md
@@ -0,0 +1,176 @@
+# Translation Check Report
+
+The following sentences/tags have issues in the translations:
+
+
+## Spanish
+
+**File:** `eop-cards-5.0-es.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00105, T00140
+
+
+## Russian
+
+**File:** `eop-cards-5.0-ru.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00001, T00003, T00004, T00090, T00100, T00105, T00210, T00220, T00230, T00240, T00250, T00260, T00270, T00280, T00290, T00300, T00310, T00320, T00330, T00340, T00350, T00360, T00370, T00380, T00390, T00400, T00410, T00420, T00430, T00440, T00450, T00460, T00470, T00480
+
+
+## Russian
+
+**File:** `mobileapp-cards-1.0-ru.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00020, T00120, T00130, T00220, T00240, T00310, T00311, T00320, T00330, T00340, T00350, T00360, T00370, T00380, T00390, T00400, T00510, T00520, T00530, T00610, T01010, T01070, T01160, T01170, T01180, T01200, T01210, T01220, T01301, T01411, T02680, T02690, T02700, T02710, T02720, T02730, T02780, T03010
+
+
+## Russian
+
+**File:** `mobileapp-cards-1.1-ru.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00020, T00120, T00130, T00220, T00240, T00310, T00311, T00320, T00330, T00340, T00350, T00360, T00370, T00380, T00390, T00400, T00510, T00520, T00530, T00610, T01010, T01070, T01160, T01170, T01180, T01200, T01210, T01220, T01301, T01411, T02680, T02690, T02700, T02710, T02720, T02730, T02780, T03010
+
+
+## Spanish
+
+**File:** `webapp-cards-2.2-es.yaml`
+
+### Missing Tags
+
+The following tags are present in the English version but missing in this translation:
+
+T01411
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00020, T00030, T00380, T01590, T02330, T02530, T02940, T03140, T03160, T03180, T03200, T03210, T03220, T03230, T03240, T03250, T03260, T03270, T03280, T03290, T03300, T03310, T03320, T03330, T03340, T03350, T03360, T03370, T03380, T03390, T03400, T03410, T03420, T03430, T03440, T03450, T03460, T03470, T03480, T03490, T03500, T03510, T03520, T03530, T03540, T03550, T03560, T03570, T03580, T03590, T03600, T03610, T03620, T03630, T03640, T03650, T03660, T03670, T03680, T03690, T03700, T03710, T03720, T03730, T03750, T03770, T03771, T03772, T03773, T03774, T03775, T03800, T03810, T03820, T03830, T03840, T03850, T03860, T03870, T03900, T03940, T03950
+
+
+## French
+
+**File:** `webapp-cards-2.2-fr.yaml`
+
+### Missing Tags
+
+The following tags are present in the English version but missing in this translation:
+
+T01411
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00200, T01100, T02330, T02530, T03110, T03120, T03130, T03150, T03170, T03190, T03240, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03771, T03772, T03773, T03774, T03775
+
+
+## Hungarian
+
+**File:** `webapp-cards-2.2-hu.yaml`
+
+### Missing Tags
+
+The following tags are present in the English version but missing in this translation:
+
+T00005, T00161, T00162, T01301, T01311, T01411
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00020, T00030, T00140, T00145, T00200, T00210, T00220, T00230, T00240, T00300, T00320, T00340, T00350, T00360, T00370, T00380, T00390, T00400, T00500, T00510, T00520, T00600, T00610, T00700, T00710, T00720, T00730, T00740, T00750, T00760, T00770, T00780, T00790, T00800, T00810, T00830, T00840, T00900, T00910, T00920, T01000, T01020, T01060, T01100, T01110, T01120, T01130, T01140, T01150, T01160, T01170, T01190, T01200, T01240, T01250, T01260, T01270, T01280, T01290, T01300, T01400, T01410, T01420, T01430, T01431, T01440, T01450, T01500, T01510, T01520, T01530, T01540, T01550, T01560, T01570, T01571, T01580, T01590, T01600, T01610, T01700, T01710, T01720, T01730, T01740, T01800, T01810, T01811, T01820, T01900, T01910, T01920, T01930, T01940, T01960, T01970, T01980, T02000, T02010, T02020, T02030, T02040, T02100, T02120, T02140, T02200, T02220, T02240, T02250, T02260, T02280, T02290, T02300, T02310, T02320, T02330, T02340, T02400, T02410, T02420, T02440, T02450, T02460, T02480, T02490, T02500, T02510, T02520, T02530, T02540, T02600, T02610, T02620, T02630, T02650, T02680, T02690, T02700, T02710, T02720, T02730, T02760, T02770, T02790, T02800, T02810, T02820, T02840, T02850, T02860, T02870, T02880, T02890, T02900, T02910, T02920, T02930, T02940, T02950, T02960, T02970, T02980, T02990, T03000, T03020, T03100, T03110, T03120, T03130, T03140, T03150, T03160, T03170, T03190, T03200, T03210, T03220, T03230, T03240, T03250, T03260, T03270, T03280, T03290, T03300, T03310, T03320, T03330, T03340, T03350, T03360, T03370, T03380, T03390, T03400, T03410, T03420, T03430, T03450, T03460, T03470, T03480, T03490, T03500, T03510, T03520, T03530, T03540, T03550, T03560, T03570, T03580, T03590, T03600, T03610, T03620, T03630, T03640, T03650, T03660, T03670, T03680, T03690, T03700, T03710, T03720, T03730, T03740, T03750, T03760, T03770, T03771, T03772, T03773, T03774, T03775, T03800, T03810, T03820, T03830, T03840, T03900, T03920, T03950
+
+
+## Italian
+
+**File:** `webapp-cards-2.2-it.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00380, T02330, T02530, T02940, T03130, T03150, T03170, T03190, T03240, T03250, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03771, T03772, T03773, T03774, T03775
+
+
+## Dutch
+
+**File:** `webapp-cards-2.2-nl.yaml`
+
+### Missing Tags
+
+The following tags are present in the English version but missing in this translation:
+
+T01411
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00500, T03130, T03150, T03170, T03190, T03240, T03260, T03350, T03420, T03470, T03540, T03580, T03710, T03730, T03750, T03770, T03771, T03772, T03773, T03774, T03775
+
+
+## Russian
+
+**File:** `webapp-cards-2.2-ru.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00380, T01411, T02330, T02530, T03130, T03150, T03170, T03190, T03240, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03771, T03772, T03773, T03774, T03775
+
+
+## hi
+
+**File:** `webapp-cards-3.0-hi.yaml`
+
+### Missing Tags
+
+The following tags are present in the English version but missing in this translation:
+
+T03390, T03400, T03410, T03560, T03570, T03610, T03620, T03630, T03640, T03650, T03670, T03680, T03690, T03700
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T03130, T03150, T03170, T03190, T03240, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03772, T03774
+
+
+## Dutch
+
+**File:** `webapp-cards-3.0-nl.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00380, T02270, T02290, T02330, T02530, T03130, T03150, T03170, T03190, T03240, T03250, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03772, T03774
+
+
+## Russian
+
+**File:** `webapp-cards-3.0-ru.yaml`
+
+### Untranslated Tags
+
+The following tags have identical text to English (not translated):
+
+T00380, T02330, T02530, T03130, T03150, T03170, T03190, T03240, T03260, T03350, T03420, T03470, T03490, T03540, T03580, T03710, T03730, T03750, T03770, T03772, T03774