文件名处理

脚本批量按表修改文件名。


# -*- coding: utf-8 -*-
"""
文件批量命名工作流

第一步:选择目录,递归扫描文件
第二步:导出 TXT 文件清单
第三步:自动生成 XLSX 人工核对表
第四步:人工填写 B 列新文件名
第五步:重新选择 XLSX,预览并批量重命名

A列:原文件名
B列:新文件名
C列:原文件完整路径(辅助定位)
"""

import os
import csv
import uuid
import tkinter as tk

from pathlib import Path
from datetime import datetime
from collections import defaultdict
from tkinter import filedialog, messagebox, ttk

from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter


class RenameTool:

    def __init__(self, root):
        self.root = root
        self.root.title("Excel 批量文件命名工具")
        self.root.geometry("950x720")

        self.folder = ""
        self.xlsx = ""
        self.plans = []
        self.issues = []

        self.build_ui()

    def build_ui(self):

        main = ttk.Frame(self.root, padding=15)
        main.pack(fill="both", expand=True)

        ttk.Label(
            main,
            text="文件批量命名工作流",
            font=("Microsoft YaHei", 16, "bold")
        ).pack(anchor="w", pady=10)

        ttk.Label(
            main,
            text="第一步:选择需要整理的目录,导出 TXT 和 XLSX"
        ).pack(anchor="w")

        row1 = ttk.Frame(main)
        row1.pack(fill="x", pady=6)

        ttk.Button(
            row1,
            text="① 选择目录并导出清单",
            command=self.export_files
        ).pack(side="left")

        ttk.Label(
            main,
            text="第二步:选择人工核对、修改后的 XLSX 表格"
        ).pack(anchor="w", pady=(20, 0))

        row2 = ttk.Frame(main)
        row2.pack(fill="x", pady=6)

        self.xlsx_label = ttk.Label(
            row2,
            text="尚未选择 XLSX 文件"
        )
        self.xlsx_label.pack(side="left", fill="x", expand=True)

        ttk.Button(
            row2,
            text="选择 XLSX",
            command=self.select_xlsx
        ).pack(side="left", padx=5)

        row3 = ttk.Frame(main)
        row3.pack(fill="x", pady=12)

        ttk.Button(
            row3,
            text="② 预览重命名",
            command=self.preview
        ).pack(side="left", padx=5)

        ttk.Button(
            row3,
            text="③ 确认批量改名",
            command=self.execute
        ).pack(side="left", padx=5)

        ttk.Button(
            row3,
            text="清空日志",
            command=self.clear_log
        ).pack(side="left", padx=5)

        self.status = ttk.Label(
            main,
            text="等待操作"
        )
        self.status.pack(anchor="w", pady=5)

        self.output = tk.Text(
            main,
            font=("Consolas", 10),
            wrap="none"
        )
        self.output.pack(fill="both", expand=True)

        scroll = ttk.Scrollbar(
            self.output,
            orient="vertical",
            command=self.output.yview
        )
        scroll.pack(side="right", fill="y")

        self.output.configure(
            yscrollcommand=scroll.set
        )

    def log(self, msg):
        self.output.insert("end", msg + "\n")
        self.output.see("end")
        self.root.update_idletasks()

    def clear_log(self):
        self.output.delete("1.0", "end")

    # ==========================
    # 第一步:导出 TXT 和 XLSX
    # ==========================

    def export_files(self):

        folder = filedialog.askdirectory(
            title="选择需要扫描的文件夹"
        )

        if not folder:
            return

        self.folder = str(Path(folder).resolve())

        root_path = Path(self.folder)

        self.clear_log()

        self.log("开始递归扫描目录...")

        files = []

        excluded = {
            "文件清单.txt",
            "重命名核对表.xlsx"
        }

        for current, dirs, filenames in os.walk(root_path):

            # 排除常见隐藏目录
            dirs[:] = [
                d for d in dirs
                if d not in {"$RECYCLE.BIN", "System Volume Information"}
            ]

            for filename in filenames:

                if filename in excluded:
                    continue

                path = Path(current) / filename

                try:
                    if path.is_file():
                        files.append(path.resolve())
                except OSError:
                    continue

        files.sort(key=lambda p: str(p).lower())

        if not files:
            messagebox.showwarning(
                "提示",
                "目录中没有找到文件"
            )
            return

        txt_path = root_path / "文件清单.txt"
        xlsx_path = root_path / "重命名核对表.xlsx"

        try:

            # 导出 TXT
            with open(
                txt_path,
                "w",
                encoding="utf-8-sig"
            ) as f:

                for path in files:
                    f.write(str(path) + "\n")

            # 生成 XLSX
            wb = Workbook()
            ws = wb.active
            ws.title = "重命名核对表"

            headers = [
                "原文件名",
                "新文件名(人工填写)",
                "原文件完整路径"
            ]

            ws.append(headers)

            for path in files:

                ws.append([
                    path.name,
                    "",
                    str(path)
                ])

            # 表格样式
            header_fill = PatternFill(
                "solid",
                fgColor="2878B5"
            )

            for cell in ws[1]:
                cell.font = Font(
                    bold=True,
                    color="FFFFFF"
                )
                cell.fill = header_fill
                cell.alignment = Alignment(
                    horizontal="center"
                )

            ws.column_dimensions["A"].width = 35
            ws.column_dimensions["B"].width = 40
            ws.column_dimensions["C"].width = 90

            ws.freeze_panes = "A2"
            ws.auto_filter.ref = ws.dimensions

            # B列设置醒目的人工填写颜色
            for row in range(2, len(files) + 2):
                ws.cell(row, 2).fill = PatternFill(
                    "solid",
                    fgColor="FFF2CC"
                )

            wb.save(xlsx_path)
            wb.close()

            self.xlsx = str(xlsx_path)
            self.xlsx_label.config(
                text=str(xlsx_path)
            )

            self.log("========== 导出完成 ==========")
            self.log(f"扫描目录:{root_path}")
            self.log(f"文件总数:{len(files)}")
            self.log(f"TXT:{txt_path}")
            self.log(f"XLSX:{xlsx_path}")
            self.log("")
            self.log("请打开 XLSX,人工填写 B 列新文件名。")
            self.log("A 列和 C 列不要修改。")

            self.status.config(
                text=f"清单导出完成,共 {len(files)} 个文件"
            )

            messagebox.showinfo(
                "导出完成",
                f"扫描文件:{len(files)} 个\n\n"
                f"已生成:\n"
                f"1. 文件清单.txt\n"
                f"2. 重命名核对表.xlsx\n\n"
                f"请人工填写 Excel 的 B 列。"
            )

        except Exception as e:

            messagebox.showerror(
                "导出失败",
                str(e)
            )

    # ==========================
    # 第二步:选择 XLSX
    # ==========================

    def select_xlsx(self):

        path = filedialog.askopenfilename(
            title="选择人工核对后的 XLSX",
            filetypes=[("Excel 文件", "*.xlsx")]
        )

        if path:
            self.xlsx = path
            self.xlsx_label.config(text=path)

    # ==========================
    # 读取 Excel
    # ==========================

    def read_excel(self):

        if not self.xlsx:
            raise ValueError("请先选择 XLSX 文件")

        wb = load_workbook(
            self.xlsx,
            read_only=True,
            data_only=True
        )

        ws = wb.active

        plans = []
        issues = []

        try:

            for row_num, row in enumerate(
                ws.iter_rows(
                    min_row=2,
                    min_col=1,
                    max_col=3,
                    values_only=True
                ),
                start=2
            ):

                old, new, full_path = row

                if old is None:
                    continue

                old = str(old).strip()

                if not old:
                    continue

                # B列为空,视为不需要改名
                if new is None or not str(new).strip():
                    continue

                new = str(new).strip()

                # 优先使用 C 列完整路径定位
                if full_path and str(full_path).strip():
                    source = Path(str(full_path).strip())
                else:
                    issues.append(
                        f"[缺少原路径] Excel 第 {row_num} 行"
                    )
                    continue

                # 防止路径或文件名非法
                if (
                    Path(new).name != new
                    or "/" in new
                    or "\\" in new
                    or any(c in new for c in '<>:"|?*')
                    or new in {".", ".."}
                ):
                    issues.append(
                        f"[新文件名非法] 第 {row_num} 行:{new}"
                    )
                    continue

                # 如果新名称没有扩展名,保留原扩展名
                if not Path(new).suffix:
                    new += source.suffix

                target = source.with_name(new)

                if source.name != old:
                    issues.append(
                        f"[原文件名与路径不一致] {source}"
                    )
                    continue

                if not source.is_file():
                    issues.append(
                        f"[文件不存在] {source}"
                    )
                    continue

                if source == target:
                    issues.append(
                        f"[无需改名] {old}"
                    )
                    continue

                plans.append({
                    "old": old,
                    "new": new,
                    "source": source,
                    "target": target,
                    "row": row_num
                })

        finally:
            wb.close()

        return plans, issues

    # ==========================
    # 第三步:预览
    # ==========================

    def preview(self):

        self.clear_log()

        try:

            plans, issues = self.read_excel()

            self.issues = issues

            # 检查目标路径重复
            target_groups = defaultdict(list)

            for p in plans:
                key = os.path.normcase(
                    os.path.abspath(str(p["target"]))
                )
                target_groups[key].append(p)

            duplicate_targets = {
                key for key, group in target_groups.items()
                if len(group) > 1
            }

            source_set = {
                os.path.normcase(
                    os.path.abspath(str(p["source"]))
                )
                for p in plans
            }

            valid = []

            for p in plans:

                target_key = os.path.normcase(
                    os.path.abspath(str(p["target"]))
                )

                if target_key in duplicate_targets:
                    issues.append(
                        f"[目标文件名重复] {p['new']}"
                    )
                    continue

                # 已存在的目标文件,如果不是本批次参与改名的源文件则跳过
                if (
                    p["target"].exists()
                    and target_key not in source_set
                ):
                    issues.append(
                        f"[目标文件已存在] {p['target']}"
                    )
                    continue

                valid.append(p)

            self.plans = valid

            self.log("========== 重命名预览 ==========")

            for p in valid:
                self.log(
                    f"[待改名] {p['old']}  ->  {p['new']}"
                )

            for issue in issues:
                self.log(issue)

            self.log("--------------------------------")
            self.log(f"待改名:{len(valid)}")
            self.log(f"跳过/异常:{len(issues)}")

            self.status.config(
                text=f"预览完成:{len(valid)} 个待改名"
            )

        except Exception as e:

            messagebox.showerror(
                "读取失败",
                str(e)
            )

    # ==========================
    # 第四步:执行改名
    # ==========================

    def execute(self):

        if not self.plans:
            messagebox.showwarning(
                "提示",
                "请先预览重命名清单"
            )
            return

        if not messagebox.askyesno(
            "确认执行",
            f"本次准备修改 {len(self.plans)} 个文件。\n\n"
            "请确认 Excel 已经核对无误。\n"
            "建议提前备份原文件。\n\n"
            "确定开始吗?"
        ):
            return

        staged = []
        completed = []

        try:

            # 第一阶段:全部改成临时名称
            # 允许批量交换文件名,例如 A->B、B->A

            for p in self.plans:

                source = p["source"]

                if not source.is_file():
                    raise FileNotFoundError(
                        f"源文件不存在:{source}"
                    )

                temp = source.with_name(
                    "__rename_tmp_"
                    + uuid.uuid4().hex
                    + ".tmp"
                )

                source.rename(temp)

                staged.append({
                    "temp": temp,
                    "source": source,
                    "target": p["target"],
                    "plan": p
                })

            # 第二阶段:临时文件改成最终名称

            for item in staged:

                item["temp"].rename(item["target"])
                completed.append(item)

            self.log("")
            self.log("========== 全部改名成功 ==========")

            for item in completed:
                self.log(
                    f"[成功] {item['plan']['old']} "
                    f"-> {item['plan']['new']}"
                )

            self.save_log(completed, "成功")

            self.status.config(
                text=f"执行完成:成功 {len(completed)} 个"
            )

            messagebox.showinfo(
                "完成",
                f"批量重命名完成!\n\n"
                f"成功:{len(completed)} 个文件\n"
                f"日志已保存到原目录。"
            )

            self.plans = []

        except Exception as e:

            self.log(f"[执行失败] {e}")
            self.log("[正在尝试回滚]")

            # 回滚已经完成的目标文件和临时文件
            for item in reversed(staged):

                try:

                    if item["target"].exists():
                        item["target"].rename(item["source"])

                    elif item["temp"].exists():
                        item["temp"].rename(item["source"])

                except Exception as rollback_error:

                    self.log(
                        f"[回滚失败] {item['source']}:"
                        f"{rollback_error}"
                    )

            messagebox.showerror(
                "执行失败",
                f"{e}\n\n已尝试回滚,请检查日志和文件。"
            )

            self.status.config(
                text="执行失败,请检查日志"
            )

    # ==========================
    # 保存日志
    # ==========================

    def save_log(self, completed, status):

        if self.folder:
            output_dir = Path(self.folder)
        else:
            output_dir = Path(self.xlsx).parent

        log_path = output_dir / (
            "批量重命名日志_"
            + datetime.now().strftime("%Y%m%d_%H%M%S")
            + ".csv"
        )

        with open(
            log_path,
            "w",
            newline="",
            encoding="utf-8-sig"
        ) as f:

            writer = csv.writer(f)

            writer.writerow([
                "原文件名",
                "新文件名",
                "原文件路径",
                "新文件路径",
                "状态"
            ])

            for item in completed:

                p = item["plan"]

                writer.writerow([
                    p["old"],
                    p["new"],
                    str(item["source"]),
                    str(item["target"]),
                    status
                ])

        self.log(f"[日志] {log_path}")


if __name__ == "__main__":

    root = tk.Tk()
    app = RenameTool(root)
    root.mainloop()