背景

https://www.halo.run/store/apps/app-YGkgz

Sakura主题因为使用了vite构建,所以会产生一个黑洞,打包后的文件很大,需要二次删除一些不必要的文件,而且有些压缩会造成主题安装失败的问题。

考虑过在package.json中添加脚本来打包,但不太想破坏原有结构,而且还想方便其他主题,所以python脚本。

代码

core

import os
import zipfile
import logging
import fnmatch
from pathlib import Path
from datetime import datetime


def setup_base_logger(name):
    """
    设置并返回一个基础日志记录器。

    参数:
    name (str): 日志记录器的名称。

    返回值:
    logging.Logger: 配置好的日志记录器对象。
    """
    logger = logging.getLogger(name)
    logger.setLevel(logging.DEBUG)
    return logger


def load_ignore_rules(ignore_file, logger):
    """
    从指定的忽略文件中加载排除规则。

    参数:
    ignore_file (str): 包含排除规则的文件路径。
    logger (logging.Logger): 用于记录日志的日志记录器。

    返回值:
    list: 加载的排除规则列表,如果文件不存在或读取失败则返回空列表。
    """
    if not os.path.exists(ignore_file):
        logger.debug(f"未找到配置文件: {ignore_file}")
        return []
    try:
        with open(ignore_file, 'r', encoding='utf-8') as f:
            rules = [
                line.strip().replace(os.sep, '/')
                for line in f
                if line.strip() and not line.startswith('#')
            ]
            logger.info(f"已加载 {len(rules)} 条排除规则 from {ignore_file}")
            return rules
    except Exception as e:
        error_msg = f"读取配置文件失败: 文件路径 {ignore_file}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)
        return []


def should_exclude(rel_path: str, exclude_list: list, logger: logging.Logger) -> bool:
    """
    判断给定的相对路径是否应该被排除。

    参数:
    rel_path (str): 需要检查的相对路径。
    exclude_list (list): 排除规则列表。
    logger (logging.Logger): 用于记录日志的日志记录器。

    返回值:
    bool: 如果路径匹配任何排除规则则返回True,否则返回False。
    """
    rel_path = rel_path.replace(os.sep, '/')

    def matches_pattern(pattern: str, path: str) -> bool:
        pattern = pattern.rstrip('/')
        if pattern.endswith('/'):
            return path.startswith(pattern) or f'/{pattern}' in path
        if '*' in pattern:
            return fnmatch.fnmatch(path, pattern)
        return path == pattern or path.startswith(pattern + '/')

    for pattern in exclude_list:
        if matches_pattern(pattern, rel_path):
            logger.debug(f"排除匹配: [模式] {pattern} ← [路径] {rel_path}")
            return True
    return False


def filter_dirs(dirs, rel_root, all_excludes, logger):
    original_count = len(dirs)
    dirs[:] = [d for d in dirs if not should_exclude(f"{rel_root}/{d}" if rel_root != '.' else d, all_excludes, logger)]
    if len(dirs) != original_count:
        logger.debug(f"目录过滤: {rel_root} ({original_count}→{len(dirs)})")


def filter_files(files, root, source_dir, all_excludes, logger):
    filtered_files = []
    for file in files:
        file_path = Path(root) / file
        rel_path = file_path.relative_to(source_dir).as_posix()
        if not should_exclude(rel_path, all_excludes, logger):
            filtered_files.append(file)
    return filtered_files


def add_file_to_zip(zipf, file_path, rel_path, logger, included_files):
    try:
        zipf.write(file_path, rel_path)
        if included_files % 100 == 0:
            logger.info(f"已处理 {included_files} 个文件")
    except zipfile.BadZipFile as e:
        error_msg = f"ZIP文件格式错误: 文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)
    except PermissionError as e:
        error_msg = f"权限不足: 无法添加文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)
    except Exception as e:
        error_msg = f"添加文件失败: 文件 {rel_path}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)


def create_zip(source_dir, output_zip, exclude_list=None, ignore_file='.zipignore', logger=None):
    """
    将指定目录压缩为ZIP文件,并根据排除规则过滤文件和目录。

    参数:
    source_dir (str): 需要压缩的源目录路径。
    output_zip (str): 输出的ZIP文件路径。如果未指定文件名,则使用source_dir的文件名。
    exclude_list (list, optional): 手动指定的排除规则列表,默认为None。
    ignore_file (str, optional): 包含排除规则的文件路径,默认为'.zipignore'。
    logger (logging.Logger, optional): 用于记录日志的日志记录器,默认为None。

    返回值:
    无返回值,但会在压缩过程中记录日志,并在发生错误时抛出异常。
    """
    if exclude_list is None:
        exclude_list = []
    if logger is None:
        logger = setup_base_logger('ZipCore')

    start_time = datetime.now()
    source_dir = Path(source_dir).resolve()

    # 处理 output_zip 路径
    output_zip_path = Path(output_zip)
    if output_zip_path.is_dir():
        # 如果 output_zip 是一个目录,使用 source_dir 的文件名生成 ZIP 文件名
        source_name = Path(source_dir).name
        output_zip = output_zip_path / f"{source_name}.zip"
    else:
        # 如果 output_zip 不是目录,直接使用其值
        output_zip = output_zip_path

    # 确保 output_zip 的父目录存在
    output_zip.parent.mkdir(parents=True, exist_ok=True)

    logger.info("=" * 50)
    logger.info(f"开始压缩任务: {source_dir} → {output_zip}")
    logger.info(f"启动时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
    logger.info(f"排除参数: {exclude_list}")

    ignore_file_path = Path(ignore_file)
    if not ignore_file_path.is_absolute() and not ignore_file_path.exists():
        possible_path = source_dir / ignore_file
        if possible_path.exists():
            ignore_file_path = possible_path

    config_ignore = load_ignore_rules(ignore_file_path, logger)
    all_excludes = list(set(exclude_list + config_ignore))
    logger.info(f"总排除规则数: {len(all_excludes)}")

    total_files = 0
    included_files = 0

    try:
        with zipfile.ZipFile(str(output_zip), 'w', zipfile.ZIP_DEFLATED, allowZip64=True) as zipf:
            for root, dirs, files in os.walk(source_dir):
                rel_root = Path(root).relative_to(source_dir).as_posix()
                filter_dirs(dirs, rel_root, all_excludes, logger)
                filtered_files = filter_files(files, root, source_dir, all_excludes, logger)
                for file in filtered_files:
                    total_files += 1
                    file_path = Path(root) / file
                    rel_path = file_path.relative_to(source_dir).as_posix()
                    included_files += 1
                    add_file_to_zip(zipf, file_path, rel_path, logger, included_files)

        end_time = datetime.now()
        duration = end_time - start_time
        logger.info("压缩任务完成")
        logger.info(f"扫描文件总数: {total_files}")
        logger.info(f"实际包含文件: {included_files}")
        logger.info(f"排除文件数量: {total_files - included_files}")
        logger.info(f"耗时: {duration.total_seconds():.2f} 秒")
        logger.info(f"输出文件大小: {Path(output_zip).stat().st_size / 1024 / 1024:.2f} MB")

    except zipfile.LargeZipFile as e:
        error_msg = f"ZIP文件过大错误: 输出文件 {output_zip}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)
        raise
    except PermissionError as e:
        error_msg = f"权限不足: 无法创建/写入ZIP文件 {output_zip}, 错误类型 {type(e).__name__}, 错误详情 {str(e)}"
        logger.error(error_msg)
        raise
    except Exception as e:
        error_msg = f"压缩过程发生严重错误: 类型 {type(e).__name__}, 错误详情: {str(e)}"
        logger.error(error_msg)
        raise

gui

import logging
import threading
import tkinter as tk
from pathlib import Path
from tkinter import filedialog, messagebox, scrolledtext
from tkinter import ttk
from core import setup_base_logger, create_zip


def setup_logger(text_widget):
    """
    设置GUI应用程序的日志记录器,将日志输出到指定的文本控件中。

    参数:
    text_widget (tk.Text): 用于显示日志的文本控件。

    返回值:
    logging.Logger: 配置好的日志记录器。
    """
    logger = setup_base_logger('ZipToolGUI')
    logger.setLevel(logging.DEBUG)

    class TextHandler(logging.Handler):
        def emit(self, record):
            """将日志消息格式化并插入到文本控件中。"""
            msg = self.format(record)
            text_widget.configure(state='normal')
            text_widget.insert(tk.END, msg + '\n')
            text_widget.see(tk.END)
            text_widget.configure(state='disabled')

    formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', "%Y-%m-%d %H:%M:%S")
    handler = TextHandler()
    handler.setFormatter(formatter)
    logger.addHandler(handler)
    return logger


def launch_gui():
    """
    启动GUI应用程序,提供文件压缩功能。

    该函数创建了一个包含源目录、输出文件、忽略文件选择和日志显示的用户界面。
    """
    def start_zip():
        """启动压缩过程,根据用户输入调用压缩函数。"""
        src = src_entry.get()
        dst = dst_entry.get()
        ign = ign_entry.get()
        if not src:
            messagebox.showwarning("输入错误", "请输入源目录")
            return
        if not dst:
            src_path = Path(src).resolve()
            dst_path = src_path.parent / f"{src_path.name}.zip"
            dst_entry.insert(0, str(dst_path))
            dst = str(dst_path)
        threading.Thread(
            target=create_zip,
            kwargs={
                'source_dir': src,
                'output_zip': dst,
                'ignore_file': ign,
                'logger': logger
            },
            daemon=True
        ).start()

    def browse_src():
        """打开文件对话框,选择源目录并自动填充输出文件路径。"""
        path = filedialog.askdirectory()
        if path:
            src_entry.delete(0, tk.END)
            src_entry.insert(0, path)
            dst_path = Path(path).resolve().parent / f"{Path(path).resolve().name}.zip"
            dst_entry.delete(0, tk.END)
            dst_entry.insert(0, str(dst_path))

    def browse_dst():
        """打开文件对话框,选择输出文件路径。"""
        path = filedialog.asksaveasfilename(defaultextension=".zip",
                                            filetypes=[("ZIP 文件", "*.zip")])
        if path:
            dst_entry.delete(0, tk.END)
            dst_entry.insert(0, path)

    def browse_ign():
        """打开文件对话框,选择忽略规则文件。"""
        path = filedialog.askopenfilename(filetypes=[("忽略文件", "*.zipignore"), ("所有文件", "*.*")])
        if path:
            ign_entry.delete(0, tk.END)
            ign_entry.insert(0, path)

    # 创建主窗口并设置基本属性
    root = tk.Tk()
    root.title("ZIP 压缩工具 - GUI 版")
    root.geometry("700x550")
    root.configure(bg="#f0f0f0")

    # 设置ttk主题样式
    style = ttk.Style()
    style.theme_use('clam')
    style.configure("TButton", padding=6, font=("Helvetica", 10))
    style.configure("TLabel", font=("Helvetica", 10), background="#f0f0f0")
    style.configure("TEntry", padding=5)

    # 创建主框架并布局
    main_frame = ttk.Frame(root, padding="15")
    main_frame.grid(row=0, column=0, sticky='nsew')
    root.grid_rowconfigure(0, weight=1)
    root.grid_columnconfigure(0, weight=1)

    # 源目录选择控件
    ttk.Label(main_frame, text="压缩文件:").grid(row=0, column=0, sticky='e', pady=5)
    src_entry = ttk.Entry(main_frame, width=50)
    src_entry.grid(row=0, column=1, pady=5)
    src_button = ttk.Button(main_frame, text="浏览", command=browse_src)
    src_button.grid(row=0, column=2, padx=5, pady=5)
    # 鼠标悬停提示
    src_button.bind('<Enter>', lambda e: src_button.config(text="选择要压缩的文件"))
    src_button.bind('<Leave>', lambda e: src_button.config(text="浏览"))

    # 输出文件选择控件
    ttk.Label(main_frame, text="输出文件:").grid(row=1, column=0, sticky='e', pady=5)
    dst_entry = ttk.Entry(main_frame, width=50)
    dst_entry.grid(row=1, column=1, pady=5)
    dst_button = ttk.Button(main_frame, text="浏览", command=browse_dst)
    dst_button.grid(row=1, column=2, padx=5, pady=5)
    # 鼠标悬停提示
    dst_button.bind('<Enter>', lambda e: dst_button.config(text="选择保存ZIP文件的位置"))
    dst_button.bind('<Leave>', lambda e: dst_button.config(text="浏览"))

    # 忽略文件选择控件
    ttk.Label(main_frame, text="忽略文件:").grid(row=2, column=0, sticky='e', pady=5)
    ign_entry = ttk.Entry(main_frame, width=50)
    ign_entry.grid(row=2, column=1, pady=5)
    ign_button = ttk.Button(main_frame, text="浏览", command=browse_ign)
    ign_button.grid(row=2, column=2, padx=5, pady=5)
    # 鼠标悬停提示
    ign_button.bind('<Enter>', lambda e: ign_button.config(text="选择忽略规则文件"))
    ign_button.bind('<Leave>', lambda e: ign_button.config(text="浏览"))

    # 开始压缩按钮
    start_button = ttk.Button(main_frame, text="开始压缩", command=start_zip, style="Accent.TButton")
    style.configure("Accent.TButton", font=("Helvetica", 11, "bold"), background="#4CAF50", foreground="white")
    start_button.grid(row=3, column=1, pady=20)

    # 日志显示区域
    log_frame = ttk.Frame(main_frame)
    log_frame.grid(row=4, column=0, columnspan=3, sticky='nsew')
    log_text = scrolledtext.ScrolledText(log_frame, height=15, state='disabled', font=("Courier", 9), wrap=tk.WORD)
    log_text.pack(fill='both', expand=True, padx=5, pady=5)

    main_frame.grid_rowconfigure(4, weight=1)
    main_frame.grid_columnconfigure(1, weight=1)

    global logger
    logger = setup_logger(log_text)

    root.mainloop()


if __name__ == '__main__':
    launch_gui()

cli

import argparse
import logging
from pathlib import Path

from core import setup_base_logger, create_zip


def setup_logger(output_zip):
    """
    为CLI应用程序设置扩展的日志记录器。

    该函数配置了一个日志记录器,用于记录应用程序的运行日志。日志会同时输出到控制台和指定的日志文件中。

    参数:
        output_zip (str): 输出的ZIP文件路径,日志文件将与ZIP文件同名,但扩展名为.log。

    返回值:
        logging.Logger: 配置好的日志记录器对象。
    """
    logger = setup_base_logger('ZipTool')
    log_path = Path(output_zip).with_suffix('.log')
    file_handler = logging.FileHandler(log_path, encoding='utf-8')
    file_handler.setLevel(logging.DEBUG)
    console_handler = logging.StreamHandler()
    console_handler.setLevel(logging.INFO)
    formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
    file_handler.setFormatter(formatter)
    console_handler.setFormatter(formatter)
    logger.addHandler(file_handler)
    logger.addHandler(console_handler)
    return logger


def main():
    """
    主函数,用于解析命令行参数并执行ZIP压缩操作。

    该函数解析命令行参数,配置日志记录器,并调用核心函数进行ZIP压缩。如果发生异常,程序将记录错误并退出。
    """
    parser = argparse.ArgumentParser(description='带日志记录的ZIP压缩工具')
    parser.add_argument('source_dir', help='要压缩的源目录')
    parser.add_argument('output_zip', nargs='?', default=None,
                        help='输出的ZIP文件路径(默认为源目录同名ZIP文件,位于同级目录)')
    parser.add_argument('--exclude', nargs='+', default=[],
                        help='命令行排除规则(可选)')
    parser.add_argument('--ignore-file', default='.zipignore',
                        help='指定配置文件(默认为 .zipignore)')
    parser.add_argument('--verbose', action='store_true',
                        help='显示详细日志')
    args = parser.parse_args()

    try:
        # 如果未指定输出ZIP文件路径,则默认使用源目录的同名ZIP文件
        output_zip = args.output_zip
        if output_zip is None:
            source_dir = Path(args.source_dir).resolve()
            output_zip = source_dir.parent / f"{source_dir.name}.zip"

        # 调用核心函数进行ZIP压缩,并传递日志记录器
        create_zip(
            source_dir=args.source_dir,
            output_zip=output_zip,
            exclude_list=args.exclude,
            ignore_file=args.ignore_file,
            logger=setup_logger(output_zip)
        )
    except Exception as e:
        # 捕获并记录异常,程序异常终止
        logging.error(f"程序异常终止: {str(e)}")
        exit(1)


if __name__ == '__main__':
    main()

zipignore示例

.git
.gitignore
.github
.idea
.vscode
.husky
.all-contributorsrc
.editorconfig
.env
.gitignore
.zipignore
docs
node_modules
src
package.json
pnpm-lock.yaml
postcss.config.js
prettier.config.js
README.md
screenshot.png
tsconfig.json
vite.config.ts
vite-env.d.ts

使用

cli

python cli.py .\halo-theme-sakura\  --ignore-file .\halo-theme-sakura\.zipignore
python cli.py .\halo-theme-sakura\  ..\ --ignore-file .\halo-theme-sakura\.zipignore
python cli.py .\halo-theme-sakura\  .\halo-theme-sakura.2.4.3.3.zip --exclude .git .github .idea ...

gui

2025-04-25-lkjf.webp

下载

gui