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}")