feat: initial release of macZIPfixer (ZIPName.app)
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
@@ -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/
|
||||||
|
```
|
||||||
Binary file not shown.
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user