背景

在重构https://www.halo.run/store/apps/app-ZxiPb时为主题设置添加 id key 字段,由于太多,眼睛与手忙不过来,于是使用脚本。

重构原因见 PR

功能简介

本工具用于自动化处理 Halo 主题的 settings.yamlannotation-setting.yaml 配置文件,自动为表单元素添加 idkey 字段。

支持的文件类型

  • Setting 类型 apiVersion: v1alpha1, kind: Setting

  • AnnotationSetting 类型 apiVersion: v1alpha1, kind: AnnotationSetting

使用方法

1. 图形界面操作

1. 启动程序:双击运行程序。

2. 选择文件方式

  • 点击"选择YAML文件"按钮选取文件。

  • 或直接将文件拖拽到拖放区域。

3. 确认处理:在弹出的确认对话框中选择"是"。

4. 查看结果:处理后的文件将-modified.yaml后缀保存到原文件同目录。

2. 处理效果

程序会自动为每个表单元素添加:

  • id 字段:值与 name 相同。

  • key 字段:值与 name 相同。

使用示例

处理前 settings.yaml 片段

- $formkit: select
  name: theme_mode
  label: 主题模式

处理后 settings.yaml 片段

- $formkit: select
  name: theme_mode
  id: theme_mode
  key: theme_mode
  label: 主题模式

注意事项

  1. 仅支持标准的 YAML 格式文件 .yaml.yml 后缀)。

  2. 文件开头必须包含正确的 apiVersionkind 声明。

  3. 多文档 YAML 文件 (使用 --- 分隔) 也能正常处理。

  4. 处理前会自动备份原文件 (添加 -modified 后缀)。

常见问题

Q: 为什么我的文件无法被识别?

A: 请检查:

  • 文件扩展名是否为 .yaml.yml

  • 文件开头是否包含正确的 apiVersionkind 声明。

  • 文件内容是否为有效的 YAML 格式。

Q: 处理后的文件保存在哪里?

A: 处理后的文件会保存在原文件同一目录下,文件名会增加 -modified 后缀。

Q: 支持批量处理多个文件吗?

A: 当前版本仅支持单个文件处理,如需批量处理请多次操作。

技术说明

  • 使用 Python 3 开发。

  • 基于 ruamel.yaml 库处理 YAML 文件。

  • 图形界面使用 Tkinter 实现。

代码

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from tkinterdnd2 import DND_FILES, TkinterDnD
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
import os

# 预期的文件开头内容
VALID_STARTS = [
    {'apiVersion': 'v1alpha1', 'kind': 'Setting'},
    {'apiVersion': 'v1alpha1', 'kind': 'AnnotationSetting'}
]


# 校验YAML文件开头是否符合要求
def validate_yaml_start(file_path):
    """
    校验YAML文件的开头内容是否符合预期。

    :param file_path: str 文件路径
    :return: bool 文件开头内容是否符合预期
    """
    try:
        yaml = YAML()
        with open(file_path, 'r', encoding='utf-8') as file:
            # 处理多文档情况,只检查第一个文档
            documents = list(yaml.load_all(file))
            if not documents:
                return False

            data = documents[0]

        # 检查是否符合任一有效格式
        for valid_start in VALID_STARTS:
            if all(data.get(key) == value for key, value in valid_start.items()):
                return True
        return False
    except Exception:
        return False


# 处理单个表单项的函数
def process_form_item(item):
    """
    处理单个表单项,为其添加ID和KEY。

    :param item: dict 表单项
    :return: dict 处理后的表单项
    """
    if isinstance(item, dict):
        if 'name' in item:
            name = item['name']
            item['id'] = name
            item['key'] = name
            new_item = CommentedMap()
            if '$formkit' in item:
                new_item['$formkit'] = item['$formkit']
            new_item['name'] = item['name']
            new_item['id'] = item['id']
            new_item['key'] = item['key']
            for key in item:
                if key not in ['$formkit', 'name', 'id', 'key']:
                    new_item[key] = item[key]
            item = new_item
        if 'children' in item:
            item['children'] = [process_form_item(child) for child in item['children']]
    return item


# 处理YAML文件的函数
def process_yaml(file_path):
    """
    处理YAML文件,修改其中的表单项。

    :param file_path: str 文件路径
    """
    try:
        if not validate_yaml_start(file_path):
            messagebox.showerror("错误", "文件格式不是 settings 或 annotationSetting")
            status_bar.config(text="错误: 文件格式不符合要求")
            return

        yaml = YAML()
        yaml.preserve_quotes = True
        yaml.width = 4096

        with open(file_path, 'r', encoding='utf-8') as file:
            documents = list(yaml.load_all(file))

        for doc in documents:
            if doc['kind'] == 'Setting' and 'forms' in doc['spec']:
                for form_group in doc['spec']['forms']:
                    if 'formSchema' in form_group:
                        form_group['formSchema'] = [process_form_item(item) for item in form_group['formSchema']]
            elif doc['kind'] == 'AnnotationSetting' and 'formSchema' in doc['spec']:
                doc['spec']['formSchema'] = [process_form_item(item) for item in doc['spec']['formSchema']]

        # 生成新的文件名
        base, ext = os.path.splitext(file_path)
        new_file_path = f"{base}-modified{ext}"

        with open(new_file_path, 'w', encoding='utf-8') as file:
            yaml.dump_all(documents, file)

        messagebox.showinfo("成功", f"文件已处理并保存为 {new_file_path}")
        status_bar.config(text=f"处理完成: {os.path.basename(file_path)}")
    except Exception as e:
        messagebox.showerror("错误", f"处理文件失败: {str(e)}")
        status_bar.config(text=f"处理失败: {os.path.basename(file_path)}")


# 处理文件拖放的函数
def on_drop(event):
    """
    处理文件拖放事件。

    :param event: 拖放事件
    """
    file_path = event.data.strip('{}')
    process_file(file_path)


# 选择文件的函数
def select_file():
    """
    选择文件对话框,用于选取要处理的YAML文件。
    """
    file_path = filedialog.askopenfilename(
        title="选择YAML文件",
        filetypes=[("YAML文件", "*.yaml *.yml"), ("所有文件", "*.*")]
    )
    if file_path:
        process_file(file_path)


# 统一处理文件的函数
def process_file(file_path):
    """
    统一处理文件,验证并调用处理YAML的函数。

    :param file_path: str 文件路径
    """
    if not (file_path.endswith('.yaml') or file_path.endswith('.yml')):
        messagebox.showerror("错误", "仅支持YAML文件")
        status_bar.config(text="错误: 仅支持YAML文件")
        return

    status_bar.config(text=f"正在验证文件: {os.path.basename(file_path)}")

    if not validate_yaml_start(file_path):
        messagebox.showerror("错误", "文件格式不是 settings 或 annotationSetting")
        status_bar.config(text="错误: 文件格式不符合要求")
        return

    status_bar.config(text=f"已选择文件: {os.path.basename(file_path)}")
    confirm = messagebox.askyesno("确认", f"是否要处理文件 {file_path}?")
    if confirm:
        process_yaml(file_path)


# 设置GUI
root = TkinterDnD.Tk()
root.title("Halo主题开发一键添加ID与KEY")
root.geometry("500x400")
root.minsize(400, 300)

# 设置主题样式
style = ttk.Style()
style.theme_use('clam')

# 主框架
main_frame = ttk.Frame(root, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)

# 应用标题
title_label = ttk.Label(
    main_frame,
    text="一键添加ID与KEY",
    font=('Helvetica', 16, 'bold'),
    foreground="#2c3e50"
)
title_label.pack(pady=(0, 10))

# 图标和说明
icon_label = ttk.Label(
    main_frame,
    text="📄",
    font=('Helvetica', 48)
)
icon_label.pack(pady=(0, 10))

# 选择文件按钮
select_button = ttk.Button(
    main_frame,
    text="选择YAML文件",
    command=select_file,
    style='Accent.TButton'
)
select_button.pack(pady=10, ipadx=10, ipady=5)

# 分隔文本
separator = ttk.Label(main_frame, text="或", foreground="#7f8c8d")
separator.pack(pady=5)

# 拖放区域
drop_frame = ttk.LabelFrame(
    main_frame,
    text="拖放区域",
    padding=20,
    relief=tk.RIDGE,
    borderwidth=2
)
drop_frame.pack(fill=tk.BOTH, expand=True)

drop_label = ttk.Label(
    drop_frame,
    text="拖放YAML文件到此处\n(仅支持 settings 或 annotationSetting 格式)",
    font=('Helvetica', 12),
    foreground="#7f8c8d",
    justify='center'
)
drop_label.pack(expand=True)

# 状态栏
status_bar = ttk.Label(
    root,
    text="就绪",
    relief=tk.SUNKEN,
    anchor=tk.W,
    padding=(10, 5)
)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)

# 启用拖放功能
root.drop_target_register(DND_FILES)
root.dnd_bind('<<Drop>>', lambda e: on_drop(e))

# 运行GUI
root.mainloop()

界面截图