""" Core logic for fixing mojibake in file names and ZIP archives on macOS. """ import os import zipfile import shutil RUSSIAN_RANGE = set(range(0x0400, 0x0500)) def score_cyrillic(text: str) -> int: """Return count of Cyrillic characters in text.""" return sum(1 for c in text if ord(c) in RUSSIAN_RANGE) def is_suspicious_char(c: str) -> bool: """Return True if character commonly appears in mojibake.""" # Signs of MacCyrillic/CP437/Latin1 mangling of Cyrillic: # ≠ ™ • † Ѓ ѓ Ђ ђ Љ љ Њ њ Ћ ћ Џ џ § © ® ™ ¶ return c in "≠™•†ЃѓЂђЉљЊњЋћЏџ§©®¶ҐґІіЇїЎў" def fix_filename_string(name: str) -> str: """ Attempt to convert a corrupted filename back to legible Cyrillic. Handles: - MacCyrillic -> CP866 (macOS Russian Archive Utility default on OEM DOS zips) - MacCyrillic -> CP1251 - CP437 -> CP866 (ditto, default zipfile, unzip) - CP437 -> CP1251 - Latin1 -> CP1251 / CP866 / UTF-8 - MacRoman -> CP1251 / CP866 """ orig_score = score_cyrillic(name) has_suspicious = any(is_suspicious_char(c) for c in name) # Pairs of (source_encoding, target_encoding) transforms = [ ("mac_cyrillic", "cp866"), ("mac_cyrillic", "cp1251"), ("cp437", "cp866"), ("cp437", "cp1251"), ("latin1", "cp1251"), ("latin1", "cp866"), ("latin1", "utf-8"), ("mac_roman", "cp866"), ("mac_roman", "cp1251"), ] best_name = name best_score = orig_score for enc_src, enc_dst in transforms: try: raw = name.encode(enc_src) candidate = raw.decode(enc_dst) cand_score = score_cyrillic(candidate) # If original had suspicious mojibake characters and candidate eliminated them # or increased cyrillic score if cand_score > best_score or (has_suspicious and cand_score >= best_score and not any(is_suspicious_char(c) for c in candidate)): best_score = cand_score best_name = candidate except Exception: continue return best_name def process_path(target_path: str, progress_callback=None) -> list[tuple[str, str, str]]: """ Recursively process a directory or file. If it's a .zip file, it extracts it with corrected names into a folder next to it. If it's a directory or regular file, it renames files and folders in-place (bottom-up). Returns list of tuples: (type, old_name, new_name) """ results = [] if not os.path.exists(target_path): return [("error", target_path, "Файл или папка не существует")] # Case 1: ZIP file if os.path.isfile(target_path) and target_path.lower().endswith(".zip"): return process_zip(target_path, progress_callback) # Case 2: Single file if os.path.isfile(target_path): dir_name, base_name = os.path.split(target_path) # Skip temporary office lock files if base_name.startswith("~$"): try: os.remove(target_path) results.append(("delete", base_name, "Удалён временный файл блокировки Office")) except Exception as e: results.append(("error", base_name, f"Не удалось удалить: {e}")) return results fixed = fix_filename_string(base_name) if fixed != base_name: new_path = os.path.join(dir_name, fixed) try: os.rename(target_path, new_path) results.append(("rename_file", base_name, fixed)) except Exception as e: results.append(("error", base_name, f"Ошибка: {e}")) else: results.append(("skip", base_name, "Имя корректно")) return results # Case 3: Directory (recursive, bottom-up so renaming children doesn't invalidate parent path) for root, dirs, files in os.walk(target_path, topdown=False): # 1. Process files for fname in files: fpath = os.path.join(root, fname) # Remove ~$ lock files if fname.startswith("~$"): try: os.remove(fpath) results.append(("delete", fname, "Удалён временный файл блокировки Office")) except Exception as e: results.append(("error", fname, f"Не удалось удалить: {e}")) continue fixed = fix_filename_string(fname) if fixed != fname: new_fpath = os.path.join(root, fixed) try: os.rename(fpath, new_fpath) results.append(("rename_file", fname, fixed)) if progress_callback: progress_callback(fname, fixed) except Exception as e: results.append(("error", fname, f"Ошибка: {e}")) else: results.append(("ok", fname, "OK")) # 2. Process subdirectories for dname in dirs: dpath = os.path.join(root, dname) fixed_dir = fix_filename_string(dname) if fixed_dir != dname: new_dpath = os.path.join(root, fixed_dir) try: os.rename(dpath, new_dpath) results.append(("rename_dir", dname, fixed_dir)) if progress_callback: progress_callback(dname, fixed_dir) except Exception as e: results.append(("error", dname, f"Ошибка: {e}")) # Also check if root folder itself needs renaming root_parent, root_name = os.path.split(os.path.abspath(target_path)) fixed_root = fix_filename_string(root_name) if fixed_root != root_name and root_parent: try: new_root_path = os.path.join(root_parent, fixed_root) os.rename(target_path, new_root_path) results.append(("rename_dir", root_name, fixed_root)) except Exception as e: results.append(("error", root_name, f"Ошибка переименования папки: {e}")) return results def process_zip(zip_path: str, progress_callback=None) -> list[tuple[str, str, str]]: """ Extract a ZIP archive directly, correcting encodings for all entries. Creates a folder next to zip_path with the extracted contents. """ results = [] base_dir = os.path.dirname(os.path.abspath(zip_path)) zip_stem = os.path.splitext(os.path.basename(zip_path))[0] fixed_stem = fix_filename_string(zip_stem) out_dir = os.path.join(base_dir, fixed_stem) counter = 1 orig_out_dir = out_dir while os.path.exists(out_dir): out_dir = f"{orig_out_dir}_{counter}" counter += 1 os.makedirs(out_dir, exist_ok=True) results.append(("extract_start", os.path.basename(zip_path), f"Распаковка в {os.path.basename(out_dir)}")) try: with zipfile.ZipFile(zip_path, "r") as zf: for item in zf.infolist(): raw_name = item.filename # Office lock files in zip can be skipped if os.path.basename(raw_name).startswith("~$"): continue # Determine best fixed name # If flag_bits & 0x800 == 0: python decoded with CP437 best_name = raw_name if not (item.flag_bits & 0x800): try: raw_bytes = raw_name.encode("cp437") for enc in ["cp866", "cp1251"]: try: candidate = raw_bytes.decode(enc) if score_cyrillic(candidate) > score_cyrillic(best_name): best_name = candidate break except Exception: pass except Exception: pass # Run standard mojibake fixer on the parts of path as well parts = best_name.split("/") fixed_parts = [fix_filename_string(p) for p in parts] clean_rel_path = "/".join(fixed_parts) # Security check: prevent zip slip dest_path = os.path.abspath(os.path.join(out_dir, clean_rel_path)) if not dest_path.startswith(os.path.abspath(out_dir)): continue if item.is_dir() or clean_rel_path.endswith("/"): os.makedirs(dest_path, exist_ok=True) else: os.makedirs(os.path.dirname(dest_path), exist_ok=True) with zf.open(item) as src_file, open(dest_path, "wb") as dst_file: shutil.copyfileobj(src_file, dst_file) results.append(("extract_file", raw_name, clean_rel_path)) if progress_callback: progress_callback(raw_name, clean_rel_path) except Exception as e: results.append(("error", os.path.basename(zip_path), f"Ошибка архива: {e}")) return results