feat: initial release of macZIPfixer (ZIPName.app)

This commit is contained in:
statix05
2026-09-08 14:15:52 +03:00
commit ce6da1937b
6 changed files with 582 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.pyc
build/
dist/
.DS_Store
+28
View File
@@ -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/
```
BIN
View File
Binary file not shown.
+248
View File
@@ -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('<<Drop>>', self.handle_drop)
self.drop_frame.drop_target_register(DND_FILES)
self.drop_frame.dnd_bind('<<Drop>>', 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('<<Drop>>', 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()
Executable
+67
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>ZIPName</string>
<key>CFBundleExecutable</key>
<string>ZIPName</string>
<key>CFBundleIconFile</key>
<string>ZIPName.icns</string>
<key>CFBundleIdentifier</key>
<string>sbs.statix.zipname</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ZIPName</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
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}"
+234
View File
@@ -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