commit ce6da1937bce17413b4da6ad3e542b208e3dd65d Author: statix05 Date: Tue Sep 8 14:15:52 2026 +0300 feat: initial release of macZIPfixer (ZIPName.app) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1fe538 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +build/ +dist/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..b044b6d --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# macZIPfixer / ZIPName.app + +Утилита с графическим интерфейсом и Drag-and-Drop для macOS, предназначенная для исправления кракозябр («mojibake») в именах файлов, папках и ZIP-архивах. + +## Проблема +При распаковке ZIP-архивов, созданных на Windows без флага UTF-8 (в кодировке OEM CP866 или Windows-1251), штатный разархиватор macOS (Archive Utility / BOMArchiveHelper) интерпретирует байты через MacCyrillic или CP437. В результате имена файлов превращаются в нечитаемые символы (`≠`, `™`, `†`, `Ѓ` и т.д.). + +## Возможности +- **Drag-and-Drop:** просто перетащите поврежденную папку, файл или ZIP-архив в окно программы. +- **Поддержка ZIP напрямую:** распаковывает ZIP с автоматическим определением и восстановлением русских имен на лету. +- **Удаление мусора:** автоматически находит и удаляет фантомные файлы блокировки Office (`~$...`), возникающие при битых распаковках. +- **Рекурсивное исправление:** обходит вложенные папки снизу вверх, переименовывая как файлы, так и сами каталоги. +- **Бренд-стиль:** тёмная тема в фирменном стиле (Carbon + Red `#ff2d20`). + +## Установка и запуск + +Приложение уже собрано в `.app` и установлено в `/Applications/ZIPName.app`. + +Для ручного запуска из терминала: +```bash +python3 app.py +``` + +Для сборки `.app`: +```bash +./build.sh +cp -R dist/ZIPName.app /Applications/ +``` diff --git a/ZIPName.icns b/ZIPName.icns new file mode 100644 index 0000000..b24f1a0 Binary files /dev/null and b/ZIPName.icns differ diff --git a/app.py b/app.py new file mode 100644 index 0000000..9da6318 --- /dev/null +++ b/app.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +ZIPName — macOS Drag-and-Drop utility to fix mojibake names in folders and ZIP archives. +""" + +import os +import sys +import threading +import tkinter as tk +from tkinter import ttk, messagebox, filedialog + +try: + from tkinterdnd2 import TkinterDnD, DND_FILES + HAS_DND = True +except ImportError: + HAS_DND = False + +from fixer_core import process_path, fix_filename_string + +class ZIPNameApp: + def __init__(self, root): + self.root = root + self.root.title("ZIPName") + self.root.geometry("580x420") + self.root.minsize(500, 360) + + # Apple styling colors + self.bg_color = "#1e1e24" + self.card_color = "#2b2b36" + self.accent_color = "#ff2d20" + self.text_color = "#ffffff" + self.sub_text_color = "#9e9ea7" + self.border_color = "#3d3d4e" + + self.root.configure(bg=self.bg_color) + self.setup_ui() + + def setup_ui(self): + # Header + header_frame = tk.Frame(self.root, bg=self.bg_color) + header_frame.pack(fill="x", padx=20, pady=(20, 10)) + + title_lbl = tk.Label( + header_frame, + text="ZIPName", + font=("SF Pro Display", 20, "bold"), + fg=self.text_color, + bg=self.bg_color + ) + title_lbl.pack(anchor="w") + + sub_lbl = tk.Label( + header_frame, + text="Восстановление имён файлов и распаковка архивов с битой кодировкой", + font=("SF Pro Text", 12), + fg=self.sub_text_color, + bg=self.bg_color + ) + sub_lbl.pack(anchor="w", pady=(2, 0)) + + # Drop Zone Frame + self.drop_frame = tk.Frame( + self.root, + bg=self.card_color, + highlightbackground=self.border_color, + highlightthickness=2, + bd=0, + relief="flat" + ) + self.drop_frame.pack(fill="both", expand=True, padx=20, pady=10) + + # Drop zone contents + self.drop_label_icon = tk.Label( + self.drop_frame, + text="📦 ➔ ✨", + font=("SF Pro Display", 32), + fg=self.accent_color, + bg=self.card_color + ) + self.drop_label_icon.pack(expand=True, pady=(20, 5)) + + self.drop_label_main = tk.Label( + self.drop_frame, + text="Перетащи сюда папку, файлы или ZIP-архив", + font=("SF Pro Text", 14, "bold"), + fg=self.text_color, + bg=self.card_color + ) + self.drop_label_main.pack(expand=True, pady=(0, 2)) + + self.drop_label_sub = tk.Label( + self.drop_frame, + text="или выбери вручную кнопкой ниже", + font=("SF Pro Text", 11), + fg=self.sub_text_color, + bg=self.card_color + ) + self.drop_label_sub.pack(expand=True, pady=(0, 10)) + + # Buttons in drop frame + btn_frame = tk.Frame(self.drop_frame, bg=self.card_color) + btn_frame.pack(pady=(0, 20)) + + btn_style = { + "font": ("SF Pro Text", 11, "bold"), + "fg": "#ffffff", + "bg": "#3e3e50", + "activebackground": self.accent_color, + "activeforeground": "#ffffff", + "relief": "flat", + "padx": 14, + "pady": 6, + "bd": 0, + "cursor": "pointinghand" + } + + self.btn_browse_folder = tk.Button(btn_frame, text="Выбрать папку", command=self.browse_folder, **btn_style) + self.btn_browse_folder.pack(side="left", padx=6) + + self.btn_browse_zip = tk.Button(btn_frame, text="Выбрать ZIP", command=self.browse_zip, **btn_style) + self.btn_browse_zip.pack(side="left", padx=6) + + # Log & Status area + self.status_lbl = tk.Label( + self.root, + text="Готов к работе", + font=("SF Pro Text", 11), + fg=self.sub_text_color, + bg=self.bg_color + ) + self.status_lbl.pack(fill="x", padx=25, pady=(4, 10)) + + # Register Drag and Drop + if HAS_DND: + self.root.drop_target_register(DND_FILES) + self.root.dnd_bind('<>', self.handle_drop) + self.drop_frame.drop_target_register(DND_FILES) + self.drop_frame.dnd_bind('<>', self.handle_drop) + for widget in (self.drop_label_icon, self.drop_label_main, self.drop_label_sub): + widget.drop_target_register(DND_FILES) + widget.dnd_bind('<>', self.handle_drop) + + def handle_drop(self, event): + raw_data = event.data + paths = self.parse_dnd_paths(raw_data) + if paths: + self.start_processing(paths) + + def parse_dnd_paths(self, dnd_data): + """Parse macOS/TkinterDnD paths, handling curly braces and spaces.""" + paths = [] + cur = [] + in_brace = False + for char in dnd_data: + if char == '{': + in_brace = True + elif char == '}': + in_brace = False + elif char == ' ' and not in_brace: + if cur: + paths.append("".join(cur).strip()) + cur = [] + else: + cur.append(char) + if cur: + paths.append("".join(cur).strip()) + return [p for p in paths if p] + + def browse_folder(self): + selected = filedialog.askdirectory(title="Выберите папку с кракозябрами") + if selected: + self.start_processing([selected]) + + def browse_zip(self): + selected = filedialog.askopenfilename( + title="Выберите ZIP архив", + filetypes=[("ZIP Archives", "*.zip"), ("All Files", "*.*")] + ) + if selected: + self.start_processing([selected]) + + def start_processing(self, paths): + # Disable buttons while running + self.btn_browse_folder.config(state="disabled") + self.btn_browse_zip.config(state="disabled") + self.status_lbl.config(text="Обработка...", fg="#e1a100") + + thread = threading.Thread(target=self._worker, args=(paths,), daemon=True) + thread.start() + + def _worker(self, paths): + total_renamed = 0 + total_extracted = 0 + total_deleted = 0 + all_results = [] + + for p in paths: + def on_progress(old, new): + self.root.after(0, lambda o=old, n=new: self.status_lbl.config( + text=f"Исправлено: {n[:35]}...", fg="#4cd964" + )) + + res = process_path(p, progress_callback=on_progress) + all_results.extend(res) + + for action, old, new in all_results: + if action in ("rename_file", "rename_dir"): + total_renamed += 1 + elif action == "extract_file": + total_extracted += 1 + elif action == "delete": + total_deleted += 1 + + self.root.after(0, lambda: self._on_complete(total_renamed, total_extracted, total_deleted)) + + def _on_complete(self, renamed, extracted, deleted): + self.btn_browse_folder.config(state="normal") + self.btn_browse_zip.config(state="normal") + + msg_parts = [] + if renamed > 0: + msg_parts.append(f"Переименовано элементов: {renamed}") + if extracted > 0: + msg_parts.append(f"Распаковано файлов: {extracted}") + if deleted > 0: + msg_parts.append(f"Удалено мусорных temp-файлов: {deleted}") + + if not msg_parts: + summary = "Ничего менять не потребовалось — все имена уже в порядке!" + self.status_lbl.config(text=summary, fg=self.sub_text_color) + messagebox.showinfo("ZIPName", summary) + else: + summary = "\n".join(msg_parts) + self.status_lbl.config(text="Готово! " + " · ".join(msg_parts), fg="#4cd964") + messagebox.showinfo("ZIPName — Готово", f"Операция успешно завершена:\n\n{summary}") + + +def main(): + if HAS_DND: + root = TkinterDnD.Tk() + else: + root = tk.Tk() + app = ZIPNameApp(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6ffe881 --- /dev/null +++ b/build.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Build script for ZIPName.app on macOS + +set -e + +APP_NAME="ZIPName" +BUNDLE_DIR="dist/${APP_NAME}.app" +MACOS_DIR="${BUNDLE_DIR}/Contents/MacOS" +RESOURCES_DIR="${BUNDLE_DIR}/Contents/Resources" + +echo "=== Сборка ${APP_NAME}.app ===" + +rm -rf dist build +mkdir -p "${MACOS_DIR}" +mkdir -p "${RESOURCES_DIR}" + +# Копируем иконку +if [ -f "ZIPName.icns" ]; then + cp "ZIPName.icns" "${RESOURCES_DIR}/" +fi + +# Копируем исходники в Resources +cp app.py fixer_core.py "${RESOURCES_DIR}/" + +# Создаем Info.plist +cat > "${BUNDLE_DIR}/Contents/Info.plist" << 'EOF' + + + + + CFBundleDisplayName + ZIPName + CFBundleExecutable + ZIPName + CFBundleIconFile + ZIPName.icns + CFBundleIdentifier + sbs.statix.zipname + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ZIPName + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + + +EOF + +# Создаем исполняемый launcher +cat > "${MACOS_DIR}/${APP_NAME}" << 'EOF' +#!/bin/bash +DIR="$(cd "$(dirname "$0")/../Resources" && pwd)" +export PYTHONPATH="$DIR:$PYTHONPATH" +exec /Library/Frameworks/Python.framework/Versions/3.14/bin/python3 "$DIR/app.py" "$@" +EOF + +chmod +x "${MACOS_DIR}/${APP_NAME}" + +echo "Сборка завершена: ${BUNDLE_DIR}" diff --git a/fixer_core.py b/fixer_core.py new file mode 100644 index 0000000..a936678 --- /dev/null +++ b/fixer_core.py @@ -0,0 +1,234 @@ +""" +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