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