diff --git a/main.py b/main.py
deleted file mode 100644
index a7085b42c..000000000
--- a/main.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""
-These python scripts follow PEP 8 guidelines with the exception that the maximum line length is extended
-to 120 characters.
-All other PEP 8 conventions apply, such as naming conventions, indentation, imports, and documentation strings.
-The project uses the Black formatter for consistent code style and Flake8 for linting and style checks.
-"""
-
-import argparse
-import os
-import subprocess
-import sys
-
-# ANSI escape codes for colors
-GREEN = "\033[92m"
-RESET = "\033[0m"
-
-
-def run_command(command, cwd=None):
- print(f"{GREEN}Running command:{RESET} {' '.join(command)}")
- result = subprocess.run(command, capture_output=True, text=True, cwd=cwd)
- if result.stdout:
- print(result.stdout)
- if result.stderr:
- print(f"Error: {result.stderr}")
- return result.returncode
-
-
-def git_operations(rvx_dir):
- """
- Perform git operations (checkout, fetch, pull) in the specified directory.
- """
- if run_command(["git", "switch", "dev"], cwd=rvx_dir) != 0:
- print("Error during git checkout")
- return False
- if run_command(["git", "fetch"], cwd=rvx_dir) != 0:
- print("Error during git fetch")
- return False
- if run_command(["git", "pull"], cwd=rvx_dir) != 0:
- print("Error during git pull")
- return False
- return True
-
-
-def main():
- parser = argparse.ArgumentParser(description="Run various string processing scripts.")
-
- parser.add_argument("-a", "--all", action="store_true", help="Run all commands in order.")
- parser.add_argument(
- "-m",
- "--missing",
- action="store_true",
- help="Run missing_strings.py.",
- )
- parser.add_argument(
- "-r",
- "--replace",
- action="store_true",
- help="Run replace_strings.py.",
- )
- parser.add_argument(
- "--remove",
- action="store_true",
- help="Run remove_unused_strings.py.",
- )
- parser.add_argument("-s", "--sort", action="store_true", help="Run sort_strings.py.")
- parser.add_argument("-p", "--prefs", action="store_true", help="Run missing_prefs.py.")
-
- parser.add_argument(
- "--youtube",
- action="store_true",
- help="Specify the --youtube argument for replace and sort commands.",
- )
- parser.add_argument(
- "--music",
- action="store_true",
- help="Specify the --music argument for replace and sort commands.",
- )
- parser.add_argument(
- "--rvx-base-dir",
- type=str,
- help="Specify the base directory of RVX patches operations.",
- )
-
- args = parser.parse_args()
-
- # Retrieve the rvx_base_dir from environment variables if not provided as
- # an argument
- rvx_base_dir = args.rvx_base_dir or os.getenv("RVX_BASE_DIR")
-
- if not rvx_base_dir:
- raise ValueError(
- "rvx_base_dir must be specified either as an argument or through \
- the RVX_BASE_DIR environment variable."
- )
-
- sub_arg = "--music" if args.music else "--youtube"
- rvx_base_dir_arg = f"--rvx-base-dir={rvx_base_dir}"
-
- commands = []
-
- if args.all:
- if git_operations(rvx_base_dir):
- commands = [
- [sys.executable, "src/utils/replace_strings.py", "--youtube", rvx_base_dir_arg],
- [sys.executable, "src/utils/replace_strings.py", "--music", rvx_base_dir_arg],
- [sys.executable, "src/utils/missing_strings.py", "--youtube"],
- [sys.executable, "src/utils/missing_strings.py", "--music"],
- [sys.executable, "src/utils/remove_unused_strings.py", "--youtube"],
- [sys.executable, "src/utils/remove_unused_strings.py", "--music"],
- [sys.executable, "src/utils/sort_strings.py", "--youtube"],
- [sys.executable, "src/utils/sort_strings.py", "--music"],
- ]
- else:
- if args.missing:
- commands.append([sys.executable, "src/utils/missing_strings.py", sub_arg])
- if args.prefs:
- commands.append([sys.executable, "src/utils/missing_prefs.py", rvx_base_dir_arg])
- if args.remove:
- commands.append([sys.executable, "src/utils/remove_unused_strings.py", sub_arg])
-
- if args.replace:
- if git_operations(rvx_base_dir):
- commands.append([sys.executable, "src/utils/replace_strings.py", sub_arg, rvx_base_dir_arg])
-
- if args.sort:
- commands.append([sys.executable, "src/utils/sort_strings.py", sub_arg])
-
- for command in commands:
- run_command(command)
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/utils/missing_prefs.py b/src/utils/missing_prefs.py
deleted file mode 100644
index ec7444d0e..000000000
--- a/src/utils/missing_prefs.py
+++ /dev/null
@@ -1,68 +0,0 @@
-import re
-import os
-import argparse
-
-
-def extract_keys(file_path):
- """
- Extract keys from the XML file.
-
- Args:
- file_path (str): The path to the XML file.
-
- Returns:
- set: A set of keys extracted from the file.
- """
- key_pattern = re.compile(r'android:key="(\w+)"') # Compile the regex pattern to match keys
- keys_found = set() # Use a set to store unique keys
-
- # Open the XML file and search for the keys
- with open(file_path, "r", encoding="utf-8") as file:
- for line in file:
- matches = key_pattern.findall(line) # Find all keys in the line
- keys_found.update(matches) # Add found keys to the set
-
- return keys_found
-
-
-def main():
- # Set up argument parser
- parser = argparse.ArgumentParser(description="Search for keys in XML files.")
- parser.add_argument(
- "--rvx-base-dir",
- type=str,
- required=True,
- help="Specify the base directory of RVX patches operations.",
- )
-
- # Parse the arguments
- args = parser.parse_args()
- base_dir = args.rvx_base_dir
-
- # Define the file paths based on the base directory provided
- prefs_file_1 = os.path.join(
- base_dir, "src/main/resources/youtube/settings/xml/revanced_prefs.xml"
- )
- prefs_file_2 = "src/main/resources/youtube/settings/xml/revanced_prefs.xml"
-
- # Check if files exist
- if not os.path.exists(prefs_file_1) or not os.path.exists(prefs_file_2):
- print("Error: One or both XML files are missing.")
- return
-
- # Extract keys from the first file
- keys_in_file_1 = extract_keys(prefs_file_1)
-
- # Extract keys from the second file
- keys_in_file_2 = extract_keys(prefs_file_2)
-
- # Find keys that are in the first file but not in the second
- keys_not_in_file_2 = keys_in_file_1 - keys_in_file_2
-
- # Print the keys not found in the second file
- for key in keys_not_in_file_2:
- print(key)
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/utils/missing_strings.py b/src/utils/missing_strings.py
deleted file mode 100644
index 245096727..000000000
--- a/src/utils/missing_strings.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import os
-import xml.etree.ElementTree as ET
-import xml.sax.saxutils as saxutils
-from utils import Utils
-
-
-def get_strings_dict(root):
- """
- Extract strings from the XML root and return them as a dictionary.
-
- :param root: Root element of the XML tree.
- :return: Dictionary with name attributes as keys and text as values.
- """
- strings_dict = {}
- for string in root.findall("string"):
- name = string.get("name")
- text = string.text
- strings_dict[name] = text
- return strings_dict
-
-
-def ensure_directory_exists(directory):
- """
- Ensure that the directory exists. If it does not, create it.
-
- :param directory: Path to the directory.
- """
- if not os.path.exists(directory):
- os.makedirs(directory)
-
-
-def read_missing_strings(missing_file_path):
- """
- Read the missing strings from the missing_strings.xml file and return them
- as a dictionary.
-
- :param missing_file_path: Path to the missing_strings.xml file.
- :return: Dictionary of missing strings.
- """
- if os.path.exists(missing_file_path):
- try:
- _, _, missing_root = Utils.parse_xml(missing_file_path)
- return get_strings_dict(missing_root)
- except ET.ParseError as e:
- print(f"Error parsing {missing_file_path}: {e}")
- return {}
- return {}
-
-
-def escape_xml_chars(text):
- return saxutils.escape(text)
-
-
-def write_missing_strings(missing_file_path, missing_strings):
- """
- Write the missing strings to the missing_strings.xml file.
-
- :param missing_file_path: Path to the missing_strings.xml file.
- :param missing_strings: Dictionary of missing strings to write.
- """
- ensure_directory_exists(os.path.dirname(missing_file_path))
- with open(missing_file_path, "w", encoding="utf-8") as f:
- f.write("\n\n")
- for name, text in missing_strings.items():
- f.write(f' {escape_xml_chars(text)}\n')
- f.write("\n")
-
-
-def compare_and_update_missing_file(source_dict, dest_file_path, missing_file_path):
- """
- Compare source strings with destination strings and update
- missing_strings.xml accordingly.
-
- :param source_dict: Dictionary of source strings.
- :param dest_file_path: Path to the destination XML file.
- :param missing_file_path: Path to the missing_strings.xml file.
- """
- if os.path.exists(dest_file_path):
- try:
- _, _, dest_root = Utils.parse_xml(dest_file_path)
- dest_dict = get_strings_dict(dest_root)
- except ET.ParseError as e:
- print(f"Error parsing {dest_file_path}: {e}")
- dest_dict = {}
- else:
- dest_dict = {}
-
- # Read existing missing strings
- missing_strings = read_missing_strings(missing_file_path)
-
- # Update missing strings based on comparison with destination strings
- for name, text in source_dict.items():
- if name in dest_dict:
- if name in missing_strings:
- del missing_strings[name]
- else:
- missing_strings[name] = text
-
- # Write updated missing strings back to the file if not empty,
- # otherwise delete the file
- if missing_strings:
- write_missing_strings(missing_file_path, missing_strings)
- elif os.path.exists(missing_file_path):
- print(f"Deleting empty missing strings file: {missing_file_path}")
- os.remove(missing_file_path)
-
-
-def main():
- """
- Main function to handle the XML parsing, comparison, and updating process.
- """
- # Get the directories based on the user selection (YouTube or Music)
- args = Utils.get_arguments()
- source_file = args["source_file"]
- destination_directory = args["destination_directory"]
-
- try:
- _, _, source_root = Utils.parse_xml(source_file)
- except (FileNotFoundError, ET.ParseError) as e:
- print(f"Error parsing source file {source_file}: {e}")
- return
-
- source_dict = get_strings_dict(source_root)
-
- for dirpath, dirnames, filenames in os.walk(destination_directory):
- for dirname in dirnames:
- lang_dir = os.path.join(dirpath, dirname)
- dest_file_path = os.path.join(lang_dir, "strings.xml")
- missing_file_path = os.path.join(lang_dir, "missing_strings.xml")
- compare_and_update_missing_file(source_dict, dest_file_path, missing_file_path)
-
-
-if __name__ == "__main__":
- main()
diff --git a/src/utils/remove_unused_strings.py b/src/utils/remove_unused_strings.py
deleted file mode 100644
index c5750f4cf..000000000
--- a/src/utils/remove_unused_strings.py
+++ /dev/null
@@ -1,230 +0,0 @@
-import os
-from lxml import etree
-from utils import Utils
-
-# Constants for blacklisted and prefixed strings
-BLACKLISTED_STRINGS = (
- # YouTube
- "revanced_remember_video_quality_mobile",
- "revanced_remember_video_quality_wifi",
- # YouTube Music
- "revanced_sb_api_url_sum",
- "revanced_sb_enabled",
- "revanced_sb_enabled_sum",
- "revanced_sb_toast_on_skip",
- "revanced_sb_toast_on_skip_sum",
-)
-PREFIX_TO_IGNORE = (
- "revanced_icon_",
- "revanced_spoof_app_version_target_entry_",
- "revanced_spoof_streaming_data_side_effects_",
-)
-
-
-def get_base_name(name):
- """Return the base name by stripping '_title' or '_summary' suffix."""
- if name.endswith("_title"):
- return name[:-6]
- elif name.endswith("_summary"):
- return name[:-8]
- elif name.endswith("_summary_off"):
- return name[:-12]
- elif name.endswith("_summary_on"):
- return name[:-11]
- return name
-
-
-def search_in_files(directories, name_values):
- """
- Search for the values in all files with allowed extensions within the
- specified directories, excluding 'strings.xml' files. It also checks
- for the base string by stripping the '_title' and '_summary' suffixes.
-
- Args:
- directories (list): List of directories to search in.
- name_values (list): List of 'name' attribute values to search for.
-
- Returns:
- dict: Dictionary with 'name' values as keys and list of file paths
- where they were found as values.
- """
- results = {name: [] for name in name_values}
- allowed_extensions = (".kt", ".java", ".xml")
-
- for directory in directories:
- for root, dirs, files in os.walk(directory):
- # Ignore dot directories and the build directory
- dirs[:] = [d for d in dirs if not d.startswith(".") and d != "build"]
- for file in files:
- if file in ("strings.xml", "missing_strings.xml") or not file.endswith(allowed_extensions):
- continue
- file_path = os.path.join(root, file)
- try:
- with open(file_path, "r", encoding="utf-8") as f:
- content = f.read()
- for name in name_values:
- # Check if the name exists in the content first
- if name in content:
- results[name].append(file_path)
- else:
- # If not, then check the base name
- base_name = get_base_name(name)
- if base_name in content:
- results[name].append(file_path)
- except Exception as e:
- print(f"Error reading {file_path}: {e}")
-
- return results
-
-
-def should_remove(name, unused_names):
- """
- Determine whether a string with the given 'name' attribute should be
- removed. It checks both the original name and its base form without the
- '_title' or '_summary' suffix.
-
- Args:
- name (str): The value of the 'name' attribute.
- unused_names (list): List of 'name' attribute values that are not used.
-
- Returns:
- bool: True if the element should be removed, False otherwise.
- """
- base_name = get_base_name(name)
- return (
- (name in unused_names or base_name in unused_names)
- and name not in BLACKLISTED_STRINGS
- and not any(name.startswith(prefix) for prefix in PREFIX_TO_IGNORE)
- )
-
-
-def remove_unused_strings(xml_file_paths, unused_names):
- """
- Remove strings with unused 'name' attributes from the specified XML files
- and write the sorted strings back to the file.
-
- Args:
- xml_file_paths (list): List of paths to XML files.
- unused_names (list): List of 'name' attribute values that are not used.
- """
- for file_path in xml_file_paths:
- tree = etree.parse(file_path)
- root = tree.getroot()
-
- # Create a dictionary of strings to keep
- strings_dict = {}
- for element in root.findall(".//*[@name]"):
- name = element.get("name")
- if not should_remove(name, unused_names):
- strings_dict[name] = element.text
-
- # Write the sorted strings back to the file
- write_sorted_strings(file_path, strings_dict)
-
-
-def check_translation_files(main_xml_path, translation_files):
- """
- Check each translation file against the main XML file, remove strings
- that don't exist in the main XML file, and write the sorted strings back.
-
- Args:
- main_xml_path (str): Path to the main XML file.
- translation_files (list): List of paths to translation XML files.
- """
- main_tree = etree.parse(main_xml_path)
- main_root = main_tree.getroot()
- main_names = set(element.get("name") for element in main_root.findall(".//*[@name]"))
-
- for translation_file in translation_files:
- translation_tree = etree.parse(translation_file)
- translation_root = translation_tree.getroot()
-
- # Create a dictionary of strings to keep
- strings_dict = {}
- for element in translation_root.findall(".//*[@name]"):
- name = element.get("name")
- if name in main_names:
- strings_dict[name] = element.text
-
- # Write the sorted strings back to the file
- write_sorted_strings(translation_file, strings_dict)
-
-
-def write_sorted_strings(file_path, strings_dict):
- """
- Write the strings to the XML file sorted by their name attributes.
-
- Args:
- file_path (str): Path to the XML file.
- strings_dict (dict): Dictionary of strings to write.
- """
- ensure_directory_exists(os.path.dirname(file_path))
-
- # Create the root element and add strings sorted by name
- root = etree.Element("resources")
- for name in sorted(strings_dict.keys()):
- string_element = etree.Element("string", name=name)
- string_element.text = strings_dict[name]
- root.append(string_element)
-
- # Write the XML file with 4-space indentation
- tree = etree.ElementTree(root)
- xml_bytes = etree.tostring(tree, encoding="utf-8", pretty_print=True, xml_declaration=True)
-
- # Manually adjust the indentation to 4 spaces
- xml_string = xml_bytes.decode("utf-8").replace(" element
- new_string_elem = etree.Element("string", name=name)
- new_string_elem.text = text
- # Append it to the target root
- target_root.append(new_string_elem)
-
- # Save the updated XML content back to the target file
- save_xml_file(target_file, target_tree)
-
-
-def parse_xml_file(file_path):
- """
- Parses an XML file and returns the ElementTree object.
-
- Args:
- - file_path (str): Path to the XML file to parse.
-
- Returns:
- - etree.ElementTree: Parsed XML tree object.
- """
- parser = etree.XMLParser(remove_blank_text=True)
- tree = etree.parse(file_path, parser)
- return tree
-
-
-def save_xml_file(file_path, tree):
- """
- Saves the XML document to a file, preserving the XML declaration and
- indentation.
-
- Args:
- - file_path (str): Path to save the XML file.
- - tree (etree.ElementTree): XML tree object to save.
- """
- xml_declaration = "\n"
- xml_content = etree.tostring(
- tree, pretty_print=True, xml_declaration=False, encoding="unicode"
- )
-
- # Adjust the indentation to 4 spaces
- xml_content = xml_content.replace(" Path:
+ """
+ Get absolute path to a specific resource directory or file.
+
+ Args:
+ app (str): Application identifier (e.g., 'youtube' or 'music')
+ resource_type (str): Type of resource or path relative to the app directory
+ (e.g., 'settings/xml/prefs.xml')
+
+ Returns:
+ Path: Absolute path to the requested resource
+
+ Example:
+ >>> settings = Settings()
+ >>> path = settings.get_resource_path('youtube', 'settings/xml/prefs.xml')
+ >>> str(path)
+ '/absolute/path/to/project/src/main/resources/youtube/settings/xml/prefs.xml'
+ """
+ return (self.RESOURCES_DIR / app / resource_type).resolve()
diff --git a/xml_tools/core/exceptions.py b/xml_tools/core/exceptions.py
new file mode 100644
index 000000000..3c3555c36
--- /dev/null
+++ b/xml_tools/core/exceptions.py
@@ -0,0 +1,13 @@
+class XMLToolsError(Exception):
+ """Base exception for XML tools."""
+ pass
+
+
+class ConfigError(XMLToolsError):
+ """Configuration related errors."""
+ pass
+
+
+class XMLProcessingError(XMLToolsError):
+ """XML processing related errors."""
+ pass
diff --git a/xml_tools/core/logging.py b/xml_tools/core/logging.py
new file mode 100644
index 000000000..60c7add16
--- /dev/null
+++ b/xml_tools/core/logging.py
@@ -0,0 +1,132 @@
+import logging
+from pathlib import Path
+from typing import Optional
+
+# ANSI escape codes for colors
+BLUE: str = "\033[94m"
+GREEN: str = "\033[92m"
+YELLOW: str = "\033[93m"
+RED: str = "\033[91m"
+CYAN: str = "\033[96m"
+DARK_CYAN: str = "\033[1;36m"
+RESET: str = "\033[0m"
+
+
+class ColorFormatter(logging.Formatter):
+ """
+ Custom formatter to add colors based on log level and special message formatting.
+
+ Attributes:
+ level_colors (dict): Mapping of log levels to their corresponding colors.
+ """
+ level_colors = {
+ "DEBUG": BLUE,
+ "INFO": GREEN,
+ "WARNING": YELLOW,
+ "ERROR": RED,
+ "CRITICAL": RED,
+ }
+
+ def format(self, record: logging.LogRecord) -> str:
+ """
+ Format the log record with colors and special message handling.
+
+ Args:
+ record (logging.LogRecord): The log record to format.
+
+ Returns:
+ str: The formatted log message with appropriate colors.
+
+ Note:
+ - Preserves original record attributes by saving and restoring them
+ - Applies special coloring to "Starting process:" messages
+ - Colors log levels according to severity
+ """
+ # Save original values
+ original_levelname = record.levelname
+ original_msg = record.msg
+
+ # Color the level name
+ if record.levelname in self.level_colors:
+ record.levelname = f"{self.level_colors[record.levelname]}{record.levelname}{RESET}"
+
+ # If message starts with "Starting process", color that part
+ if isinstance(record.msg, str) and record.msg.startswith("Starting process:"):
+ record.msg = f"{CYAN}Starting process:{DARK_CYAN}{str(record.msg).split(':', 1)[1]}{RESET}"
+
+ # Format with colors
+ formatted_message = super().format(record)
+
+ # Restore original values
+ record.levelname = original_levelname
+ record.msg = original_msg
+
+ return formatted_message
+
+
+def setup_logging(log_file: Optional[Path] = None, debug: bool = True) -> logging.Logger:
+ """
+ Configure logging with colored level names for console output and optional file logging.
+
+ Args:
+ log_file (Optional[Path]): Path to the log file. If None, only console logging is configured.
+ debug (bool): Whether to enable DEBUG level logging. Defaults to True.
+
+ Returns:
+ logging.Logger: Configured logger instance.
+
+ Note:
+ - Console output uses colors for better readability
+ - File output (if enabled) uses plain text without colors
+ - DEBUG messages are enabled by default
+ - Clears any existing handlers before configuration
+ """
+ # Create logger
+ logger = logging.getLogger("xml_tools")
+
+ # Set the base level to DEBUG if debug is True, otherwise INFO
+ base_level = logging.DEBUG if debug else logging.INFO
+ logger.setLevel(base_level)
+
+ # Remove any existing handlers
+ logger.handlers = []
+
+ # Console handler with colors
+ console_handler = logging.StreamHandler()
+ console_handler.setLevel(base_level) # Use same level as logger
+ console_formatter = ColorFormatter(
+ "%(asctime)s - %(levelname)s - %(message)s"
+ )
+ console_handler.setFormatter(console_formatter)
+ logger.addHandler(console_handler)
+
+ # File handler without colors if log_file specified
+ if log_file:
+ file_handler = logging.FileHandler(log_file)
+ file_handler.setLevel(base_level) # Use same level as logger
+ file_formatter = logging.Formatter(
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ )
+ file_handler.setFormatter(file_formatter)
+ logger.addHandler(file_handler)
+
+ # Log initial setup
+ logger.debug("Logging system initialized")
+ if log_file:
+ logger.debug(f"Log file created at: {log_file}")
+
+ return logger
+
+
+def log_process(logger: logging.Logger, process_name: str) -> None:
+ """
+ Log the start of a process with special formatting.
+
+ Args:
+ logger (logging.Logger): The logger instance to use.
+ process_name (str): Name of the process being started.
+
+ Note:
+ Uses special color formatting for "Starting process:" messages.
+ """
+ logger.info(f"Starting process: {process_name}")
diff --git a/xml_tools/handlers/missing_prefs.py b/xml_tools/handlers/missing_prefs.py
new file mode 100644
index 000000000..314b424ac
--- /dev/null
+++ b/xml_tools/handlers/missing_prefs.py
@@ -0,0 +1,72 @@
+from pathlib import Path
+from typing import Set
+import re
+import logging
+
+from config.settings import Settings
+from core.exceptions import XMLProcessingError
+
+logger = logging.getLogger("xml_tools")
+
+
+def extract_keys(path: Path) -> Set[str]:
+ """Extract keys from XML file.
+
+ Args:
+ path: Path to XML file
+
+ Returns:
+ Set of extracted keys
+
+ Raises:
+ XMLProcessingError: If parsing fails
+ """
+ try:
+ key_pattern = re.compile(r'android:key="(\w+)"') # Compile the regex pattern to match keys
+ keys_found = set() # Use a set to store unique keys
+
+ # Open the XML file and search for the keys
+ with open(path, "r", encoding="utf-8") as file:
+ for line in file:
+ matches = key_pattern.findall(line) # Find all keys in the line
+ keys_found.update(matches) # Add found keys to the set
+
+ return keys_found
+ except Exception as e:
+ logger.error(f"Failed to extract keys from {path}: {e}")
+ raise XMLProcessingError(f"Failed to extract keys from {path}: {e}")
+
+
+def process(app: str, base_dir: Path) -> None:
+ """Process prefs files to find missing keys.
+
+ Args:
+ app: Application name (youtube/music)
+ base_dir: Base directory of RVX patches operations
+ """
+ settings = Settings()
+ base_path = settings.get_resource_path(app, "settings")
+
+ # Define file paths using base_dir
+ prefs_path_1 = base_dir / "src/main/resources/youtube/settings/xml/revanced_prefs.xml"
+ prefs_path_2 = base_path / "xml/revanced_prefs.xml"
+
+ try:
+ # Extract keys from both files
+ keys_1 = extract_keys(prefs_path_1)
+ keys_2 = extract_keys(prefs_path_2)
+
+ # Find missing keys
+ missing_keys = keys_1 - keys_2
+
+ # Log results
+ if missing_keys:
+ logger.info("Missing keys found:")
+ for key in sorted(missing_keys):
+ logger.info(key)
+ else:
+ logger.info("No missing keys found")
+
+ except XMLProcessingError as e:
+ logger.error(f"Failed to process preference files: {e}")
+ raise
diff --git a/xml_tools/handlers/missing_strings.py b/xml_tools/handlers/missing_strings.py
new file mode 100644
index 000000000..fc850abf2
--- /dev/null
+++ b/xml_tools/handlers/missing_strings.py
@@ -0,0 +1,77 @@
+from pathlib import Path
+import logging
+from lxml import etree as ET
+
+from config.settings import Settings
+from core.exceptions import XMLProcessingError
+from utils.xml import XMLProcessor
+
+logger = logging.getLogger("xml_tools")
+
+
+def compare_and_update(source_path: Path, dest_path: Path, missing_path: Path) -> None:
+ """Compare source and destination files and update missing strings.
+
+ Args:
+ source_path: Path to source XML file
+ dest_path: Path to destination XML file
+ missing_path: Path to missing strings file
+ """
+ try:
+ # Parse source and destination files
+ _, _, source_strings = XMLProcessor.parse_file(source_path)
+ _, _, dest_strings = XMLProcessor.parse_file(dest_path)
+ _, _, missing_path_strings = (
+ XMLProcessor.parse_file(missing_path) if missing_path.exists() else (None, None, {})
+ )
+
+ # Find missing strings
+ missing_strings = {}
+ for name, data in source_strings.items():
+ if name not in dest_strings:
+ missing_strings[name] = data
+
+ if missing_strings:
+ # Create new root with missing strings
+ root = ET.Element("resources")
+ for name, data in sorted(missing_strings.items()):
+ # If the string is already in the file and the count of strings is the same, then skip.
+ if name in missing_path_strings and len(missing_strings.keys()) == len(missing_path_strings.keys()):
+ logger.info(f"Up to date: {missing_path}")
+ return
+ string_elem = ET.Element("string", **data["attributes"])
+ string_elem.text = data["text"]
+ root.append(string_elem)
+
+ # Write missing strings file
+ XMLProcessor.write_file(missing_path, root)
+ logger.info(f"Modified missing strings file: {missing_path}")
+ elif missing_path.exists():
+ missing_path.unlink()
+ logger.info(f"Removed empty missing strings file: {missing_path}")
+
+ except Exception as e:
+ logger.error(f"Failed to process missing strings: {e}")
+ raise XMLProcessingError(str(e))
+
+
+def process(app: str) -> None:
+ """Process all files to find missing strings.
+
+ Args:
+ app: Application name (youtube/music)
+ """
+ settings = Settings()
+ source_path = settings.get_resource_path(app, "settings") / "host/values/strings.xml"
+ translations = settings.get_resource_path(app, "translations")
+
+ try:
+ for lang_dir in translations.iterdir():
+ if lang_dir.is_dir():
+ dest_path = lang_dir / "strings.xml"
+ missing_path = lang_dir / "missing_strings.xml"
+ compare_and_update(source_path, dest_path, missing_path)
+
+ except Exception as e:
+ logger.error(f"Failed to process {app} translations: {e}")
+ raise XMLProcessingError(str(e))
diff --git a/xml_tools/handlers/remove_unused_strings.py b/xml_tools/handlers/remove_unused_strings.py
new file mode 100644
index 000000000..d7c081716
--- /dev/null
+++ b/xml_tools/handlers/remove_unused_strings.py
@@ -0,0 +1,230 @@
+from typing import Set, Dict, List
+import logging
+import os
+from lxml import etree as ET
+from pathlib import Path
+
+from config.settings import Settings
+from core.exceptions import XMLProcessingError
+from utils.xml import XMLProcessor
+
+logger = logging.getLogger("xml_tools")
+
+# Constants
+BLACKLISTED_STRINGS: Set[str] = {
+ "revanced_remember_video_quality_mobile",
+ "revanced_remember_video_quality_wifi",
+ "revanced_sb_api_url_sum",
+ "revanced_sb_enabled",
+ "revanced_sb_enabled_sum",
+ "revanced_sb_toast_on_skip",
+ "revanced_sb_toast_on_skip_sum"
+}
+
+PREFIX_TO_IGNORE: tuple[str, ...] = (
+ "revanced_icon_",
+ "revanced_spoof_app_version_target_entry_",
+ "revanced_spoof_streaming_data_side_effects_"
+)
+
+settings_instance = Settings()
+
+SCRIPT_DIR = settings_instance.BASE_DIR
+SEARCH_DIRECTORIES = [
+ str(SCRIPT_DIR.parent / "revanced-patches"),
+ str(SCRIPT_DIR.parent / "revanced-integrations")
+]
+ALLOWED_EXTENSIONS = (".kt", ".java", ".xml")
+
+
+def get_base_name(name: str) -> str:
+ """
+ Return the base name by stripping known suffixes from a string name.
+
+ Args:
+ name (str): The original string name.
+
+ Returns:
+ str: The string name with known suffixes removed.
+
+ Example:
+ >>> get_base_name("my_setting_summary_on")
+ 'my_setting'
+ """
+ suffixes = [
+ "_title",
+ "_summary_off",
+ "_summary_on",
+ "_summary"
+ ]
+ for suffix in suffixes:
+ if name.endswith(suffix):
+ return name[:-len(suffix)]
+ return name
+
+
+def search_in_files(directories: List[str], name_values: Set[str]) -> Dict[str, List[str]]:
+ """
+ Search for string names in all files within specified directories.
+
+ Args:
+ directories (List[str]): List of directory paths to search.
+ name_values (Set[str]): Set of string names to search for.
+
+ Returns:
+ Dict[str, List[str]]: Dictionary mapping each string name to a list of file paths where it was found.
+
+ Raises:
+ OSError: If there are problems accessing the directories or files.
+ UnicodeDecodeError: If a file cannot be read as UTF-8.
+
+ Notes:
+ - Skips hidden directories and 'build' directories
+ - Ignores 'strings.xml' and 'missing_strings.xml' files
+ - Only searches files with extensions defined in ALLOWED_EXTENSIONS
+ - Searches for both original names and their base forms (without suffixes)
+ """
+ results = {name: [] for name in name_values}
+
+ for directory in directories:
+ abs_dir = os.path.abspath(directory)
+ logger.info(f"Searching in directory: {abs_dir} (exists: {os.path.exists(abs_dir)})")
+
+ for root, dirs, files in os.walk(directory):
+ # Skip hidden and build directories
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d != "build"]
+
+ for file in files:
+ if (file in ("strings.xml", "missing_strings.xml") or not file.endswith(ALLOWED_EXTENSIONS)):
+ continue
+
+ file_path = os.path.join(root, file)
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+ for name in name_values:
+ # Check both original name and base name
+ if name in content or get_base_name(name) in content:
+ results[name].append(file_path)
+ except Exception as e:
+ logger.error(f"Error reading {file_path}: {e}")
+
+ return results
+
+
+def should_remove(name: str, unused_names: Set[str]) -> bool:
+ """
+ Determine if a string should be removed based on various criteria.
+
+ Args:
+ name (str): The string name to check.
+ unused_names (Set[str]): Set of string names that were not found in any source files.
+
+ Returns:
+ bool: True if the string should be removed, False otherwise.
+
+ Notes:
+ A string should be removed if:
+ - The string or its base name is in the unused_names set
+ - The string is not in BLACKLISTED_STRINGS
+ - The string does not start with any prefix in PREFIX_TO_IGNORE
+ """
+ base_name = get_base_name(name)
+ return (
+ (name in unused_names or base_name in unused_names) and
+ name not in BLACKLISTED_STRINGS and
+ not any(name.startswith(prefix) for prefix in PREFIX_TO_IGNORE)
+ )
+
+
+def process_xml_file(file_path: Path, unused_names: Set[str]) -> None:
+ """
+ Process a single XML file to remove unused strings.
+
+ Args:
+ file_path (Path): Path to the XML file to process.
+ unused_names (Set[str]): Set of string names that should be considered for removal.
+
+ Raises:
+ XMLProcessingError: If there are any errors during XML processing.
+
+ Notes:
+ - Creates a new XML tree containing only the strings that should be kept
+ - Only writes the file if strings were actually removed
+ - Maintains original XML structure and attributes
+ """
+ try:
+ _, _, strings_dict = XMLProcessor.parse_file(file_path)
+
+ # Count strings before removal
+ initial_count = len(strings_dict)
+
+ # Create new root with only used strings
+ new_root = ET.Element("resources")
+ kept_strings = 0
+ for name, data in sorted(strings_dict.items()):
+ if not should_remove(name, unused_names):
+ string_elem = ET.Element("string", **data["attributes"])
+ string_elem.text = data["text"]
+ new_root.append(string_elem)
+ kept_strings += 1
+
+ # Only write if strings were actually removed
+ if kept_strings < initial_count:
+ XMLProcessor.write_file(file_path, new_root)
+ logger.info(
+ f"Updated {file_path}: "
+ f"removed {initial_count - kept_strings} strings, "
+ f"kept {kept_strings} strings"
+ )
+ else:
+ logger.info(f"No changes needed for {file_path}")
+
+ except Exception as e:
+ logger.error(f"Error processing {file_path}: {e}")
+ raise XMLProcessingError(f"Failed to process {file_path}: {str(e)}")
+
+
+def process(app: str) -> None:
+ """
+ Remove unused strings from XML files for a given application.
+
+ Args:
+ app (str): The application identifier to process.
+
+ Raises:
+ XMLProcessingError: If there are any errors during XML processing.
+
+ Notes:
+ - Processes both the source strings file and all translation files
+ - Uses settings from the Settings class to determine file locations
+ - Maintains a log of all operations
+ - Skips writing files if no changes are needed
+ """
+ settings = Settings()
+ base_path = settings.get_resource_path(app, "settings")
+ source_path = base_path / "host/values/strings.xml"
+ translations = settings.get_resource_path(app, "translations")
+
+ try:
+ # Get source strings
+ _, _, source_strings = XMLProcessor.parse_file(source_path)
+
+ # Find unused strings using direct file search
+ search_results = search_in_files(SEARCH_DIRECTORIES, set(source_strings.keys()))
+ unused_names = {name for name, files in search_results.items() if not files}
+
+ # Process source file
+ if unused_names:
+ process_xml_file(source_path, unused_names)
+
+ # Process translation files
+ for lang_dir in translations.iterdir():
+ if lang_dir.is_dir():
+ dest_path = lang_dir / "strings.xml"
+ if dest_path.exists():
+ process_xml_file(dest_path, unused_names)
+
+ except Exception as e:
+ logger.error(f"Error during processing: {e}")
+ raise XMLProcessingError(str(e))
diff --git a/xml_tools/handlers/replace_strings.py b/xml_tools/handlers/replace_strings.py
new file mode 100644
index 000000000..55c63832f
--- /dev/null
+++ b/xml_tools/handlers/replace_strings.py
@@ -0,0 +1,78 @@
+from pathlib import Path
+import logging
+from lxml import etree as ET
+
+from config.settings import Settings
+from core.exceptions import XMLProcessingError
+from utils.xml import XMLProcessor
+
+logger = logging.getLogger("xml_tools")
+
+
+def update_strings(target_path: Path, source_path: Path) -> None:
+ """Update target XML file with strings from source file.
+
+ Args:
+ target_path: Path to target XML file
+ source_path: Path to source XML file
+ """
+ try:
+ # Parse source and target files
+ _, target_root, target_strings = XMLProcessor.parse_file(target_path)
+ _, _, source_strings = XMLProcessor.parse_file(source_path)
+
+ # Update existing strings
+ for elem in target_root.findall(".//string"):
+ name = elem.get("name")
+ if name in source_strings:
+ data = source_strings[name]
+ elem.text = data["text"]
+ elem.attrib.update(data["attributes"])
+ del source_strings[name]
+
+ # Add new strings
+ for name, data in sorted(source_strings.items()):
+ string_elem = ET.Element("string", **data["attributes"])
+ string_elem.text = data["text"]
+ target_root.append(string_elem)
+
+ # Write updated file
+ XMLProcessor.write_file(target_path, target_root)
+ logger.info(f"Updated strings in {target_path}")
+
+ except Exception as e:
+ logger.error(f"Failed to update strings in {target_path}: {e}")
+ raise XMLProcessingError(str(e))
+
+
+def process(app: str, base_dir: Path) -> None:
+ """Process all files to replace strings.
+
+ Args:
+ app: Application name (youtube/music)
+ base_dir: Base directory of RVX patches operations
+ """
+ settings = Settings()
+ base_path = settings.get_resource_path(app, "settings")
+ source_path = base_path / "host/values/strings.xml"
+ translations = settings.get_resource_path(app, "translations")
+
+ try:
+ # First update base strings file from RVX
+ rvx_base_path = base_dir / "src/main/resources" / app
+ rvx_source_path = rvx_base_path / "settings/host/values/strings.xml"
+ if rvx_source_path.exists():
+ update_strings(source_path, rvx_source_path)
+
+ # Process translation files
+ for lang_dir in translations.iterdir():
+ if lang_dir.is_dir():
+ target_path = lang_dir / "strings.xml"
+ rvx_lang_path = rvx_base_path / "translations" / lang_dir.name / "strings.xml"
+
+ if rvx_lang_path.exists():
+ update_strings(target_path, rvx_lang_path)
+
+ except Exception as e:
+ logger.error(f"Failed to process {app} translations: {e}")
+ raise XMLProcessingError(str(e))
diff --git a/xml_tools/handlers/sort_strings.py b/xml_tools/handlers/sort_strings.py
new file mode 100644
index 000000000..20eebd7ca
--- /dev/null
+++ b/xml_tools/handlers/sort_strings.py
@@ -0,0 +1,53 @@
+from pathlib import Path
+from lxml import etree as ET
+import logging
+
+from config.settings import Settings
+from core.exceptions import XMLProcessingError
+from utils.xml import XMLProcessor
+
+logger = logging.getLogger("xml_tools")
+
+
+def sort_file(path: Path) -> None:
+ """Sort strings in XML file alphabetically.
+
+ Args:
+ path: Path to XML file to sort
+ """
+ try:
+ _, root, strings = XMLProcessor.parse_file(path)
+
+ # Create new root with sorted strings
+ new_root = ET.Element("resources")
+ for name in sorted(strings.keys()):
+ data = strings[name]
+ string_elem = ET.Element("string", **data["attributes"])
+ string_elem.text = data["text"]
+ new_root.append(string_elem)
+
+ XMLProcessor.write_file(path, new_root)
+ logger.info(f"Sorted strings in {path}")
+
+ except Exception as e:
+ logger.error(f"Failed to sort {path}: {e}")
+ raise XMLProcessingError(f"Failed to sort {path}: {e}")
+
+
+def process(app: str) -> None:
+ """Process all files for an app.
+
+ Args:
+ app: Application name (youtube/music)
+ """
+ settings = Settings()
+ base_path = settings.get_resource_path(app, "settings")
+ translations = settings.get_resource_path(app, "translations")
+
+ # Sort main strings file
+ sort_file(base_path / "host/values/strings.xml")
+
+ # Sort translation files
+ for lang_dir in translations.iterdir():
+ if lang_dir.is_dir():
+ sort_file(lang_dir / "strings.xml")
diff --git a/xml_tools/main.py b/xml_tools/main.py
new file mode 100644
index 000000000..70a01bdfa
--- /dev/null
+++ b/xml_tools/main.py
@@ -0,0 +1,208 @@
+from pathlib import Path
+import click
+import os
+import sys
+from typing import Optional, List, Tuple, Callable
+from logging import Logger
+
+from config.settings import Settings
+from core.exceptions import ConfigError
+from core.logging import setup_logging, log_process
+from utils.git import GitClient
+from handlers import missing_prefs, missing_strings, remove_unused_strings, replace_strings, sort_strings
+
+settings = Settings()
+
+
+def get_rvx_base_dir() -> Path:
+ """Get RVX base directory from environment variable.
+
+ Returns:
+ Path: The path to the RVX base directory.
+
+ Raises:
+ ConfigError: If RVX_BASE_DIR environment variable is not set.
+
+ Note:
+ This function checks for the RVX_BASE_DIR environment variable
+ which must be set before running the application.
+ """
+ rvx_dir = os.getenv("RVX_BASE_DIR")
+ if not rvx_dir:
+ raise ConfigError("RVX_BASE_DIR environment variable must be set")
+ return Path(rvx_dir)
+
+
+def validate_rvx_base_dir(ctx: click.Context, base_dir: Optional[str] = None) -> Path:
+ """Validate and return the RVX base directory path.
+
+ Args:
+ ctx (click.Context): The Click context object containing shared resources.
+ base_dir (Optional[str], optional): The base directory path string. Defaults to None.
+
+ Returns:
+ Path: A validated Path object for the RVX base directory.
+
+ Raises:
+ SystemExit: If the base directory validation fails.
+
+ Note:
+ If base_dir is not provided, the function attempts to get it from
+ the RVX_BASE_DIR environment variable.
+ """
+ if not base_dir:
+ try:
+ base_dir = str(get_rvx_base_dir())
+ except ConfigError as e:
+ ctx.obj['logger'].error(str(e))
+ sys.exit(1)
+ return Path(base_dir)
+
+
+def process_all(app: str, base_dir: Path, logger: Logger) -> None:
+ """Run all processing steps in sequence for the specified application.
+
+ Args:
+ app (str): The application to process ('youtube' or 'music').
+ base_dir (Path): The base directory path for RVX operations.
+ logger (Logger): The logger instance for recording operations.
+
+ Raises:
+ SystemExit: If any processing step fails or Git sync fails.
+ Exception: If any handler encounters an error during execution.
+
+ Note:
+ This function executes the following steps in order:
+ 1. Syncs the Git repository
+ 2. Replaces strings for YouTube and YouTube Music
+ 3. Removes unused strings
+ 4. Sorts strings
+ 5. Checks for missing strings
+ 6. Checks for missing preferences
+ """
+ git = GitClient(base_dir)
+ if not git.sync_repository():
+ sys.exit(1)
+
+ handlers: List[Tuple[str, Callable, List[str]]] = [
+ ("Replace Strings (YouTube)", replace_strings.process, ["youtube", base_dir]),
+ ("Replace Strings (YouTube Music)", replace_strings.process, ["music", base_dir]),
+ ("Remove Unused Strings (YouTube)", remove_unused_strings.process, ["youtube"]),
+ ("Remove Unused Strings (YouTube Music)", remove_unused_strings.process, ["music"]),
+ ("Sort Strings (YouTube)", sort_strings.process, ["youtube"]),
+ ("Sort Strings (YouTube Music)", sort_strings.process, ["music"]),
+ ("Missing Strings Check (YouTube)", missing_strings.process, ["youtube"]),
+ ("Missing Strings Check (YouTube Music)", missing_strings.process, ["music"]),
+ ("Missing Prefs Check", missing_prefs.process, ["youtube", base_dir]),
+ ]
+
+ for process_name, handler, args in handlers:
+ try:
+ log_process(logger, process_name)
+ handler(*args)
+ except Exception as e:
+ logger.error(f"Handler {process_name} failed: {e}")
+ sys.exit(1)
+
+
+@click.group(invoke_without_command=True)
+@click.option("--log-file", type=str, help="Path to log file")
+@click.option("--rvx-base-dir", type=str, help="Base directory of RVX patches operations", envvar="RVX_BASE_DIR")
+@click.option("-a", "--all", "run_all", is_flag=True, help="Run all commands in order")
+@click.option("-m", "--missing", is_flag=True, help="Run missing strings check")
+@click.option("-r", "--replace", is_flag=True, help="Run replace strings operation")
+@click.option("--remove", is_flag=True, help="Remove unused strings")
+@click.option("-s", "--sort", is_flag=True, help="Sort strings in XML files")
+@click.option("-p", "--prefs", is_flag=True, help="Run missing preferences check")
+@click.option("--youtube/--music", default=True, help="Process YouTube or Music strings")
+@click.pass_context
+def cli(ctx: click.Context,
+ log_file: Optional[str],
+ rvx_base_dir: Optional[str],
+ run_all: bool,
+ missing: bool,
+ replace: bool,
+ remove: bool,
+ sort: bool,
+ prefs: bool,
+ youtube: bool) -> None:
+ """XML processing tools for RVX patches with backwards compatibility.
+
+ Args:
+ ctx (click.Context): Click context object for sharing resources between commands.
+ log_file (Optional[str]): Path to the log file. If None, logs to stdout.
+ rvx_base_dir (Optional[str]): Base directory for RVX operations. Can be set via RVX_BASE_DIR env var.
+ run_all (bool): Flag to run all processing steps in sequence.
+ missing (bool): Flag to run missing strings check.
+ replace (bool): Flag to run string replacement operation.
+ remove (bool): Flag to remove unused strings.
+ sort (bool): Flag to sort strings in XML files.
+ prefs (bool): Flag to run missing preferences check.
+ youtube (bool): Flag to process YouTube (--youtube) or Music (--music) strings.
+
+ Raises:
+ SystemExit: If any processing step fails.
+ Exception: If any operation encounters an error.
+
+ Note:
+ - The function initializes logging and validates the RVX base directory when required.
+ - Operations are executed in the order specified by command line flags.
+ - The --youtube/--music flag determines which application's strings to process.
+ - When --all is specified, all operations are run in a predefined sequence.
+ """
+ # Initialize the logger
+ logger = setup_logging(Path(log_file) if log_file else None)
+
+ app = "youtube" if youtube else "music"
+
+ # Store common context
+ ctx.obj = {
+ "app": app,
+ "logger": logger
+ }
+
+ # Only validate RVX_BASE_DIR for commands that need it
+ needs_rvx_dir = run_all or replace or prefs
+ if needs_rvx_dir:
+ base_dir = validate_rvx_base_dir(ctx, rvx_base_dir)
+
+ # Handle all operations if --all is specified
+ if run_all:
+ try:
+ process_all(app, base_dir, logger)
+ return
+ except Exception as e:
+ logger.error(f"Error during processing: {e}")
+ sys.exit(1)
+
+ # Handle individual operations
+ try:
+ if missing:
+ log_process(logger, "Missing Strings Check")
+ missing_strings.process(app)
+
+ if prefs:
+ log_process(logger, "Missing Preferences Check")
+ missing_prefs.process(app, base_dir)
+
+ if remove:
+ log_process(logger, "Remove Unused Strings")
+ remove_unused_strings.process(app)
+
+ if replace:
+ git = GitClient(base_dir)
+ if git.sync_repository():
+ log_process(logger, "Replace Strings")
+ replace_strings.process(app, base_dir)
+
+ if sort:
+ log_process(logger, "Sort Strings")
+ sort_strings.process(app)
+
+ except Exception as e:
+ logger.error(f"Error during processing: {e}")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ cli()
diff --git a/xml_tools/utils/git.py b/xml_tools/utils/git.py
new file mode 100644
index 000000000..14a670a09
--- /dev/null
+++ b/xml_tools/utils/git.py
@@ -0,0 +1,87 @@
+import subprocess
+from pathlib import Path
+from typing import Tuple
+import logging
+
+logger = logging.getLogger("xml_tools")
+
+
+class GitClient:
+ """
+ Handler for Git operations on a repository.
+
+ This class provides methods to perform Git operations on a specified repository path.
+
+ Attributes:
+ repo_path (Path): Path to the Git repository
+ """
+
+ def __init__(self, repo_path: Path) -> None:
+ """
+ Initialize GitClient with repository path.
+
+ Args:
+ repo_path (Path): Path to the Git repository
+
+ Returns:
+ None
+ """
+ self.repo_path = repo_path
+
+ def run_command(self, command: list) -> Tuple[int, str, str]:
+ """
+ Execute a Git command and return its result.
+
+ Args:
+ command (list): List of command components (e.g., ["git", "pull"])
+
+ Returns:
+ Tuple[int, str, str]: A tuple containing:
+ - Return code (0 for success)
+ - Command output (stdout)
+ - Error output (stderr)
+
+ Note:
+ Commands are executed in the repository directory specified during initialization.
+ """
+ try:
+ result = subprocess.run(
+ command,
+ cwd=self.repo_path,
+ capture_output=True,
+ text=True
+ )
+ return result.returncode, result.stdout, result.stderr
+ except subprocess.SubprocessError as e:
+ logger.error(f"Git command failed: {e}")
+ return 1, "", str(e)
+
+ def sync_repository(self) -> bool:
+ """
+ Synchronize the repository with its remote.
+
+ This method performs three operations in sequence:
+ 1. Switches to the 'dev' branch
+ 2. Fetches updates from remote
+ 3. Pulls changes
+
+ Returns:
+ bool: True if all operations succeeded, False otherwise
+
+ Note:
+ Logs success/failure of each operation through the logger.
+ """
+ operations = [
+ (["git", "switch", "dev"], "checkout"),
+ (["git", "fetch"], "fetch"),
+ (["git", "pull"], "pull")
+ ]
+
+ for command, operation in operations:
+ code, out, err = self.run_command(command)
+ if code != 0:
+ logger.error(f"Git {operation} failed: {err}")
+ return False
+ logger.info(f"Git {operation} successful")
+
+ return True
diff --git a/xml_tools/utils/xml.py b/xml_tools/utils/xml.py
new file mode 100644
index 000000000..df79d73df
--- /dev/null
+++ b/xml_tools/utils/xml.py
@@ -0,0 +1,93 @@
+from lxml import etree as ET
+from typing import Dict, Tuple
+from pathlib import Path
+import logging
+from core.exceptions import XMLProcessingError
+
+logger = logging.getLogger("xml_tools")
+
+
+class XMLProcessor:
+ """
+ Utilities for processing XML files.
+
+ This class provides static methods for parsing and writing XML files,
+ with special handling for elements containing 'name' attributes.
+ """
+
+ @staticmethod
+ def parse_file(path: Path) -> Tuple[ET.ElementTree, ET.Element, Dict[str, Dict[str, str]]]:
+ """
+ Parse an XML file and extract data from elements with 'name' attributes.
+
+ Args:
+ path (Path): Path to the XML file to parse
+
+ Returns:
+ Tuple[ET.ElementTree, ET.Element, Dict[str, Dict[str, str]]]: A tuple containing:
+ - The parsed XML tree
+ - Root element
+ - Dictionary mapping element names to their properties:
+ {
+ "element_name": {
+ "text": "element text content",
+ "attributes": {"attr1": "value1", ...}
+ }
+ }
+
+ Raises:
+ XMLProcessingError: If the file cannot be parsed or read
+
+ Note:
+ Only elements with 'name' attributes are included in the returned dictionary.
+ """
+ try:
+ tree = ET.parse(path)
+ root = tree.getroot()
+
+ # Capture all elements with a 'name' attribute
+ strings = {}
+ for elem in root.findall(".//*[@name]"):
+ name = elem.get("name")
+ if name:
+ strings[name] = {
+ "text": elem.text or "",
+ "attributes": dict(elem.attrib)
+ }
+
+ return tree, root, strings
+ except (ET.ParseError, IOError) as e:
+ logger.error(f"Failed to parse {path}: {e}")
+ raise XMLProcessingError(f"Failed to parse {path}: {e}")
+
+ @staticmethod
+ def write_file(path: Path, root: ET.Element, pretty_print: bool = True) -> None:
+ """
+ Write an XML element tree to a file.
+
+ Args:
+ path (Path): Output file path
+ root (ET.Element): Root element to write
+ pretty_print (bool): Whether to format the output with proper indentation
+
+ Raises:
+ XMLProcessingError: If the file cannot be written
+
+ Note:
+ - Creates parent directories if they don't exist
+ - Uses 4-space indentation when pretty_print is True
+ - Writes in UTF-8 encoding with XML declaration
+ """
+ try:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tree = ET.ElementTree(root)
+ ET.indent(tree, space=" ") # Set indentation to 4 spaces
+ tree.write(
+ path,
+ encoding="utf-8",
+ xml_declaration=True,
+ pretty_print=pretty_print
+ )
+ except IOError as e:
+ logger.error(f"Failed to write {path}: {e}")
+ raise XMLProcessingError(f"Failed to write {path}: {e}")