From 42003b7b1b83d6495b28e1be221ab77d706d177f Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Fri, 6 Feb 2026 22:16:10 +0800 Subject: [PATCH 01/11] Setup CI/CD workflow and overhaul documentation - Add GitHub Actions workflow `release.yml` for automated building and releasing - Add `requirements.txt` for build environment setup - Rewrite `README.md` with improved structure, quick start guide, and detailed command reference - Update repository link and relax MCDR dependency version in `mcdreforged.plugin.json` --- .github/workflows/release.yml | 51 ++++++++++++ README.md | 144 +++++++++++++++++++--------------- mcdreforged.plugin.json | 4 +- requirements.txt | 1 + 4 files changed, 133 insertions(+), 67 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 requirements.txt diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d7fa8d3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: CI for MCDR Plugin + +on: + push: + pull_request: + +permissions: + contents: write + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Pack Plugin + run: | + # Pack the plugin using MCDR's official tool + # -o specifies the output directory + python -m mcdreforged pack -o ./package + + - uses: actions/upload-artifact@v4 + with: + name: Sakura Flow distribution for ${{ github.sha }} + path: package/ + + - name: Publish distribution to release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + # MCDR pack command generates .mcdr files by default + files: package/*.mcdr + generate_release_notes: true diff --git a/README.md b/README.md index c86c159..e5dbfa9 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,103 @@ # Sakura Flow -一款专注于**依赖管理**与**深度交互**的 MCDReforged 待办事项插件。 -Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题,提供极简的输入体验和类似 IDE 的聊天栏交互界面。 +**Sakura Flow** 是一款基于 MCDReforged 的 Minecraft 服务器**高级待办事项(Todo)管理插件**。 -## ✨ 核心特性 +Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题(如大型机器搭建、自动化流水线设计等)。通过充分利用 Minecraft 聊天栏的富文本功能,插件提供了**悬浮预览**、**点击跳转**与**指令自动填充**等交互特性,实现了高效、直观的任务管理体验。 -* **极简输入**: 减少繁琐打字,支持字段别称(如 `t` 代表 `tier`),让指令输入如行云流水。 -* **深度交互**: 充分利用 Minecraft 聊天栏富文本(RText),将聊天栏打造为交互式“控制面板”。支持悬浮预览详情、点击执行指令。 -* **流程管理**: 专为复杂任务链设计,强调任务的**前置依赖(Dependencies)**管理,自动梳理任务执行顺序。 -* **层级可视化**: 内置动态颜色系统,根据任务等级(Tier)显示不同颜色,直观区分任务重要性或阶段。 -* **协作管理**: 支持多玩家协作,记录任务创建者与协作者。 -* **自然排序**: 依赖项 ID 自动按数值排序,协作者和标签按字典序排序。 +## ✨ 核心特性 -## 🛠️ 指令系统 +* **游戏内管理**: 直接在聊天栏进行全流程任务管理,无需切换窗口。 +* **富文本交互**: 基于 Minecraft 聊天栏的**富文本(JSON)** 特性,支持点击按钮执行指令(完成、暂停、查看详情),操作便捷。 +* **依赖管理**: 专为复杂工程设计,支持设置任务**前置条件(Dependencies)**,自动梳理任务执行顺序。 +* **高效输入**: 提供智能指令别名系统(如 `t` 代表 `tier`),简化指令输入流程。 +* **层级可视化**: 内置动态颜色系统,根据任务等级(Tier)自动区分显示样式,直观展示任务重要性。 + +## 🚀 快速上手 (Quick Start) + +本节介绍插件的基本工作流: + +1. **创建任务** + 示例:创建一个名为“建造刷铁机”的任务。 + `!!todo add 建造刷铁机` +2. **查看列表** + 显示当前所有待办任务。 + `!!todo list` + *(提示:鼠标悬浮在 `[#ID]` 上可预览任务摘要)* +3. **追加记录** + 为任务添加详细说明或进度笔记。 + `!!todo note 需要准备 20 张床` + *(提示:点击列表中的 `[✎]` 按钮可自动填充此指令前缀)* +4. **标记完成** + 任务完成后,将其标记为 Done 并归档。 + `!!todo complete ` + *(提示:也可直接点击列表中的 `[✔]` 按钮)* + +## 📖 指令手册 所有指令前缀为 `!!todo`。 -### 任务创建 +### 1. 基础操作 -| 指令 | 别称 | 说明 | -|:---------------------|:-----------|:---------| -| `!!todo add ` | `!!todo a` | 创建一个新任务。 | +| 动作 | 指令格式 | 别称 | 说明 | +|:-------|:-----------------------|:----|:-----------------------| +| **新建** | `!!todo add <标题>` | `a` | 创建一个新任务。 | +| **列表** | `!!todo list` | `l` | 显示所有**进行中**的任务(含交互按钮)。 | +| **详情** | `!!todo info <ID>` | `i` | 查看指定任务的详细信息(依赖、笔记等)。 | +| **完成** | `!!todo complete <ID>` | - | 标记任务为完成并移入归档。 | +| **帮助** | `!!todo help` | - | 显示帮助菜单。 | -### 查询与管理 +### 2. 进阶管理 -| 指令 | 别称 | 说明 | -|:-------------------|:------------|:---------------------------| -| `!!todo list` | `!!todo l` | 显示当前所有 **未完成** 的任务(含交互按钮)。 | -| `!!todo archive` | `!!todo ar` | 显示所有 **已完工** 的历史任务。 | -| `!!todo info <id>` | `!!todo i` | 查看任务详情,支持依赖项悬浮预览和跳转。 | -| `!!todo help` | - | 显示帮助菜单。 | +| 动作 | 指令格式 | 别称 | 说明 | +|:---------|:------------------------|:-----|:-------------------------| +| **记笔记** | `!!todo note <ID> <内容>` | `n` | 追加一条带有时间戳的进度记录。 | +| **暂停** | `!!todo pause <ID>` | - | 挂起任务(状态变更为 On Hold)。 | +| **恢复** | `!!todo resume <ID>` | - | 恢复任务(状态变更为 In Progress)。 | +| **归档库** | `!!todo archive` | `ar` | 查看所有已完成的历史任务。 | +| **恢复归档** | `!!todo restore <ID>` | - | 将已完成的任务恢复至进行中状态。 | -### 快捷操作 +### 3. 属性修改 (Set/Modify) -| 指令 | 别称 | 说明 | -|:--------------------------|:-----------|:----------------------------| -| `!!todo note <id> <text>` | `!!todo n` | 追加带有时间戳和作者的任务进度记录。 | -| `!!todo complete <id>` | - | 一键标记为 **Done** 并移入归档。 | -| `!!todo pause <id>` | - | 状态切换为 **On Hold**,暂停项目。 | -| `!!todo resume <id>` | - | 状态切换为 **In Progress**,恢复项目。 | -| `!!todo restore <id>` | - | 从归档中恢复任务至 **In Progress**。 | +用于修改任务的各项属性,如等级、依赖关系等。 -### 属性修改 +| 操作类型 | 指令格式 | 别称 | 说明 | +|:----------|:------------------------------|:-----|:----------------------------------| +| **修改单值** | `!!todo set <ID> <属性> <值>` | `s` | 修改标题、等级、优先级等单值属性。 | +| **添加列表项** | `!!todo append <ID> <属性> <值>` | `ap` | 向列表属性(如依赖 `dep`、协作者 `collab`)追加值。 | +| **移除列表项** | `!!todo remove <ID> <属性> <值>` | `rm` | 从列表属性中移除指定值。 | -| 指令 | 别称 | 说明 | -|:------------------------------------|:------------|:----------------------------------------------------------| -| `!!todo set <id> <prop> <value>` | `!!todo s` | 修改单值属性(如 title, tier, priority)。<br>Tier 支持数字输入,用于区分任务等级。 | -| `!!todo append <id> <list> <value>` | `!!todo ap` | 向列表属性(dep, collab, label)追加项。<br>追加依赖时会检查 ID 是否存在。 | -| `!!todo remove <id> <list> <value>` | `!!todo rm` | 从列表属性中移除特定项。 | +> **说明**: +> * **Tier (等级)**: 支持 `0-14` 的整数。数值越大,任务在列表中的显示颜色越醒目。 +> * **Dependencies (依赖)**: 使用 `!!todo ap <ID> dep <前置ID>` 添加依赖关系。 -## 🖥️ 交互界面 +## 🖱️ 交互界面指南 -插件在聊天栏提供丰富的交互元素: +插件在聊天栏提供以下交互元素: -* **列表视图**: - * `[#ID]`: 悬浮显示详情,点击填充查询指令。 - * `[▶/⏸]`: 点击快捷切换 暂停/进行中 状态。 - * `[✔]`: 点击一键完成任务。 - * `[✎]`: 点击快捷填充笔记指令。 -* **详情视图**: - * **等级颜色**: 根据 Tier 等级显示对应颜色,高等级任务会有更醒目的高亮显示。 - * **依赖链**: 依赖项以 `[#ID]` 展示,支持悬浮预览状态,点击跳转。 +* **列表视图**: + * `[#ID]`: **悬浮**查看摘要,**点击**自动填充 `info` 指令。 + * `[▶] / [⏸]`: **点击**切换 暂停/进行中 状态。 + * `[✔]`: **点击**直接完成任务。 + * `[✎]`: **点击**自动填充 `note` 指令。 +* **详情视图**: + * **依赖链**: 点击依赖任务的 ID 可跳转查看该前置任务的详情。 ## 📦 安装说明 -1. 确保已安装 [MCDReforged](https://github.com/MCDReforged/MCDReforged)。 -2. 将本插件文件夹放入 `plugins` 目录。 -3. 在 MCDR 中重载插件。 - -## 📝 字段说明 - -### 单值属性 (Set) +1. 确保服务端已安装 [MCDReforged](https://github.com/MCDReforged/MCDReforged)。 +2. 下载本插件,将文件夹放入 `plugins` 目录。 +3. 在控制台或游戏内输入 `!!MCDR plugin reload` 重载插件。 -| 属性名 | 别称 | 说明 | 合法值示例 | -|:--------------|:------------|:-----|:-------------------------------------------| -| `title` | `title` | 任务名称 | 任意文本 | -| `description` | `desc` | 详细描述 | 任意文本 | -| `status` | `stat`, `s` | 任务状态 | `In Progress`, `On Hold`, `Done` | -| `tier` | `t` | 任务等级 | `0`-`14` (数字越大颜色越醒目) | -| `priority` | `prio`, `p` | 优先级 | `0`-`4` 或 `Very High`, `High`, `Medium`... | +## 📝 附录:属性字段速查 -### 列表属性 (Append/Remove) +在执行 `set`, `append`, `remove` 时可用的属性名及其简写: -| 属性名 | 别称 | 说明 | -|:----------------|:--------------|:--------------------| -| `collaborators` | `collab`, `c` | 协作者列表 | -| `dependencies` | `dep`, `d` | 前置任务 ID 列表 (自动数值排序) | -| `labels` | `label`, `l` | 标签列表 | +| 属性全名 | 简写 | 类型 | 说明 | +|:----------------|:--------------|:------|:----------------------| +| `title` | - | 文本 | 任务标题 | +| `description` | `desc` | 文本 | 详细描述 | +| `tier` | `t` | 数字 | 任务等级 (0-14) | +| `priority` | `p`, `prio` | 文本/数字 | 优先级 (High, Medium...) | +| `dependencies` | `d`, `dep` | 列表 | **前置任务 ID** | +| `collaborators` | `c`, `collab` | 列表 | 协作者玩家名 | +| `labels` | `l`, `label` | 列表 | 自定义标签 | diff --git a/mcdreforged.plugin.json b/mcdreforged.plugin.json index 32ff473..969116e 100644 --- a/mcdreforged.plugin.json +++ b/mcdreforged.plugin.json @@ -4,9 +4,9 @@ "name": "Sakura Flow", "description": "一款专注于依赖管理与深度交互的 MCDReforged 待办事项插件。", "author": "Sakura-Ex", - "link": "https://github.com/Sakura-Ex/Sakura-Flow", + "link": "https://github.com/Sakura-Ex/SakuraFlow", "dependencies": { - "mcdreforged": ">=2.15.7" + "mcdreforged": ">=2.15.0" }, "resources": [ "lang" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..135f282 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +mcdreforged>=2.15.0 \ No newline at end of file From c0fdc1fd84679f6ed87dc005127c21afa143c943 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Sat, 7 Feb 2026 01:06:48 +0800 Subject: [PATCH 02/11] Refactor architecture for CLI support, add search, and implement file locking - Split `commands.py` into `controller.py`, `mcdr_entry.py`, and `cli_entry.py` to decouple business logic from MCDR API - Implement `TodoController` with advanced search functionality supporting multiple criteria, negation, and caching - Add `__main__.py` to allow running the plugin as a standalone CLI tool - Introduce `FileLock` and transaction context in `TodoManager` for safe concurrent data access - Add `!!todo search` command and update `list` and `archive` to utilize the new search logic - Update `interface.py` to support rendering arbitrary task lists with custom pagination prefixes - Relocate data storage to `sf_tasks/tasks.json` in the working directory to share state between MCDR and CLI - Add localization keys for search features in `lang/zh_cn.json` --- __main__.py | 42 +++++++ lang/zh_cn.json | 8 +- sakura_flow/__init__.py | 34 ++---- sakura_flow/cli_entry.py | 200 ++++++++++++++++++++++++++++++++ sakura_flow/commands.py | 209 --------------------------------- sakura_flow/controller.py | 200 ++++++++++++++++++++++++++++++++ sakura_flow/interface.py | 36 +++--- sakura_flow/manager.py | 162 +++++++++++++++++--------- sakura_flow/mcdr_entry.py | 235 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 823 insertions(+), 303 deletions(-) create mode 100644 __main__.py create mode 100644 sakura_flow/cli_entry.py delete mode 100644 sakura_flow/commands.py create mode 100644 sakura_flow/controller.py create mode 100644 sakura_flow/mcdr_entry.py diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..6588081 --- /dev/null +++ b/__main__.py @@ -0,0 +1,42 @@ +import argparse +import os +import sys +from sakura_flow.manager import TodoManager +from sakura_flow.controller import TodoController +from sakura_flow.cli_entry import register_cli_commands, handle_cli_command + +def main(): + parser = argparse.ArgumentParser(description="Sakura Flow CLI") + register_cli_commands(parser) + + args = parser.parse_args() + + # Determine data path logic + cwd = os.getcwd() + + # Strategy: Try to find MCDR root relative to current location + # Priority 1: If running inside plugin source folder (contains mcdreforged.plugin.json) + if os.path.exists(os.path.join(cwd, "mcdreforged.plugin.json")): + mcdr_root = os.path.abspath(os.path.join(cwd, "../..")) + # Priority 2: If running in a directory that contains .pyz file (e.g. plugins dir) + elif os.path.exists(os.path.join(cwd, "../sf_tasks")): + mcdr_root = os.path.abspath(os.path.join(cwd, "..")) + # Priority 3: Fallback to current directory (assuming running from MCDR root) + else: + # If sf_tasks exists in ../.., use it + if os.path.exists(os.path.join(cwd, "../../sf_tasks")): + mcdr_root = os.path.abspath(os.path.join(cwd, "../..")) + else: + # If we really can't find it, assume we are in plugin folder as requested + mcdr_root = os.path.abspath(os.path.join(cwd, "../..")) + + data_path = os.path.join(mcdr_root, 'sf_tasks', 'tasks.json') + + # Initialize manager and controller + manager = TodoManager(data_path) + controller = TodoController(manager) + + handle_cli_command(args, controller) + +if __name__ == "__main__": + main() diff --git a/lang/zh_cn.json b/lang/zh_cn.json index fdc4f00..f8f8c52 100644 --- a/lang/zh_cn.json +++ b/lang/zh_cn.json @@ -61,6 +61,7 @@ "todo.help.hint": "提示:点击可填充指令至聊天栏;鼠标移至指令上方查看详情", "todo.help.list": "查看进行中任务清单", "todo.help.archive": "查看已完成归档记录", + "todo.help.search": "搜索任务 (支持多条件: t=标题 s=!Done)", "todo.help.add": "立项一个新的任务", "todo.help.info": "查询特定任务的详细信息", "todo.help.note": "追加一条任务进度记录", @@ -92,6 +93,10 @@ "todo.list.empty": "当前没有进行中的任务", "todo.archive.header": "已归档任务", "todo.archive.empty": "归档记录为空", + "todo.search.header": "搜索结果", + "todo.search.empty": "未找到匹配的任务", + "todo.search.more_results": "... 还有 {0} 条结果", + "todo.search.cache_expired": "搜索缓存已过期,请重新输入查询条件", "todo.msg.add_success": "任务 {0} 已成功立项!", "todo.msg.not_found": "未找到任务 ID", @@ -110,5 +115,6 @@ "todo.msg.complete_success": "任务 #{0} 已完工并移入归档 ✔", "todo.msg.pause_success": "任务 #{0} 已标记为暂停 ⏸", "todo.msg.resume_success": "任务 #{0} 已恢复运行 ▶", - "todo.msg.default_tier_success": "默认电压等级已设置为: {0}" + "todo.msg.default_tier_success": "默认电压等级已设置为: {0}", + "todo.msg.unknown_error": "未知错误: {0}" } \ No newline at end of file diff --git a/sakura_flow/__init__.py b/sakura_flow/__init__.py index e660d5f..cf8c0ce 100644 --- a/sakura_flow/__init__.py +++ b/sakura_flow/__init__.py @@ -1,38 +1,26 @@ from mcdreforged.api.all import * -from mcdreforged.api.command import SimpleCommandBuilder, Text, GreedyText, Integer import os from .manager import TodoManager -from .commands import register_commands +from .controller import TodoController +from .mcdr_entry import register_mcdr_commands from .constants import COMMAND_PREFIX manager = None - +controller = None def on_load(server: PluginServerInterface, _prev): - global manager + global manager, controller # 初始化管理器 - data_path = os.path.join(server.get_data_folder(), 'tasks.json') + # 数据存放到 MCDR 根目录下的 sf_tasks 目录 + data_path = os.path.join(os.getcwd(), 'sf_tasks', 'tasks.json') manager = TodoManager(data_path) + + # 初始化控制器 + controller = TodoController(manager) # 注册指令帮助条目 server.register_help_message(COMMAND_PREFIX, "任务管理") - # 构建指令系统 - builder = SimpleCommandBuilder() - - # 定义参数节点 - builder.arg("id", Text) - builder.arg("title", GreedyText) - builder.arg("prop", Text) - builder.arg("list_prop", Text) - builder.arg("value", GreedyText) - builder.arg("content", GreedyText) - builder.arg("tier", Text) # 添加 tier 参数定义 - builder.arg("page", Integer) # 添加 page 参数定义 - - # 注册回调逻辑 (传入 manager 实例) - register_commands(builder, manager) - - # 注册到服务器 - builder.register(server) + # 注册 MCDR 指令 + register_mcdr_commands(server, controller) diff --git a/sakura_flow/cli_entry.py b/sakura_flow/cli_entry.py new file mode 100644 index 0000000..b8d4d2f --- /dev/null +++ b/sakura_flow/cli_entry.py @@ -0,0 +1,200 @@ +import argparse +from .controller import TodoController +from .enums import Status + +def register_cli_commands(parser: argparse.ArgumentParser): + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Add + add_parser = subparsers.add_parser("add", help="Add a new task") + add_parser.add_argument("title", help="Task title") + add_parser.add_argument("--creator", default="CLI", help="Creator name") + + # List + list_parser = subparsers.add_parser("list", help="List tasks") + list_parser.add_argument("--all", action="store_true", help="Show all tasks including completed ones") + list_parser.add_argument("--archive", action="store_true", help="Show archived tasks") + # Search filters + list_parser.add_argument("--title", help="Filter by title (fuzzy)") + list_parser.add_argument("--status", help="Filter by status") + list_parser.add_argument("--tier", help="Filter by tier") + list_parser.add_argument("--priority", help="Filter by priority") + list_parser.add_argument("--creator", help="Filter by creator") + list_parser.add_argument("--collab", help="Filter by collaborator") + list_parser.add_argument("--label", help="Filter by label") + + # Info + info_parser = subparsers.add_parser("info", help="Show task details") + info_parser.add_argument("id", help="Task ID") + + # Set + set_parser = subparsers.add_parser("set", help="Set task property") + set_parser.add_argument("id", help="Task ID") + set_parser.add_argument("prop", help="Property name (title, status, tier, priority, description)") + set_parser.add_argument("value", help="New value") + set_parser.add_argument("--editor", default="CLI", help="Editor name") + + # Append + append_parser = subparsers.add_parser("append", help="Append to list property") + append_parser.add_argument("id", help="Task ID") + append_parser.add_argument("list_prop", help="List property name (dependencies, collaborators, labels)") + append_parser.add_argument("value", help="Value to append") + append_parser.add_argument("--editor", default="CLI", help="Editor name") + + # Remove + remove_parser = subparsers.add_parser("remove", help="Remove from list property") + remove_parser.add_argument("id", help="Task ID") + remove_parser.add_argument("list_prop", help="List property name") + remove_parser.add_argument("value", help="Value to remove") + remove_parser.add_argument("--editor", default="CLI", help="Editor name") + + # Note + note_parser = subparsers.add_parser("note", help="Add a note to task") + note_parser.add_argument("id", help="Task ID") + note_parser.add_argument("content", help="Note content") + note_parser.add_argument("--author", default="CLI", help="Author name") + + # Status changes + complete_parser = subparsers.add_parser("complete", help="Mark task as completed") + complete_parser.add_argument("id", help="Task ID") + + pause_parser = subparsers.add_parser("pause", help="Pause task") + pause_parser.add_argument("id", help="Task ID") + + resume_parser = subparsers.add_parser("resume", help="Resume task") + resume_parser.add_argument("id", help="Task ID") + + restore_parser = subparsers.add_parser("restore", help="Restore archived task") + restore_parser.add_argument("id", help="Task ID") + + # Default Tier + dt_parser = subparsers.add_parser("default_tier", help="Set default tier") + dt_parser.add_argument("tier", help="Tier value") + +def handle_cli_command(args, controller: TodoController): + if args.command == "add": + task_id = controller.add_task(args.title, args.creator) + print(f"Task created with ID: {task_id}") + + elif args.command == "list": + # Build criteria + criteria = {} + if args.title: criteria['title'] = args.title + if args.status: criteria['status'] = args.status + if args.tier: criteria['tier'] = args.tier + if args.priority: criteria['priority'] = args.priority + if args.creator: criteria['creator'] = args.creator + if args.collab: criteria['collaborator'] = args.collab + if args.label: criteria['label'] = args.label + + # If criteria exists, use search_tasks + if criteria: + tasks = controller.search_tasks(criteria) + # Apply archive/all filter on top of search results if needed? + # search_tasks currently searches ALL tasks. + # We should probably filter by status if --all is not present and status is not in criteria. + + filtered_tasks = {} + for tid, task in tasks.items(): + is_done = task['status'] == Status.DONE.value + if args.archive: + if is_done: filtered_tasks[tid] = task + elif args.all: + filtered_tasks[tid] = task + else: + # Default: only show not done + if not is_done: filtered_tasks[tid] = task + + tasks = filtered_tasks + + else: + # Fallback to original logic + if args.archive: + tasks = controller.get_archived_tasks() + else: + tasks = controller.get_tasks(include_done=args.all) + + print(f"{'ID':<5} {'Status':<12} {'Title'}") + print("-" * 40) + for tid, task in tasks.items(): + print(f"{tid:<5} {task['status']:<12} {task['title']}") + + elif args.command == "info": + task = controller.get_task(args.id) + if task: + print(f"ID: {args.id}") + print(f"Title: {task['title']}") + print(f"Status: {task['status']}") + description = task.get('description', '') + if not description: + description = "No description" + print(f"Description: {description}") + print(f"Tier: {task.get('tier', '')}") + print(f"Priority: {task.get('priority', '')}") + print(f"Dependencies: {', '.join(map(str, task.get('dependencies', [])))}") + print(f"Collaborators: {', '.join(task.get('collaborators', []))}") + print("Notes:") + for note in task.get("notes", []): + print(f" [{note['time']}] {note['author']}: {note['content']}") + else: + print(f"Task {args.id} not found.") + + elif args.command == "set": + success, val, err = controller.set_property(args.id, args.prop, args.value, args.editor) + if success: + print(f"Set {args.prop} to {val}") + else: + print(f"Error: {err}") + + elif args.command == "append": + success, err = controller.append_list_property(args.id, args.list_prop, args.value, args.editor) + if success: + print(f"Appended {args.value} to {args.list_prop}") + else: + print(f"Error: {err}") + + elif args.command == "remove": + success, err = controller.remove_list_property(args.id, args.list_prop, args.value, args.editor) + if success: + print(f"Removed {args.value} from {args.list_prop}") + else: + print(f"Error: {err}") + + elif args.command == "note": + if controller.add_note(args.id, args.content, args.author): + print("Note added.") + else: + print("Failed to add note.") + + elif args.command == "complete": + if controller.update_status(args.id, Status.DONE, "CLI"): + print(f"Task {args.id} marked as completed.") + else: + print(f"Failed to update task {args.id}.") + + elif args.command == "pause": + if controller.update_status(args.id, Status.ON_HOLD, "CLI"): + print(f"Task {args.id} paused.") + else: + print(f"Failed to update task {args.id}.") + + elif args.command == "resume": + if controller.update_status(args.id, Status.IN_PROGRESS, "CLI"): + print(f"Task {args.id} resumed.") + else: + print(f"Failed to update task {args.id}.") + + elif args.command == "restore": + if controller.update_status(args.id, Status.IN_PROGRESS, "CLI"): + print(f"Task {args.id} restored.") + else: + print(f"Failed to update task {args.id}.") + + elif args.command == "default_tier": + if controller.set_default_tier(args.tier): + print(f"Default tier set to {args.tier}") + else: + print("Invalid tier.") + + else: + print("No command specified. Use --help for usage.") diff --git a/sakura_flow/commands.py b/sakura_flow/commands.py deleted file mode 100644 index 2e3b0b2..0000000 --- a/sakura_flow/commands.py +++ /dev/null @@ -1,209 +0,0 @@ -from mcdreforged.api.all import * - -from . import TodoManager -from .constants import * -from .enums import Status, Priority, Tier -from .interface import UI -from .utils import Utils - - -def register_commands(builder: SimpleCommandBuilder, manager: TodoManager): - @builder.command(COMMAND_PREFIX) - def welcome_cmd(source: CommandSource, _context: CommandContext): - source.reply(UI.render_welcome(source.get_server())) - - @builder.command(f"{COMMAND_PREFIX} help") - def help_cmd(source: CommandSource, _context: CommandContext): - source.reply(UI.render_help(source.get_server())) - - @builder.command(f"{COMMAND_PREFIX} list") - @builder.command(f"{COMMAND_PREFIX} l") - @builder.command(f"{COMMAND_PREFIX} list <page>") - @builder.command(f"{COMMAND_PREFIX} l <page>") - def list_tasks(source: CommandSource, context: CommandContext): - page = context.get("page", 1) - try: - page = int(page) - except ValueError: - page = 1 - UI.render_paged_list(source, manager, 'todo.list.header', 'todo.list.empty', False, page) - - @builder.command(f"{COMMAND_PREFIX} archive") - @builder.command(f"{COMMAND_PREFIX} ar") - @builder.command(f"{COMMAND_PREFIX} archive <page>") - @builder.command(f"{COMMAND_PREFIX} ar <page>") - def list_archive(source: CommandSource, context: CommandContext): - page = context.get("page", 1) - try: - page = int(page) - except ValueError: - page = 1 - UI.render_paged_list(source, manager, 'todo.archive.header', 'todo.archive.empty', True, page) - - @builder.command(f"{COMMAND_PREFIX} add <title>") - @builder.command(f"{COMMAND_PREFIX} a <title>") - def add_task(source: CommandSource, context: CommandContext): - server = source.get_server() - creator = source.player if source.is_player else "Console" - tid = manager.add_task(context['title'], creator) - - # 构建富文本参数 - tid_text = RText(f"#{tid}", color=RColor.green, styles=RStyle.bold) - source.reply(Utils.info_msg(server, 'todo.msg.add_success', tid_text)) - - @builder.command(f"{COMMAND_PREFIX} info <id>") - @builder.command(f"{COMMAND_PREFIX} i <id>") - def show_info(source: CommandSource, context: CommandContext): - """调用重构后的 UI 渲染器展示详细信息""" - server = source.get_server() - tid = str(context['id']) - task = manager.data["tasks"].get(tid) - if not task: - source.reply(Utils.error_msg(server, 'todo.msg.not_found')) - return - # 使用 interface.py 中的 render_task_info 方法 - source.reply(UI.render_task_info(tid, task, manager.data["tasks"], server)) - - @builder.command(f"{COMMAND_PREFIX} append <id> <list_prop> <value>") - @builder.command(f"{COMMAND_PREFIX} ap <id> <list_prop> <value>") - def append_prop(source: CommandSource, context: CommandContext): - server = source.get_server() - real_prop = LIST_PROP_ALIASES.get(context['list_prop'].lower()) - if not real_prop: - source.reply(Utils.error_msg(server, 'todo.msg.invalid_list_alias', context['list_prop'])) - return - - # 核心逻辑:依赖项合法性检查 - val = str(context['value']) - if real_prop == "dependencies" and val not in manager.data["tasks"]: - source.reply(Utils.error_msg(server, 'todo.msg.dep_not_found', val)) - return - - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), real_prop, val, editor): - prop_text = RText(real_prop, color=RColor.yellow) - source.reply(Utils.info_msg(server, 'todo.msg.append_success', context['id'], prop_text, val)) - - @builder.command(f"{COMMAND_PREFIX} remove <id> <list_prop> <value>") - @builder.command(f"{COMMAND_PREFIX} rm <id> <list_prop> <value>") - def remove_prop(source: CommandSource, context: CommandContext): - server = source.get_server() - real_prop = LIST_PROP_ALIASES.get(context['list_prop'].lower()) - if not real_prop: - source.reply(Utils.error_msg(server, 'todo.msg.invalid_list_alias', context['list_prop'])) - return - - editor = source.player if source.is_player else "Console" - val = str(context['value']) - - if manager.remove_item(str(context['id']), real_prop, val, editor): - prop_text = RText(real_prop, color=RColor.yellow) - source.reply(Utils.info_msg(server, 'todo.msg.remove_success', context['id'], prop_text, val)) - else: - source.reply(Utils.error_msg(server, 'todo.msg.remove_failed', val)) - - @builder.command(f"{COMMAND_PREFIX} set <id> <prop> <value>") - @builder.command(f"{COMMAND_PREFIX} s <id> <prop> <value>") - def set_prop(source: CommandSource, context: CommandContext): - server = source.get_server() - alias = context['prop'].lower() - real_prop = PROP_ALIASES.get(alias) - if not real_prop: - source.reply(Utils.error_msg(server, 'todo.msg.invalid_prop_alias', context['prop'])) - return - - val = context['value'] - - rval = RText(val) - - if real_prop == "tier": - validated_tier = Tier.validate(val) - if validated_tier: - val = validated_tier - rval = Tier.get_rtext(val) - else: - tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) - source.reply(Utils.error_msg(server, 'todo.msg.invalid_tier', len(GT_TIERS) - 1, tier_list)) - return - - elif real_prop == "priority": - validated_prio = Priority.validate(val) - if validated_prio: - val = validated_prio - rval = Priority.get_rtext(val, server) - else: - prio_list = Utils.list_to_rtext([Priority.get_rtext(p.value) for p in Priority]) - source.reply(Utils.error_msg(server, 'todo.msg.invalid_priority', prio_list)) - return - - elif real_prop == "status": - validated_status = Status.validate(val) - if validated_status: - val = validated_status - rval = Status.get_rtext(val, server) - else: - status_list = Utils.list_to_rtext([Status.get_rtext(s.value) for s in Status]) - source.reply(Utils.error_msg(server, 'todo.msg.invalid_status', status_list)) - return - - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), real_prop, val, editor): - prop_text = RText(real_prop, color=RColor.yellow) - - # 如果是 status 属性,尝试获取翻译后的文本 - if real_prop == "status": - rval = Status.get_rtext(val, server) - - source.reply(Utils.info_msg(server, 'todo.msg.set_success', context['id'], prop_text, rval)) - - @builder.command(f"{COMMAND_PREFIX} note <id> <content>") - @builder.command(f"{COMMAND_PREFIX} n <id> <content>") - def add_note(source: CommandSource, context: CommandContext): - server = source.get_server() - author = source.player if source.is_player else "Console" - if manager.add_note(str(context['id']), context['content'], author): - source.reply(Utils.info_msg(server, 'todo.msg.note_success', context['id'])) - - @builder.command(f"{COMMAND_PREFIX} restore <id>") - def restore_task(source: CommandSource, context: CommandContext): - """将已归档任务恢复为进行中""" - server = source.get_server() - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), "status", Status.IN_PROGRESS.value, editor): - source.reply(Utils.info_msg(server, 'todo.msg.restore_success', context['id'])) - - @builder.command(f"{COMMAND_PREFIX} complete <id>") - def complete_task(source: CommandSource, context: CommandContext): - server = source.get_server() - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), "status", Status.DONE.value, editor): - source.reply(Utils.info_msg(server, 'todo.msg.complete_success', context['id'])) - - @builder.command(f"{COMMAND_PREFIX} pause <id>") - def pause_task(source: CommandSource, context: CommandContext): - server = source.get_server() - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), "status", Status.ON_HOLD.value, editor): - source.reply(Utils.info_msg(server, 'todo.msg.pause_success', context['id'])) - - @builder.command(f"{COMMAND_PREFIX} resume <id>") - def resume_task(source: CommandSource, context: CommandContext): - server = source.get_server() - editor = source.player if source.is_player else "Console" - if manager.update_task(str(context['id']), "status", Status.IN_PROGRESS.value, editor): - source.reply(Utils.info_msg(server, 'todo.msg.resume_success', context['id'])) - - # TODO 加入 setdefault 方法给所有 prop 设置默认值,而不是只能修改默认 tier - @builder.command(f"{COMMAND_PREFIX} default_tier <tier>") - def set_default_tier(source: CommandSource, context: CommandContext): # TODO 使用通用的 setdefault 命令代替专属命令 - server = source.get_server() - val = context['tier'] - validated_tier = Tier.validate(val) - - if validated_tier: - manager.set_default_tier(validated_tier) - tier_text = Tier.get_rtext(validated_tier) - source.reply(Utils.info_msg(server, 'todo.msg.default_tier_success', tier_text)) - else: - tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) - source.reply(Utils.error_msg(server, 'todo.msg.invalid_tier', len(GT_TIERS) - 1, tier_list)) diff --git a/sakura_flow/controller.py b/sakura_flow/controller.py new file mode 100644 index 0000000..477daa5 --- /dev/null +++ b/sakura_flow/controller.py @@ -0,0 +1,200 @@ +from typing import Optional, List, Dict, Any +import time +from .manager import TodoManager +from .enums import Status, Tier, Priority +from .constants import PROP_ALIASES, LIST_PROP_ALIASES, GT_TIERS + +class SearchCache: + def __init__(self, ttl: int = 300): + self.cache = {} + self.ttl = ttl # Time to live in seconds + + def set(self, key: str, query: str, results: Dict[str, Any]): + self.cache[key] = { + 'query': query, + 'results': results, + 'timestamp': time.time() + } + + def get(self, key: str) -> Optional[Dict[str, Any]]: + entry = self.cache.get(key) + if not entry: + return None + + if time.time() - entry['timestamp'] > self.ttl: + del self.cache[key] + return None + + return entry + +class TodoController: + def __init__(self, manager: TodoManager): + self.manager = manager + self.search_cache = SearchCache() + + def add_task(self, title: str, creator: str) -> str: + return self.manager.add_task(title, creator) + + def get_task(self, task_id: str) -> Optional[Dict[str, Any]]: + return self.manager.data["tasks"].get(task_id) + + # Deprecated: Use search_tasks({'status': '!Done'}) instead + def get_tasks(self, include_done: bool = False) -> Dict[str, Dict[str, Any]]: + if include_done: + return self.manager.data["tasks"] + return self.search_tasks({'status': '!Done'}) + + # Deprecated: Use search_tasks({'status': 'Done'}) instead + def get_archived_tasks(self) -> Dict[str, Dict[str, Any]]: + return self.search_tasks({'status': 'Done'}) + + def search_tasks(self, criteria: Dict[str, str], cache_key: str = None) -> Dict[str, Dict[str, Any]]: + """ + 根据条件搜索任务 + criteria: { + 'title': 'keyword', + 'status': 'In Progress' or '!Done', + 'tier': 'IV' or '!IV', + 'priority': 'High' or '!High', + 'creator': 'player_name' or '!player_name', + 'collaborator': 'player_name' or '!player_name', + 'label': 'tag' or '!tag' + } + """ + result = {} + for tid, task in self.manager.data["tasks"].items(): + match = True + + # Title (Fuzzy) + if 'title' in criteria and criteria['title'].lower() not in task['title'].lower(): + match = False + + # Helper for exact match with negation + def check_exact(field_val, target_val): + if target_val.startswith('!'): + return field_val.lower() != target_val[1:].lower() + return field_val.lower() == target_val.lower() + + # Helper for list contains with negation + def check_contains(field_list, target_val): + field_list_lower = [x.lower() for x in field_list] + if target_val.startswith('!'): + return target_val[1:].lower() not in field_list_lower + return target_val.lower() in field_list_lower + + # Status + if match and 'status' in criteria: + if not check_exact(task['status'], criteria['status']): + match = False + + # Tier + if match and 'tier' in criteria: + if not check_exact(task.get('tier', ''), criteria['tier']): + match = False + + # Priority + if match and 'priority' in criteria: + if not check_exact(task.get('priority', ''), criteria['priority']): + match = False + + # Creator + if match and 'creator' in criteria: + if not check_exact(task.get('creator', ''), criteria['creator']): + match = False + + # Collaborator + if match and 'collaborator' in criteria: + if not check_contains(task.get('collaborators', []), criteria['collaborator']): + match = False + + # Label + if match and 'label' in criteria: + if not check_contains(task.get('labels', []), criteria['label']): + match = False + + if match: + result[tid] = task + + # Update cache if key provided + if cache_key: + # Construct a query string representation for cache (optional, mainly for debug/UI) + query_str = " ".join([f"{k}={v}" for k, v in criteria.items()]) + self.search_cache.set(cache_key, query_str, result) + + return result + + def get_cached_search(self, cache_key: str) -> Optional[Dict[str, Any]]: + entry = self.search_cache.get(cache_key) + return entry['results'] if entry else None + + def update_status(self, task_id: str, status: Status, editor: str) -> bool: + return self.manager.update_task(task_id, "status", status.value, editor) + + def add_note(self, task_id: str, content: str, author: str) -> bool: + return self.manager.add_note(task_id, content, author) + + def set_property(self, task_id: str, prop_alias: str, value: str, editor: str) -> tuple[bool, Any, Optional[str]]: + """ + 设置属性 + Returns: (success, processed_value, error_key) + """ + real_prop = PROP_ALIASES.get(prop_alias.lower()) + if not real_prop: + return False, None, 'todo.msg.invalid_prop_alias' + + processed_val = value + + if real_prop == "tier": + validated = Tier.validate(value) + if not validated: + return False, None, 'todo.msg.invalid_tier' + processed_val = validated + + elif real_prop == "priority": + validated = Priority.validate(value) + if not validated: + return False, None, 'todo.msg.invalid_priority' + processed_val = validated + + elif real_prop == "status": + validated = Status.validate(value) + if not validated: + return False, None, 'todo.msg.invalid_status' + processed_val = validated + + success = self.manager.update_task(task_id, real_prop, processed_val, editor) + return success, processed_val, None + + def append_list_property(self, task_id: str, list_alias: str, value: str, editor: str) -> tuple[bool, Optional[str]]: + """ + 追加列表属性 + Returns: (success, error_key) + """ + real_prop = LIST_PROP_ALIASES.get(list_alias.lower()) + if not real_prop: + return False, 'todo.msg.invalid_list_alias' + + if real_prop == "dependencies" and value not in self.manager.data["tasks"]: + return False, 'todo.msg.dep_not_found' + + success = self.manager.update_task(task_id, real_prop, value, editor) + return success, None + + def remove_list_property(self, task_id: str, list_alias: str, value: str, editor: str) -> tuple[bool, Optional[str]]: + """ + 移除列表属性 + Returns: (success, error_key) + """ + real_prop = LIST_PROP_ALIASES.get(list_alias.lower()) + if not real_prop: + return False, 'todo.msg.invalid_list_alias' + + success = self.manager.remove_item(task_id, real_prop, value, editor) + return success, None + + def set_default_tier(self, tier_val: str) -> bool: + validated = Tier.validate(tier_val) + if validated: + self.manager.set_default_tier(validated) + return True + return False diff --git a/sakura_flow/interface.py b/sakura_flow/interface.py index 9fbd1ef..611e583 100644 --- a/sakura_flow/interface.py +++ b/sakura_flow/interface.py @@ -174,6 +174,10 @@ def render_task_info(tid: str, task: dict, tasks_db: dict, server: ServerInterfa collabs = task.get('collaborators', []) collab_val = Utils.list_to_rtext(collabs) if collabs else server.tr('todo.common.unassigned') + description = task.get('description', '') + if not description: + description = server.tr('todo.ui.info.no_desc') + return RTextList( UI.make_dividing_line(server.tr('todo.ui.info.header', tid)), UI._render_info_row(tid, server.tr('todo.common.title'), task['title'], "title", server), @@ -190,12 +194,12 @@ def render_task_info(tid: str, task: dict, tasks_db: dict, server: ServerInterfa UI._render_info_row(tid, server.tr('todo.common.dependencies'), dep_list, "dependency", server, is_list=True), UI._render_info_row(tid, server.tr('todo.common.description'), - task.get('description', server.tr('todo.ui.info.no_desc')), "description", server), + description, "description", server), RText("-" * 35 + "\n", color=RColor.dark_gray), RText(f"{server.tr('todo.ui.info.progress_header')}\n", color=RColor.gold), *notes_content, - UI.make_dividing_line() + UI.make_dividing_line(newline=False) ) @staticmethod @@ -270,6 +274,7 @@ def build_props_info(header_key: str, props_dict: dict) -> RTextList: help_line("list", server.tr('todo.help.list'), usage="", abbr="l"), help_line("archive", server.tr('todo.help.archive'), usage="", abbr="ar"), + help_line("search", server.tr('todo.help.search'), usage="<query>", abbr="find"), help_line("add", server.tr('todo.help.add'), usage="<title>", abbr="a"), help_line("info", server.tr('todo.help.info'), usage="<id>", abbr="i"), help_line("note", server.tr('todo.help.note'), usage="<id> <content>", abbr="n"), @@ -286,7 +291,7 @@ def build_props_info(header_key: str, props_dict: dict) -> RTextList: help_line("complete", server.tr('todo.help.complete'), usage="<id>"), help_line("restore", server.tr('todo.help.restore'), usage="<id>"), - UI.make_dividing_line() + UI.make_dividing_line(newline=False) ) @staticmethod @@ -309,15 +314,21 @@ def render_welcome(server: ServerInterface) -> RTextBase: ) @staticmethod - def render_paged_list(source: CommandSource, manager: TodoManager, header_key: str, empty_key: str, is_archive: bool, input_page: int = 1): + def render_paged_list(source: CommandSource, tasks: dict, manager: TodoManager, header_key: str, empty_key: str, + input_page: int = 1, cmd_prefix: str = "list"): + """ + 渲染分页列表 + :param tasks: 要渲染的任务字典 {tid: task_data} + :param manager: TodoManager 实例,用于查找依赖任务信息 + :param cmd_prefix: 翻页命令的前缀,例如 "search" + """ server = source.get_server() page_size = PAGE_SIZE + # 将字典转换为列表以便切片 filtered_tasks = [] - for tid, task in manager.data["tasks"].items(): - is_done = task["status"] == Status.DONE.value - if is_archive == is_done: - filtered_tasks.append((tid, task)) + for tid, task in tasks.items(): + filtered_tasks.append((tid, task)) total_items = len(filtered_tasks) @@ -327,10 +338,7 @@ def render_paged_list(source: CommandSource, manager: TodoManager, header_key: s return total_pages = (total_items + page_size - 1) // page_size - # For `input_page`, 1 refers to the first page, -1 refers to the last page, etc. - # Specially, 0 refers to the first page. - - # This `page`'s index start from 0 + if input_page > 0: page = (input_page - 1) % total_pages else: @@ -350,7 +358,7 @@ def render_paged_list(source: CommandSource, manager: TodoManager, header_key: s # 上一页按钮 if page > 0: - prev_cmd = f"{COMMAND_PREFIX} {'archive' if is_archive else 'list'} {page}" + prev_cmd = f"{COMMAND_PREFIX} {cmd_prefix} {page}" footer.append(Utils.create_button("<<", RColor.aqua, server.tr("todo.action.prev_page"), prev_cmd)) else: footer.append(RText("[<<]", color=RColor.gray)) @@ -359,7 +367,7 @@ def render_paged_list(source: CommandSource, manager: TodoManager, header_key: s # 下一页按钮 if page < total_pages - 1: - next_cmd = f"{COMMAND_PREFIX} {'archive' if is_archive else 'list'} {page + 2}" + next_cmd = f"{COMMAND_PREFIX} {cmd_prefix} {page + 2}" footer.append(Utils.create_button(">>", RColor.aqua, server.tr("todo.action.next_page"), next_cmd)) else: footer.append(RText("[>>]", color=RColor.gray)) diff --git a/sakura_flow/manager.py b/sakura_flow/manager.py index 18501f0..dd91538 100644 --- a/sakura_flow/manager.py +++ b/sakura_flow/manager.py @@ -2,13 +2,53 @@ import os import time from typing import Dict, Any, List +from contextlib import contextmanager from .enums import Status +class FileLock: + """简单的基于文件的互斥锁""" + def __init__(self, lock_file: str, timeout: int = 5, delay: float = 0.1): + self.lock_file = lock_file + self.timeout = timeout + self.delay = delay + + def acquire(self): + start_time = time.time() + while True: + try: + # 尝试以独占模式创建锁文件 + fd = os.open(self.lock_file, os.O_CREAT | os.O_EXCL | os.O_RDWR) + os.close(fd) + return True + except OSError: + # 文件已存在,说明被锁定 + if time.time() - start_time >= self.timeout: + raise TimeoutError(f"Could not acquire lock on {self.lock_file}") + time.sleep(self.delay) + + def release(self): + try: + os.remove(self.lock_file) + except OSError: + pass + + @contextmanager + def lock(self): + self.acquire() + try: + yield + finally: + self.release() + + class TodoManager: def __init__(self, data_path: str): self.data_path = data_path + self.lock_path = data_path + ".lock" + self.file_lock = FileLock(self.lock_path) self.data: Dict[str, Any] = {"tasks": {}, "next_id": 1, "default_tier": "LV"} + # 初始加载不需要锁,因为只是读取 self.load() def load(self): @@ -30,30 +70,40 @@ def save(self): except IOError: pass + @contextmanager + def transaction(self): + """ + 事务上下文:获取锁 -> 重新加载数据 -> 执行操作 -> 保存数据 -> 释放锁 + """ + with self.file_lock.lock(): + self.load() # 关键:在持有锁的情况下重新加载最新数据 + yield + self.save() + def set_default_tier(self, tier: str): - self.data["default_tier"] = tier - self.save() + with self.transaction(): + self.data["default_tier"] = tier def add_task(self, title: str, creator: str) -> str: - task_id = str(self.data["next_id"]) - self.data["tasks"][task_id] = { - "title": title, - "creator": creator, - "description": "暂无描述", # TODO 移除硬编码 - "status": Status.IN_PROGRESS.value, - "tier": self.data.get("default_tier", "LV"), - "priority": "Medium", - "labels": [], - "collaborators": [], - "dependencies": [], - "notes": [], - "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "last_updated": time.strftime("%Y-%m-%d %H:%M:%S"), - "last_editor": creator - } - self.data["next_id"] += 1 - self.save() - return task_id + with self.transaction(): + task_id = str(self.data["next_id"]) + self.data["tasks"][task_id] = { + "title": title, + "creator": creator, + "description": "", + "status": Status.IN_PROGRESS.value, + "tier": self.data.get("default_tier", "LV"), + "priority": "Medium", + "labels": [], + "collaborators": [], + "dependencies": [], + "notes": [], + "created_at": time.strftime("%Y-%m-%d %H:%M:%S"), + "last_updated": time.strftime("%Y-%m-%d %H:%M:%S"), + "last_editor": creator + } + self.data["next_id"] += 1 + return task_id @staticmethod def _sort_collection(collection: List, key_type: str): @@ -68,48 +118,48 @@ def _sort_collection(collection: List, key_type: str): collection.sort() def update_task(self, task_id: str, key: str, value: Any, editor: str) -> bool: - task = self.data["tasks"].get(task_id) - if not task: - return False - - # 列表属性去重与自然排序 (包含 labels) - if key in ["collaborators", "dependencies", "labels"]: - if value not in task[key]: - task[key].append(value) - self._sort_collection(task[key], key) - else: + with self.transaction(): + task = self.data["tasks"].get(task_id) + if not task: return False - else: - task[key] = value - - task.update({ - "last_updated": time.strftime("%Y-%m-%d %H:%M:%S"), - "last_editor": editor - }) - self.save() - return True - def remove_item(self, task_id: str, key: str, value: str, editor: str) -> bool: - task = self.data["tasks"].get(task_id) - # 确保 labels 也在可移除字段中 - if not task or key not in ["collaborators", "dependencies", "labels"]: - return False + # 列表属性去重与自然排序 (包含 labels) + if key in ["collaborators", "dependencies", "labels"]: + if value not in task[key]: + task[key].append(value) + self._sort_collection(task[key], key) + else: + return False + else: + task[key] = value - if value in task[key]: - task[key].remove(value) task.update({ "last_updated": time.strftime("%Y-%m-%d %H:%M:%S"), "last_editor": editor }) - self.save() return True - return False + + def remove_item(self, task_id: str, key: str, value: str, editor: str) -> bool: + with self.transaction(): + task = self.data["tasks"].get(task_id) + # 确保 labels 也在可移除字段中 + if not task or key not in ["collaborators", "dependencies", "labels"]: + return False + + if value in task[key]: + task[key].remove(value) + task.update({ + "last_updated": time.strftime("%Y-%m-%d %H:%M:%S"), + "last_editor": editor + }) + return True + return False def add_note(self, task_id: str, content: str, author: str) -> bool: - task = self.data["tasks"].get(task_id) - if not task: return False - note = {"time": time.strftime("%Y-%m-%d %H:%M:%S"), "author": author, "content": content} - task["notes"].append(note) - task.update({"last_updated": note["time"], "last_editor": author}) - self.save() - return True + with self.transaction(): + task = self.data["tasks"].get(task_id) + if not task: return False + note = {"time": time.strftime("%Y-%m-%d %H:%M:%S"), "author": author, "content": content} + task["notes"].append(note) + task.update({"last_updated": note["time"], "last_editor": author}) + return True diff --git a/sakura_flow/mcdr_entry.py b/sakura_flow/mcdr_entry.py new file mode 100644 index 0000000..5437ad7 --- /dev/null +++ b/sakura_flow/mcdr_entry.py @@ -0,0 +1,235 @@ +from mcdreforged.api.all import * +from mcdreforged.api.command import * + +from .controller import TodoController +from .interface import UI +from .utils import Utils +from .constants import COMMAND_PREFIX, GT_TIERS +from .enums import Status, Tier, Priority + +def register_mcdr_commands(server: PluginServerInterface, controller: TodoController): + # --- Command Callbacks --- + + def on_welcome(source: CommandSource): + source.reply(UI.render_welcome(server)) + + def on_help(source: CommandSource): + source.reply(UI.render_help(server)) + + def on_list(source: CommandSource, context: CommandContext): + page = context.get("page", 1) + # 使用 search_tasks 获取非 Done 任务 + tasks = controller.search_tasks({'status': '!Done'}) + UI.render_paged_list(source, tasks, controller.manager, 'todo.list.header', 'todo.list.empty', + input_page=page, cmd_prefix="list") + + def on_archive(source: CommandSource, context: CommandContext): + page = context.get("page", 1) + # 使用 search_tasks 获取 Done 任务 + tasks = controller.search_tasks({'status': 'Done'}) + UI.render_paged_list(source, tasks, controller.manager, 'todo.archive.header', 'todo.archive.empty', + input_page=page, cmd_prefix="archive") + + def on_search(source: CommandSource, context: CommandContext): + query_raw = context['query'] + player_key = source.player if source.is_player else "Console" + + # 尝试判断是否为纯数字(翻页) + is_page = False + page = 1 + if query_raw.isdigit(): + is_page = True + page = int(query_raw) + + results = None + + if is_page: + # 尝试从缓存获取 + results = controller.get_cached_search(player_key) + if results is None: + # 缓存过期或不存在,提示用户重新搜索 + source.reply(Utils.error_msg(server, 'todo.search.cache_expired')) + return + else: + # 执行新搜索 + # 解析复合查询 + # 示例: "c=playerA s=!Done title=机器" + criteria = {} + parts = query_raw.split() + + for part in parts: + if '=' in part: + key, val = part.split('=', 1) + key = key.lower() + + # 映射简写 + if key in ['t', 'title']: criteria['title'] = val + elif key in ['s', 'stat', 'status']: criteria['status'] = val + elif key in ['tier']: criteria['tier'] = val + elif key in ['p', 'prio', 'priority']: criteria['priority'] = val + elif key in ['c', 'creator']: criteria['creator'] = val + elif key in ['collab', 'collaborator']: criteria['collaborator'] = val + elif key in ['l', 'label']: criteria['label'] = val + # 可以添加更多映射 + else: + # 如果没有等号,默认视为标题搜索 + # 如果已经有标题搜索条件了,可以追加还是覆盖? + # 简单起见,覆盖或者作为补充。这里假设用户只输入一个标题关键词。 + criteria['title'] = part + + # 执行搜索并缓存 + results = controller.search_tasks(criteria, cache_key=player_key) + page = 1 # 新搜索重置为第一页 + + # 渲染搜索结果 + UI.render_paged_list(source, results, controller.manager, 'todo.search.header', 'todo.search.empty', + input_page=page, cmd_prefix="search") + + + def on_add(source: CommandSource, context: CommandContext): + creator = source.player if source.is_player else "Console" + tid = controller.add_task(context['title'], creator) + tid_text = RText(f"#{tid}", color=RColor.green, styles=RStyle.bold) + source.reply(Utils.info_msg(server, 'todo.msg.add_success', tid_text)) + + def on_info(source: CommandSource, context: CommandContext): + tid = str(context['id']) + task = controller.get_task(tid) + if not task: + source.reply(Utils.error_msg(server, 'todo.msg.not_found')) + return + source.reply(UI.render_task_info(tid, task, controller.manager.data["tasks"], server)) + + def on_set(source: CommandSource, context: CommandContext): + editor = source.player if source.is_player else "Console" + success, val, err = controller.set_property(str(context['id']), context['prop'], context['value'], editor) + + if not success: + if err == 'todo.msg.invalid_tier': + tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) + source.reply(Utils.error_msg(server, err, len(GT_TIERS) - 1, tier_list)) + elif err == 'todo.msg.invalid_priority': + prio_list = Utils.list_to_rtext([Priority.get_rtext(p.value) for p in Priority]) + source.reply(Utils.error_msg(server, err, prio_list)) + elif err == 'todo.msg.invalid_status': + status_list = Utils.list_to_rtext([Status.get_rtext(s.value) for s in Status]) + source.reply(Utils.error_msg(server, err, status_list)) + else: + source.reply(Utils.error_msg(server, err or 'todo.msg.unknown_error', context.get('prop'))) + return + + # 成功后的 UI 反馈 + # 需要重新获取 real_prop 对应的显示文本,这里稍微有点 hack,因为 controller 已经处理了逻辑 + # 简单起见,我们直接用 context['prop'] 作为显示,或者让 controller 返回 real_prop + # 为了更好的体验,这里简单处理: + rval = RText(val) + # 尝试美化显示 + if Tier.validate(val): rval = Tier.get_rtext(val) + elif Priority.validate(val): rval = Priority.get_rtext(val, server) + elif Status.validate(val): rval = Status.get_rtext(val, server) + + source.reply(Utils.info_msg(server, 'todo.msg.set_success', context['id'], context['prop'], rval)) + + def on_append(source: CommandSource, context: CommandContext): + editor = source.player if source.is_player else "Console" + success, err = controller.append_list_property(str(context['id']), context['list_prop'], str(context['value']), editor) + + if not success: + if err == 'todo.msg.dep_not_found': + source.reply(Utils.error_msg(server, err, context['value'])) + else: + source.reply(Utils.error_msg(server, err or 'todo.msg.unknown_error', context['list_prop'])) + return + + source.reply(Utils.info_msg(server, 'todo.msg.append_success', context['id'], context['list_prop'], context['value'])) + + def on_remove(source: CommandSource, context: CommandContext): + editor = source.player if source.is_player else "Console" + success, err = controller.remove_list_property(str(context['id']), context['list_prop'], str(context['value']), editor) + + if not success: + source.reply(Utils.error_msg(server, 'todo.msg.remove_failed', context['value'])) + return + + source.reply(Utils.info_msg(server, 'todo.msg.remove_success', context['id'], context['list_prop'], context['value'])) + + def on_note(source: CommandSource, context: CommandContext): + author = source.player if source.is_player else "Console" + if controller.add_note(str(context['id']), context['content'], author): + source.reply(Utils.info_msg(server, 'todo.msg.note_success', context['id'])) + + def on_status_change(source: CommandSource, context: CommandContext, status: Status, msg_key: str): + editor = source.player if source.is_player else "Console" + if controller.update_status(str(context['id']), status, editor): + source.reply(Utils.info_msg(server, msg_key, context['id'])) + + def on_default_tier(source: CommandSource, context: CommandContext): + if controller.set_default_tier(context['tier']): + source.reply(Utils.info_msg(server, 'todo.msg.default_tier_success', context['tier'])) + else: + tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) + source.reply(Utils.error_msg(server, 'todo.msg.invalid_tier', len(GT_TIERS) - 1, tier_list)) + + + # --- Command Tree Definition --- + + # Nodes + node_root = Literal(COMMAND_PREFIX).runs(on_welcome) + + node_help = Literal('help').runs(on_help) + + node_list = Literal('list').runs(on_list).then(Integer('page').runs(on_list)) + node_list_alias = Literal('l').runs(on_list).then(Integer('page').runs(on_list)) + + node_archive = Literal('archive').runs(on_archive).then(Integer('page').runs(on_archive)) + node_archive_alias = Literal('ar').runs(on_archive).then(Integer('page').runs(on_archive)) + + node_search = Literal('search').then(GreedyText('query').runs(on_search)) + node_search_alias = Literal('find').then(GreedyText('query').runs(on_search)) + + node_add = Literal('add').then(GreedyText('title').runs(on_add)) + node_add_alias = Literal('a').then(GreedyText('title').runs(on_add)) + + node_info = Literal('info').then(Text('id').runs(on_info)) + node_info_alias = Literal('i').then(Text('id').runs(on_info)) + + node_set = Literal('set').then( + Text('id').then( + Text('prop').then( + GreedyText('value').runs(on_set) + ) + ) + ) + node_set_alias = Literal('s').then(Text('id').then(Text('prop').then(GreedyText('value').runs(on_set)))) + + node_append = Literal('append').then(Text('id').then(Text('list_prop').then(GreedyText('value').runs(on_append)))) + node_append_alias = Literal('ap').then(Text('id').then(Text('list_prop').then(GreedyText('value').runs(on_append)))) + + node_remove = Literal('remove').then(Text('id').then(Text('list_prop').then(GreedyText('value').runs(on_remove)))) + node_remove_alias = Literal('rm').then(Text('id').then(Text('list_prop').then(GreedyText('value').runs(on_remove)))) + + node_note = Literal('note').then(Text('id').then(GreedyText('content').runs(on_note))) + node_note_alias = Literal('n').then(Text('id').then(GreedyText('content').runs(on_note))) + + node_complete = Literal('complete').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.DONE, 'todo.msg.complete_success'))) + node_pause = Literal('pause').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.ON_HOLD, 'todo.msg.pause_success'))) + node_resume = Literal('resume').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'todo.msg.resume_success'))) + node_restore = Literal('restore').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'todo.msg.restore_success'))) + + node_default_tier = Literal('default_tier').then(Text('tier').runs(on_default_tier)) + + # Assembly + node_root.then(node_help) + node_root.then(node_list).then(node_list_alias) + node_root.then(node_archive).then(node_archive_alias) + node_root.then(node_search).then(node_search_alias) + node_root.then(node_add).then(node_add_alias) + node_root.then(node_info).then(node_info_alias) + node_root.then(node_set).then(node_set_alias) + node_root.then(node_append).then(node_append_alias) + node_root.then(node_remove).then(node_remove_alias) + node_root.then(node_note).then(node_note_alias) + node_root.then(node_complete).then(node_pause).then(node_resume).then(node_restore) + node_root.then(node_default_tier) + + server.register_command(node_root) From 089e4b3328ea56329e8c960f3c14f40f9f73f476 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Sat, 7 Feb 2026 14:01:26 +0800 Subject: [PATCH 03/11] Update CI workflow triggers - Configure push triggers for `main`, `dev` branches and `v*` tags - Configure pull request triggers for `main` and `dev` branches - Add `paths-ignore` for markdown and gitignore files to skip unnecessary builds --- .github/workflows/release.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d7fa8d3..5de9cea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,21 @@ name: CI for MCDR Plugin on: push: + branches: + - main + - dev + tags: + - 'v*' + paths-ignore: + - '**.md' + - '.gitignore' pull_request: + branches: + - main + - dev + paths-ignore: + - '**.md' + - '.gitignore' permissions: contents: write From ae239f619e3f4c67afecd1ab1873d7e15c905aa1 Mon Sep 17 00:00:00 2001 From: BLyb25 <1530253552@qq.com> Date: Sat, 7 Feb 2026 15:55:45 +0800 Subject: [PATCH 04/11] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=20README.md=20?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=92=8C=E5=86=85=E5=AE=B9=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 61 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index e5dbfa9..500f1c1 100644 --- a/README.md +++ b/README.md @@ -6,31 +6,33 @@ Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题( ## ✨ 核心特性 -* **游戏内管理**: 直接在聊天栏进行全流程任务管理,无需切换窗口。 -* **富文本交互**: 基于 Minecraft 聊天栏的**富文本(JSON)** 特性,支持点击按钮执行指令(完成、暂停、查看详情),操作便捷。 -* **依赖管理**: 专为复杂工程设计,支持设置任务**前置条件(Dependencies)**,自动梳理任务执行顺序。 -* **高效输入**: 提供智能指令别名系统(如 `t` 代表 `tier`),简化指令输入流程。 -* **层级可视化**: 内置动态颜色系统,根据任务等级(Tier)自动区分显示样式,直观展示任务重要性。 +* **游戏内管理**: 直接在聊天栏进行全流程任务管理,无需切换窗口。 +* **富文本交互**: 基于 Minecraft 聊天栏的**富文本(JSON)** 特性,支持点击按钮执行指令(完成、暂停、查看详情),操作便捷。 +* **依赖管理**: 专为复杂工程设计,支持设置任务**前置条件(Dependencies)**,自动梳理任务执行顺序。 +* **高效输入**: 提供智能指令别名系统(如 `t` 代表 `tier`),简化指令输入流程。 +* **层级可视化**: 内置动态颜色系统,根据任务等级(Tier)自动区分显示样式,直观展示任务重要性。 ## 🚀 快速上手 (Quick Start) 本节介绍插件的基本工作流: -1. **创建任务** - 示例:创建一个名为“建造刷铁机”的任务。 +1. **创建任务** + 示例:创建一个名为“建造刷铁机”的任务。任务会自动分配一个唯一的ID,后续操作该任务需要使用其ID。 `!!todo add 建造刷铁机` -2. **查看列表** +2. **查看列表** 显示当前所有待办任务。 `!!todo list` *(提示:鼠标悬浮在 `[#ID]` 上可预览任务摘要)* -3. **追加记录** +3. **追加记录** 为任务添加详细说明或进度笔记。 - `!!todo note <ID> 需要准备 20 张床` + `!!todo note <ID> 需要准备 20 张床` 或者 `!!todo note <ID> 已完成建筑材料的准备` *(提示:点击列表中的 `[✎]` 按钮可自动填充此指令前缀)* -4. **标记完成** +4. **标记完成** 任务完成后,将其标记为 Done 并归档。 `!!todo complete <ID>` *(提示:也可直接点击列表中的 `[✔]` 按钮)* +5. **查看历史任务** + `!!todo archive`可查看所有已完成的历史任务 ## 📖 指令手册 @@ -39,11 +41,11 @@ Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题( ### 1. 基础操作 | 动作 | 指令格式 | 别称 | 说明 | -|:-------|:-----------------------|:----|:-----------------------| -| **新建** | `!!todo add <标题>` | `a` | 创建一个新任务。 | -| **列表** | `!!todo list` | `l` | 显示所有**进行中**的任务(含交互按钮)。 | +|:-------|:------------------|:----|:-----------------------| +| **新建** | `!!todo add <标题>` | `a` | 创建一个新任务。任务会自动分配一个唯一的ID,后续操作该任务需要使用其ID。 | +| **列表** | `!!todo list` | `l` | 显示所有**进行中**的任务(含交互按钮)。**已完成**的任务见后文进阶管理。 | | **详情** | `!!todo info <ID>` | `i` | 查看指定任务的详细信息(依赖、笔记等)。 | -| **完成** | `!!todo complete <ID>` | - | 标记任务为完成并移入归档。 | +| **完成** | `!!todo complete <ID>` | - | 标记任务为完成并移入归档。 | | **帮助** | `!!todo help` | - | 显示帮助菜单。 | ### 2. 进阶管理 @@ -66,27 +68,28 @@ Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题( | **添加列表项** | `!!todo append <ID> <属性> <值>` | `ap` | 向列表属性(如依赖 `dep`、协作者 `collab`)追加值。 | | **移除列表项** | `!!todo remove <ID> <属性> <值>` | `rm` | 从列表属性中移除指定值。 | -> **说明**: -> * **Tier (等级)**: 支持 `0-14` 的整数。数值越大,任务在列表中的显示颜色越醒目。 -> * **Dependencies (依赖)**: 使用 `!!todo ap <ID> dep <前置ID>` 添加依赖关系。 +> **说明**: +> +> * **Tier (等级)**: 支持 `0-14` 的整数。数值越大,任务在列表中的显示颜色越醒目。 +> * **Dependencies (依赖)**: 使用 `!!todo ap <ID> dep <前置ID>` 添加依赖关系。 ## 🖱️ 交互界面指南 插件在聊天栏提供以下交互元素: -* **列表视图**: - * `[#ID]`: **悬浮**查看摘要,**点击**自动填充 `info` 指令。 - * `[▶] / [⏸]`: **点击**切换 暂停/进行中 状态。 - * `[✔]`: **点击**直接完成任务。 - * `[✎]`: **点击**自动填充 `note` 指令。 -* **详情视图**: - * **依赖链**: 点击依赖任务的 ID 可跳转查看该前置任务的详情。 +* **列表视图**: + * `[#ID]`: **悬浮**查看摘要,**点击**自动填充 `info` 指令。 + * `[▶] / [⏸]`: **点击**切换 暂停/进行中 状态。 + * `[✔]`: **点击**直接完成任务。 + * `[✎]`: **点击**自动填充 `note` 指令。 +* **详情视图**: + * **依赖链**: 点击依赖任务的 ID 可跳转查看该前置任务的详情。 ## 📦 安装说明 -1. 确保服务端已安装 [MCDReforged](https://github.com/MCDReforged/MCDReforged)。 -2. 下载本插件,将文件夹放入 `plugins` 目录。 -3. 在控制台或游戏内输入 `!!MCDR plugin reload` 重载插件。 +1. 确保服务端已安装 [MCDReforged](https://github.com/MCDReforged/MCDReforged)。 +2. 下载本插件,将文件夹放入 `plugins` 目录。 +3. 在控制台或游戏内输入 `!!MCDR plugin reload` 重载插件。 ## 📝 附录:属性字段速查 @@ -99,5 +102,5 @@ Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题( | `tier` | `t` | 数字 | 任务等级 (0-14) | | `priority` | `p`, `prio` | 文本/数字 | 优先级 (High, Medium...) | | `dependencies` | `d`, `dep` | 列表 | **前置任务 ID** | -| `collaborators` | `c`, `collab` | 列表 | 协作者玩家名 | +| `collaborators` | `c`, `collab` | 列表 | 协作者玩家名 | | `labels` | `l`, `label` | 列表 | 自定义标签 | From c8a0543668d0ec05607d9d35072a56829e4718a7 Mon Sep 17 00:00:00 2001 From: BLyb25 <1530253552@qq.com> Date: Sat, 7 Feb 2026 16:27:05 +0800 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=88=B7?= =?UTF-8?q?=E9=93=81=E6=9C=BA=E5=BB=BA=E9=80=A0=E7=9A=84=E7=BB=BC=E5=90=88?= =?UTF-8?q?=E5=AE=9E=E6=88=98=E7=A4=BA=E4=BE=8B=EF=BC=8C=E4=B8=B0=E5=AF=8C?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=8C=87=E5=8D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/README.md b/README.md index 500f1c1..03d6a11 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,56 @@ Sakura Flow 旨在解决长周期、多线并行的复杂任务管理难题( | `dependencies` | `d`, `dep` | 列表 | **前置任务 ID** | | `collaborators` | `c`, `collab` | 列表 | 协作者玩家名 | | `labels` | `l`, `label` | 列表 | 自定义标签 | + +## 🛠️ 示例:建造刷铁机 + +以下是从零开始管理一个“刷铁机建造工程”的完整指令流: + +### 1. 创建与基础设置 +* **创建主任务**: + `!!todo add 建造高效刷铁机` (假设分配 ID 为 `1`) +* **设置任务等级**: + `!!todo set 1 tier 5` (将等级设为 5,使其在列表中以更醒目的颜色显示) +* **设置任务优先级**: + `!!todo set 1 priority High` (标记为高优先级) +* **添加详细描述**: + `!!todo set 1 desc 位于主城坐标 100, 64, 200 的 32 堆叠刷铁机` + +### 2. 细化任务与依赖管理 +* **创建前置子任务**: + `!!todo add 收集 20 张床` (假设分配 ID 为 `2`) + `!!todo add 运输 3 名村民` (假设分配 ID 为 `3`) +* **建立依赖关系**: + `!!todo append 1 dep 2` (主任务 `1` 现在依赖于 `2`) + `!!todo append 1 dep 3` (主任务 `1` 现在依赖于 `3`) + +### 3. 协作与进度记录 +* **添加协作者**: + `!!todo append 1 collab Steve` (邀请 Steve 一起参与) +* **记录进度笔记**: + `!!todo note 2 已在沙漠村庄收集到 12 张床,还差 8 张` +* **添加标签**: + `!!todo append 1 label 工业` + +### 4. 状态流转 +* **查看当前进度**: + `!!todo list` (查看所有任务状态) +* **查看任务 1 的详细依赖链**: + `!!todo info 1` +* **暂时挂起**: + `!!todo pause 1` (因材料不足暂时停工) +* **恢复施工**: + `!!todo resume 1` +* **完成前置任务**: + `!!todo complete 2` + `!!todo complete 3` +* **最终归档**: + `!!todo complete 1` + +### 5. 维护与回顾 +* **查看已完成的任务**: + `!!todo archive` +* **移除不再需要的标签**: + `!!todo remove 1 label 工业` +* **若发现漏掉细节需要重做**: + `!!todo restore 1` \ No newline at end of file From d01ba2a6a5ad140935d860cf312a4e22070ba070 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Sat, 7 Feb 2026 17:12:07 +0800 Subject: [PATCH 06/11] Rename translation keys to `sakuraflow` namespace and add CI/tests - Migrate all translation keys from `todo.*` prefix to `sakuraflow.*` in `zh_cn.json` and source code - Add GitHub Actions workflow for automated linting (flake8) and testing (pytest) - Create test suite including smoke tests, command registration checks, and translation key validation - Optimize imports to remove wildcard usage in `mcdr_entry.py`, `interface.py`, and `__init__.py` - Update `.gitignore` for pytest cache and build output --- .github/workflows/test.yml | 68 +++++++++ .gitignore | 11 ++ lang/zh_cn.json | 214 ++++++++++++++--------------- sakura_flow/__init__.py | 2 +- sakura_flow/controller.py | 14 +- sakura_flow/enums.py | 16 +-- sakura_flow/interface.py | 134 +++++++++--------- sakura_flow/mcdr_entry.py | 50 +++---- sakura_flow/utils.py | 2 +- tests/conftest.py | 18 +++ tests/test_command_registration.py | 26 ++++ tests/test_smoke.py | 21 +++ tests/test_translation_keys.py | 67 +++++++++ 13 files changed, 427 insertions(+), 216 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_command_registration.py create mode 100644 tests/test_smoke.py create mode 100644 tests/test_translation_keys.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..43be232 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,68 @@ +name: Tests + +on: + push: + branches: + - main + - dev + paths-ignore: + - '**.md' + - '.gitignore' + - '.idea/**' + pull_request: + branches: + - main + - dev + paths-ignore: + - '**.md' + - '.gitignore' + - '.idea/**' + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Lint with flake8 + run: | + # 1. 检查严重错误和不安全的导入习惯 + # E9: 语法错误 + # F63: 总是为真的测试 + # F7: 编译错误 + # F82: 未定义的变量 + # F401: 导入但未使用的模块 + # F403: 使用了 'from module import *' + flake8 . --count --select=E9,F63,F7,F82,F401,F403 --show-source --statistics + + # 2. 其他风格检查 (作为警告,不中断构建) + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Run tests + run: | + # 确保当前目录在 PYTHONPATH 中,以便测试脚本可以导入插件包 + export PYTHONPATH=$PYTHONPATH:. + # 运行所有测试并显示详细输出 + pytest -v diff --git a/.gitignore b/.gitignore index 5f3da52..bd767bc 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,14 @@ /dataSources.local.xml /.idea/ /sakura_flow/__pycache__/ + +# Python 缓存 +__pycache__/ +*.py[cod] +*$py.class + +# Pytest 缓存 +.pytest_cache/ + +# MCDR 打包输出 +package/ diff --git a/lang/zh_cn.json b/lang/zh_cn.json index f8f8c52..ab64460 100644 --- a/lang/zh_cn.json +++ b/lang/zh_cn.json @@ -1,120 +1,120 @@ { - "todo.common.task": "任务", - "todo.common.creator": "创建者", - "todo.common.tier": "等级", - "todo.common.priority": "优先级", - "todo.common.collaborators": "负责人", - "todo.common.dependencies": "前置依赖", - "todo.common.status": "状态", - "todo.common.description": "描述", - "todo.common.unknown": "未知", - "todo.common.unassigned": "未分配", - "todo.common.none": "无", - "todo.common.usage": "用法", - "todo.common.alias": "缩写", - "todo.common.labels": "任务标签", - "todo.common.title": "标题", + "sakuraflow.common.task": "任务", + "sakuraflow.common.creator": "创建者", + "sakuraflow.common.tier": "等级", + "sakuraflow.common.priority": "优先级", + "sakuraflow.common.collaborators": "负责人", + "sakuraflow.common.dependencies": "前置依赖", + "sakuraflow.common.status": "状态", + "sakuraflow.common.description": "描述", + "sakuraflow.common.unknown": "未知", + "sakuraflow.common.unassigned": "未分配", + "sakuraflow.common.none": "无", + "sakuraflow.common.usage": "用法", + "sakuraflow.common.alias": "缩写", + "sakuraflow.common.labels": "任务标签", + "sakuraflow.common.title": "标题", - "todo.prop.title": "任务标题", - "todo.prop.description": "任务描述", - "todo.prop.status": "任务状态", - "todo.prop.tier": "电压等级", - "todo.prop.priority": "优先级", - "todo.prop.collaborators": "协作人员", - "todo.prop.dependencies": "前置依赖", - "todo.prop.labels": "任务标签", + "sakuraflow.prop.title": "任务标题", + "sakuraflow.prop.description": "任务描述", + "sakuraflow.prop.status": "任务状态", + "sakuraflow.prop.tier": "电压等级", + "sakuraflow.prop.priority": "优先级", + "sakuraflow.prop.collaborators": "协作人员", + "sakuraflow.prop.dependencies": "前置依赖", + "sakuraflow.prop.labels": "任务标签", - "todo.action.restore": "恢复任务", - "todo.action.resume": "恢复", - "todo.action.pause": "暂停", - "todo.action.complete": "完工", - "todo.action.note": "笔记", - "todo.action.claim": "认领", - "todo.action.append": "追加", - "todo.action.modify": "修改", - "todo.action.remove": "移除", - "todo.action.prev_page": "上一页", - "todo.action.next_page": "下一页", + "sakuraflow.action.restore": "恢复任务", + "sakuraflow.action.resume": "恢复", + "sakuraflow.action.pause": "暂停", + "sakuraflow.action.complete": "完工", + "sakuraflow.action.note": "笔记", + "sakuraflow.action.claim": "认领", + "sakuraflow.action.append": "追加", + "sakuraflow.action.modify": "修改", + "sakuraflow.action.remove": "移除", + "sakuraflow.action.prev_page": "上一页", + "sakuraflow.action.next_page": "下一页", - "todo.status.done": "已完成", - "todo.status.in_progress": "进行中", - "todo.status.on_hold": "挂起", + "sakuraflow.status.done": "已完成", + "sakuraflow.status.in_progress": "进行中", + "sakuraflow.status.on_hold": "挂起", - "todo.priority.very_high": "非常高", - "todo.priority.high": "高", - "todo.priority.medium": "中", - "todo.priority.low": "低", - "todo.priority.very_low": "非常低", + "sakuraflow.priority.very_high": "非常高", + "sakuraflow.priority.high": "高", + "sakuraflow.priority.medium": "中", + "sakuraflow.priority.low": "低", + "sakuraflow.priority.very_low": "非常低", - "todo.ui.hover.latest_progress": "最新进展", - "todo.ui.hover.waiting_record": "等待记录", - "todo.ui.hover.click_to_action": "点击{0}{1}", - "todo.ui.hover.remove_hint": "使用 {0} remove 移除项", + "sakuraflow.ui.hover.latest_progress": "最新进展", + "sakuraflow.ui.hover.waiting_record": "等待记录", + "sakuraflow.ui.hover.click_to_action": "点击{0}{1}", + "sakuraflow.ui.hover.remove_hint": "使用 {0} remove 移除项", - "todo.ui.info.header": "任务详情 #{0}", - "todo.ui.info.no_desc": "暂无描述", - "todo.ui.info.progress_header": "任务进度记录", - "todo.ui.info.no_records": "暂无记录", - "todo.ui.info.invalid_dep": "已失效", + "sakuraflow.ui.info.header": "任务详情 #{0}", + "sakuraflow.ui.info.no_desc": "暂无描述", + "sakuraflow.ui.info.progress_header": "任务进度记录", + "sakuraflow.ui.info.no_records": "暂无记录", + "sakuraflow.ui.info.invalid_dep": "已失效", - "todo.help.header": "TodoList 指令帮助", - "todo.help.hint": "提示:点击可填充指令至聊天栏;鼠标移至指令上方查看详情", - "todo.help.list": "查看进行中任务清单", - "todo.help.archive": "查看已完成归档记录", - "todo.help.search": "搜索任务 (支持多条件: t=标题 s=!Done)", - "todo.help.add": "立项一个新的任务", - "todo.help.info": "查询特定任务的详细信息", - "todo.help.note": "追加一条任务进度记录", - "todo.help.set": "修改任务的核心属性", - "todo.help.append": "向任务追加协作人或依赖", - "todo.help.remove": "从任务中移除协作人或依赖", - "todo.help.pause": "挂起当前任务", - "todo.help.resume": "恢复挂起任务", - "todo.help.complete": "标记任务完工并归档", - "todo.help.restore": "将已完成任务重新激活", + "sakuraflow.help.header": "TodoList 指令帮助", + "sakuraflow.help.hint": "提示:点击可填充指令至聊天栏;鼠标移至指令上方查看详情", + "sakuraflow.help.list": "查看进行中任务清单", + "sakuraflow.help.archive": "查看已完成归档记录", + "sakuraflow.help.search": "搜索任务 (支持多条件: t=标题 s=!Done)", + "sakuraflow.help.add": "立项一个新的任务", + "sakuraflow.help.info": "查询特定任务的详细信息", + "sakuraflow.help.note": "追加一条任务进度记录", + "sakuraflow.help.set": "修改任务的核心属性", + "sakuraflow.help.append": "向任务追加协作人或依赖", + "sakuraflow.help.remove": "从任务中移除协作人或依赖", + "sakuraflow.help.pause": "挂起当前任务", + "sakuraflow.help.resume": "恢复挂起任务", + "sakuraflow.help.complete": "标记任务完工并归档", + "sakuraflow.help.restore": "将已完成任务重新激活", - "todo.help.desc.set.main": "修改任务属性。电压(t): 0-14对应(ULV-MAX);优先级(p): 0=Very High, 4=Very Low", - "todo.help.available_props": "可用属性:", - "todo.help.desc.list.main": "向任务的列表字段中追加或移除项。依赖项需为有效的任务ID。", - "todo.help.available_lists": "可用列表:", + "sakuraflow.help.desc.set.main": "修改任务属性。电压(t): 0-14对应(ULV-MAX);优先级(p): 0=Very High, 4=Very Low", + "sakuraflow.help.available_props": "可用属性:", + "sakuraflow.help.desc.list.main": "向任务的列表字段中追加或移除项。依赖项需为有效的任务ID。", + "sakuraflow.help.available_lists": "可用列表:", - "todo.welcome.header": "TodoList", - "todo.welcome.line1": "欢迎使用待办事项管理插件。", - "todo.welcome.line2": "本插件能够协助管理复杂的流程,支持任务追踪、等级标记及协作管理。", - "todo.welcome.btn_line": "可用快捷操作", - "todo.welcome.btn.help": "查看帮助", - "todo.welcome.hover.help": "点击查看帮助", - "todo.welcome.btn.list": "任务列表", - "todo.welcome.hover.list": "点击查看未完成任务", - "todo.welcome.btn.add": "新建任务", - "todo.welcome.hover.add": "点击填充新建指令", + "sakuraflow.welcome.header": "TodoList", + "sakuraflow.welcome.line1": "欢迎使用待办事项管理插件。", + "sakuraflow.welcome.line2": "本插件能够协助管理复杂的流程,支持任务追踪、等级标记及协作管理。", + "sakuraflow.welcome.btn_line": "可用快捷操作", + "sakuraflow.welcome.btn.help": "查看帮助", + "sakuraflow.welcome.hover.help": "点击查看帮助", + "sakuraflow.welcome.btn.list": "任务列表", + "sakuraflow.welcome.hover.list": "点击查看未完成任务", + "sakuraflow.welcome.btn.add": "新建任务", + "sakuraflow.welcome.hover.add": "点击填充新建指令", - "todo.list.header": "任务列表", - "todo.list.empty": "当前没有进行中的任务", - "todo.archive.header": "已归档任务", - "todo.archive.empty": "归档记录为空", - "todo.search.header": "搜索结果", - "todo.search.empty": "未找到匹配的任务", - "todo.search.more_results": "... 还有 {0} 条结果", - "todo.search.cache_expired": "搜索缓存已过期,请重新输入查询条件", + "sakuraflow.list.header": "任务列表", + "sakuraflow.list.empty": "当前没有进行中的任务", + "sakuraflow.archive.header": "已归档任务", + "sakuraflow.archive.empty": "归档记录为空", + "sakuraflow.search.header": "搜索结果", + "sakuraflow.search.empty": "未找到匹配的任务", + "sakuraflow.search.more_results": "... 还有 {0} 条结果", + "sakuraflow.search.cache_expired": "搜索缓存已过期,请重新输入查询条件", - "todo.msg.add_success": "任务 {0} 已成功立项!", - "todo.msg.not_found": "未找到任务 ID", - "todo.msg.invalid_list_alias": "无效列表别称: {0}", - "todo.msg.dep_not_found": "引用错误:任务 #{0} 不存在,无法添加为前置依赖", - "todo.msg.append_success": "已向任务 #{0} 列表 {1} 追加: {2}", - "todo.msg.remove_success": "已从任务 #{0} 的 {1} 中移除: {2}", - "todo.msg.remove_failed": "移除失败:项 {0} 不在列表内或任务 ID 错误", - "todo.msg.invalid_prop_alias": "无效属性别称: {0}", - "todo.msg.invalid_tier": "无效的电压等级!请输入 0-{0} 或名称: {1}", - "todo.msg.invalid_priority": "无效优先级!请输入 0-4 或名称: {0}", - "todo.msg.invalid_status": "无效状态!请输入: {0}", - "todo.msg.set_success": "任务 #{0} 属性 {1} 已更新为 {2}", - "todo.msg.note_success": "进度已记录至任务 #{0}", - "todo.msg.restore_success": "任务 #{0} 已从归档中恢复至进行中状态 ▶", - "todo.msg.complete_success": "任务 #{0} 已完工并移入归档 ✔", - "todo.msg.pause_success": "任务 #{0} 已标记为暂停 ⏸", - "todo.msg.resume_success": "任务 #{0} 已恢复运行 ▶", - "todo.msg.default_tier_success": "默认电压等级已设置为: {0}", - "todo.msg.unknown_error": "未知错误: {0}" + "sakuraflow.msg.add_success": "任务 {0} 已成功立项!", + "sakuraflow.msg.not_found": "未找到任务 ID", + "sakuraflow.msg.invalid_list_alias": "无效列表别称: {0}", + "sakuraflow.msg.dep_not_found": "引用错误:任务 #{0} 不存在,无法添加为前置依赖", + "sakuraflow.msg.append_success": "已向任务 #{0} 列表 {1} 追加: {2}", + "sakuraflow.msg.remove_success": "已从任务 #{0} 的 {1} 中移除: {2}", + "sakuraflow.msg.remove_failed": "移除失败:项 {0} 不在列表内或任务 ID 错误", + "sakuraflow.msg.invalid_prop_alias": "无效属性别称: {0}", + "sakuraflow.msg.invalid_tier": "无效的电压等级!请输入 0-{0} 或名称: {1}", + "sakuraflow.msg.invalid_priority": "无效优先级!请输入 0-4 或名称: {0}", + "sakuraflow.msg.invalid_status": "无效状态!请输入: {0}", + "sakuraflow.msg.set_success": "任务 #{0} 属性 {1} 已更新为 {2}", + "sakuraflow.msg.note_success": "进度已记录至任务 #{0}", + "sakuraflow.msg.restore_success": "任务 #{0} 已从归档中恢复至进行中状态 ▶", + "sakuraflow.msg.complete_success": "任务 #{0} 已完工并移入归档 ✔", + "sakuraflow.msg.pause_success": "任务 #{0} 已标记为暂停 ⏸", + "sakuraflow.msg.resume_success": "任务 #{0} 已恢复运行 ▶", + "sakuraflow.msg.default_tier_success": "默认电压等级已设置为: {0}", + "sakuraflow.msg.unknown_error": "未知错误: {0}" } \ No newline at end of file diff --git a/sakura_flow/__init__.py b/sakura_flow/__init__.py index cf8c0ce..9d7b73b 100644 --- a/sakura_flow/__init__.py +++ b/sakura_flow/__init__.py @@ -1,4 +1,4 @@ -from mcdreforged.api.all import * +from mcdreforged.api.all import PluginServerInterface import os from .manager import TodoManager diff --git a/sakura_flow/controller.py b/sakura_flow/controller.py index 477daa5..738ec74 100644 --- a/sakura_flow/controller.py +++ b/sakura_flow/controller.py @@ -140,26 +140,26 @@ def set_property(self, task_id: str, prop_alias: str, value: str, editor: str) - """ real_prop = PROP_ALIASES.get(prop_alias.lower()) if not real_prop: - return False, None, 'todo.msg.invalid_prop_alias' + return False, None, 'sakuraflow.msg.invalid_prop_alias' processed_val = value if real_prop == "tier": validated = Tier.validate(value) if not validated: - return False, None, 'todo.msg.invalid_tier' + return False, None, 'sakuraflow.msg.invalid_tier' processed_val = validated elif real_prop == "priority": validated = Priority.validate(value) if not validated: - return False, None, 'todo.msg.invalid_priority' + return False, None, 'sakuraflow.msg.invalid_priority' processed_val = validated elif real_prop == "status": validated = Status.validate(value) if not validated: - return False, None, 'todo.msg.invalid_status' + return False, None, 'sakuraflow.msg.invalid_status' processed_val = validated success = self.manager.update_task(task_id, real_prop, processed_val, editor) @@ -172,10 +172,10 @@ def append_list_property(self, task_id: str, list_alias: str, value: str, editor """ real_prop = LIST_PROP_ALIASES.get(list_alias.lower()) if not real_prop: - return False, 'todo.msg.invalid_list_alias' + return False, 'sakuraflow.msg.invalid_list_alias' if real_prop == "dependencies" and value not in self.manager.data["tasks"]: - return False, 'todo.msg.dep_not_found' + return False, 'sakuraflow.msg.dep_not_found' success = self.manager.update_task(task_id, real_prop, value, editor) return success, None @@ -187,7 +187,7 @@ def remove_list_property(self, task_id: str, list_alias: str, value: str, editor """ real_prop = LIST_PROP_ALIASES.get(list_alias.lower()) if not real_prop: - return False, 'todo.msg.invalid_list_alias' + return False, 'sakuraflow.msg.invalid_list_alias' success = self.manager.remove_item(task_id, real_prop, value, editor) return success, None diff --git a/sakura_flow/enums.py b/sakura_flow/enums.py index 3ef42a4..e022d1b 100644 --- a/sakura_flow/enums.py +++ b/sakura_flow/enums.py @@ -83,19 +83,19 @@ def validate(cls, val: str) -> Optional[str]: class Status(BaseProperty): - DONE = ("Done", RColor.green, ['done', 'd', 'finished', 'finish', 'complete', 'completed'], "todo.status.done") + DONE = ("Done", RColor.green, ['done', 'd', 'finished', 'finish', 'complete', 'completed'], "sakuraflow.status.done") IN_PROGRESS = ("In Progress", RColor.aqua, ['in progress', 'inprogress', 'ip', 'progress', 'p', 'doing'], - "todo.status.in_progress") + "sakuraflow.status.in_progress") ON_HOLD = ("On Hold", RColor.red, ['on hold', 'onhold', 'hold', 'oh', 'h', 'pause', 'paused', 'waiting'], - "todo.status.on_hold") + "sakuraflow.status.on_hold") class Priority(BaseProperty): - VERY_HIGH = ("Very High", RColor.dark_red, ['veryhigh', 'vh', '0'], "todo.priority.very_high") - HIGH = ("High", RColor.red, ['high', 'h', '1'], "todo.priority.high") - MEDIUM = ("Medium", RColor.yellow, ['medium', 'med', 'm', '2'], "todo.priority.medium") - LOW = ("Low", RColor.green, ['low', 'l', '3'], "todo.priority.low") - VERY_LOW = ("Very Low", RColor.gray, ['verylow', 'vl', '4'], "todo.priority.very_low") + VERY_HIGH = ("Very High", RColor.dark_red, ['veryhigh', 'vh', '0'], "sakuraflow.priority.very_high") + HIGH = ("High", RColor.red, ['high', 'h', '1'], "sakuraflow.priority.high") + MEDIUM = ("Medium", RColor.yellow, ['medium', 'med', 'm', '2'], "sakuraflow.priority.medium") + LOW = ("Low", RColor.green, ['low', 'l', '3'], "sakuraflow.priority.low") + VERY_LOW = ("Very Low", RColor.gray, ['verylow', 'vl', '4'], "sakuraflow.priority.very_low") class Tier(BaseProperty): diff --git a/sakura_flow/interface.py b/sakura_flow/interface.py index 611e583..057c255 100644 --- a/sakura_flow/interface.py +++ b/sakura_flow/interface.py @@ -1,4 +1,4 @@ -from mcdreforged.api.all import * +from mcdreforged.api.all import RTextBase, RText, RColor, RTextList, ServerInterface, CommandSource, RAction, RStyle from . import TodoManager from .constants import * @@ -43,24 +43,24 @@ def create_hover_info(tid: str, task: dict, tasks_db: dict, server: ServerInterf prio = task.get('priority', 'Medium') collabs = task.get('collaborators', []) - collab_text = Utils.list_to_rtext(collabs) if collabs else server.tr('todo.common.unassigned') + collab_text = Utils.list_to_rtext(collabs) if collabs else server.tr('sakuraflow.common.unassigned') return RTextList( - RText(f"{server.tr('todo.common.task')}: {task['title']}\n", color=RColor.yellow), - RText(f"{server.tr('todo.common.creator')}: {task.get('creator', server.tr('todo.common.unknown'))}\n", + RText(f"{server.tr('sakuraflow.common.task')}: {task['title']}\n", color=RColor.yellow), + RText(f"{server.tr('sakuraflow.common.creator')}: {task.get('creator', server.tr('sakuraflow.common.unknown'))}\n", color=RColor.gray), - RText(f"{server.tr('todo.common.tier')}: ", color=RColor.gray), + RText(f"{server.tr('sakuraflow.common.tier')}: ", color=RColor.gray), Tier.get_rtext(tier), "\n", - RText(f"{server.tr('todo.common.priority')}: ", color=RColor.gray), + RText(f"{server.tr('sakuraflow.common.priority')}: ", color=RColor.gray), Priority.get_rtext(prio, server), "\n", - RText(f"{server.tr('todo.common.collaborators')}: ", color=RColor.gray), collab_text, "\n", - RText(f"{server.tr('todo.common.dependencies')}: ", color=RColor.gray), - Utils.list_to_rtext(dep_display) if dep_display else RText(server.tr('todo.common.none'), + RText(f"{server.tr('sakuraflow.common.collaborators')}: ", color=RColor.gray), collab_text, "\n", + RText(f"{server.tr('sakuraflow.common.dependencies')}: ", color=RColor.gray), + Utils.list_to_rtext(dep_display) if dep_display else RText(server.tr('sakuraflow.common.none'), color=RColor.gray), "\n", RText("-" * 25 + "\n"), RText( - f"{server.tr('todo.ui.hover.latest_progress')}: {task['notes'][-1]['content'] if task.get('notes') else server.tr('todo.ui.hover.waiting_record')}", + f"{server.tr('sakuraflow.ui.hover.latest_progress')}: {task['notes'][-1]['content'] if task.get('notes') else server.tr('sakuraflow.ui.hover.waiting_record')}", color=RColor.gray) ) @@ -79,19 +79,19 @@ def render_task_line(tid: str, task: dict, tasks_db: dict, server: ServerInterfa btns = RTextList() if is_done: btns.append( - Utils.create_button("↺", RColor.blue, server.tr('todo.action.restore'), f"{COMMAND_PREFIX} restore {tid}")) + Utils.create_button("↺", RColor.blue, server.tr('sakuraflow.action.restore'), f"{COMMAND_PREFIX} restore {tid}")) else: is_paused = status_str == Status.ON_HOLD.value - toggle_btn = Utils.create_button("▶", RColor.green, server.tr('todo.action.resume'), + toggle_btn = Utils.create_button("▶", RColor.green, server.tr('sakuraflow.action.resume'), f"{COMMAND_PREFIX} resume {tid}") if is_paused else \ - Utils.create_button("⏸", RColor.yellow, server.tr('todo.action.pause'), f"{COMMAND_PREFIX} pause {tid}") - complete_btn = Utils.create_button("✔", RColor.green, server.tr('todo.action.complete'), + Utils.create_button("⏸", RColor.yellow, server.tr('sakuraflow.action.pause'), f"{COMMAND_PREFIX} pause {tid}") + complete_btn = Utils.create_button("✔", RColor.green, server.tr('sakuraflow.action.complete'), f"{COMMAND_PREFIX} complete {tid}") - note_btn = Utils.create_button("✎", RColor.aqua, server.tr('todo.action.note'), f"{COMMAND_PREFIX} note {tid} ") + note_btn = Utils.create_button("✎", RColor.aqua, server.tr('sakuraflow.action.note'), f"{COMMAND_PREFIX} note {tid} ") btns.append(toggle_btn, " ", complete_btn, " ", note_btn) if source.is_player: - claim_btn = Utils.create_button("★", RColor.gold, server.tr('todo.action.claim'), f"{COMMAND_PREFIX} set {tid} collaborators {source.player}") + claim_btn = Utils.create_button("★", RColor.gold, server.tr('sakuraflow.action.claim'), f"{COMMAND_PREFIX} set {tid} collaborators {source.player}") btns.append(claim_btn) return RTextList( @@ -110,12 +110,12 @@ def _render_info_row(tid: str, label: str, value: RTextBase | str, cmd: str, ser """ LABEL_COLOR = RColor.gray action_type = "append" if is_list else "set" - hint = server.tr('todo.action.append') if is_list else server.tr('todo.action.modify') + hint = server.tr('sakuraflow.action.append') if is_list else server.tr('sakuraflow.action.modify') cmd_str = f"{COMMAND_PREFIX} {action_type} {tid} {cmd} " - hover = server.tr('todo.ui.hover.click_to_action', hint, label) + hover = server.tr('sakuraflow.ui.hover.click_to_action', hint, label) if is_list: - hover += "\n" + server.tr('todo.ui.hover.remove_hint', COMMAND_PREFIX) + hover += "\n" + server.tr('sakuraflow.ui.hover.remove_hint', COMMAND_PREFIX) # 标签部分:永远保持点击触发修改指令 label_component = RText(f"{label}: ", color=LABEL_COLOR).h(hover).c(RAction.suggest_command, cmd_str) @@ -137,7 +137,7 @@ def render_task_info(tid: str, task: dict, tasks_db: dict, server: ServerInterfa # 依赖列表特殊渲染逻辑 deps = task.get("dependencies", []) if not deps: - dep_list = RText(server.tr('todo.common.none'), color=RColor.gray) + dep_list = RText(server.tr('sakuraflow.common.none'), color=RColor.gray) else: dep_items = [] for d_id in deps: @@ -155,7 +155,7 @@ def render_task_info(tid: str, task: dict, tasks_db: dict, server: ServerInterfa .c(RAction.suggest_command, f"{COMMAND_PREFIX} info {d_id}") ) else: - dep_items.append(RText(f"#{d_id}{server.tr('todo.ui.info.invalid_dep')}", color=RColor.red)) + dep_items.append(RText(f"#{d_id}{server.tr('sakuraflow.ui.info.invalid_dep')}", color=RColor.red)) dep_list = Utils.list_to_rtext(dep_items) # 日志内容构建 @@ -169,35 +169,35 @@ def render_task_info(tid: str, task: dict, tasks_db: dict, server: ServerInterfa RText(f"{n['content']}\n") )) else: - notes_content.append(RText(f" {server.tr('todo.ui.info.no_records')}\n", color=RColor.dark_gray)) + notes_content.append(RText(f" {server.tr('sakuraflow.ui.info.no_records')}\n", color=RColor.dark_gray)) collabs = task.get('collaborators', []) - collab_val = Utils.list_to_rtext(collabs) if collabs else server.tr('todo.common.unassigned') + collab_val = Utils.list_to_rtext(collabs) if collabs else server.tr('sakuraflow.common.unassigned') description = task.get('description', '') if not description: - description = server.tr('todo.ui.info.no_desc') + description = server.tr('sakuraflow.ui.info.no_desc') return RTextList( - UI.make_dividing_line(server.tr('todo.ui.info.header', tid)), - UI._render_info_row(tid, server.tr('todo.common.title'), task['title'], "title", server), - RText(f"{server.tr('todo.common.creator')}: ", color=RColor.gray), - RText(f"{task.get('creator', server.tr('todo.common.unknown'))}\n", color=RColor.white), - UI._render_info_row(tid, server.tr('todo.common.status'), Status.get_rtext(task['status'], server), + UI.make_dividing_line(server.tr('sakuraflow.ui.info.header', tid)), + UI._render_info_row(tid, server.tr('sakuraflow.common.title'), task['title'], "title", server), + RText(f"{server.tr('sakuraflow.common.creator')}: ", color=RColor.gray), + RText(f"{task.get('creator', server.tr('sakuraflow.common.unknown'))}\n", color=RColor.white), + UI._render_info_row(tid, server.tr('sakuraflow.common.status'), Status.get_rtext(task['status'], server), "status", server, value_color=Status.get_color(task['status'])), - UI._render_info_row(tid, server.tr('todo.common.tier'), Tier.get_rtext(task['tier']), + UI._render_info_row(tid, server.tr('sakuraflow.common.tier'), Tier.get_rtext(task['tier']), "tier", server, value_color=Tier.get_color(task['tier'])), - UI._render_info_row(tid, server.tr('todo.common.priority'), Priority.get_rtext(task['priority'], server), + UI._render_info_row(tid, server.tr('sakuraflow.common.priority'), Priority.get_rtext(task['priority'], server), "priority", server, value_color=Priority.get_color(task['priority'])), - UI._render_info_row(tid, server.tr('todo.common.collaborators'), collab_val, "collaborators", server, + UI._render_info_row(tid, server.tr('sakuraflow.common.collaborators'), collab_val, "collaborators", server, is_list=True), - UI._render_info_row(tid, server.tr('todo.common.dependencies'), dep_list, "dependency", server, + UI._render_info_row(tid, server.tr('sakuraflow.common.dependencies'), dep_list, "dependency", server, is_list=True), - UI._render_info_row(tid, server.tr('todo.common.description'), + UI._render_info_row(tid, server.tr('sakuraflow.common.description'), description, "description", server), RText("-" * 35 + "\n", color=RColor.dark_gray), - RText(f"{server.tr('todo.ui.info.progress_header')}\n", color=RColor.gold), + RText(f"{server.tr('sakuraflow.ui.info.progress_header')}\n", color=RColor.gold), *notes_content, UI.make_dividing_line(newline=False) ) @@ -211,12 +211,12 @@ def help_line(cmd: str, desc: str, usage: str = "", full_desc: RTextBase | str = hover_content = RTextList() # 用法展示 - hover_content.append(RText(f"{server.tr('todo.common.usage')}: ", color=RColor.gray)) + hover_content.append(RText(f"{server.tr('sakuraflow.common.usage')}: ", color=RColor.gray)) hover_content.append(RText(f"{COMMAND_PREFIX} {cmd} {usage}\n", color=RColor.aqua)) # 缩写展示 if abbr: - hover_content.append(RText(f"{server.tr('todo.common.alias')}: ", color=RColor.gray)) + hover_content.append(RText(f"{server.tr('sakuraflow.common.alias')}: ", color=RColor.gray)) hover_content.append(RText(f"{COMMAND_PREFIX} {abbr}\n", color=RColor.aqua)) hover_content.append(RText("-" * 20 + "\n", color=RColor.dark_gray)) @@ -243,7 +243,7 @@ def build_props_info(header_key: str, props_dict: dict) -> RTextList: builder = ItemizeBuilder() items = list(props_dict.items()) for i, (prop, aliases) in enumerate(items): - prop_name = server.tr(f"todo.prop.{prop}") + prop_name = server.tr(f"sakuraflow.prop.{prop}") alias_items = [RText(a, color=RColor.aqua) for a in sorted(aliases)] alias_list = Utils.list_to_rtext(alias_items) @@ -259,37 +259,37 @@ def build_props_info(header_key: str, props_dict: dict) -> RTextList: return result properties_info = RTextList( - server.tr('todo.help.desc.set.main'), "\n\n", - build_props_info('todo.help.available_props', TASK_PROPERTIES) + server.tr('sakuraflow.help.desc.set.main'), "\n\n", + build_props_info('sakuraflow.help.available_props', TASK_PROPERTIES) ) list_properties_info = RTextList( - server.tr('todo.help.desc.list.main'), "\n\n", - build_props_info('todo.help.available_lists', LIST_PROPERTIES) + server.tr('sakuraflow.help.desc.list.main'), "\n\n", + build_props_info('sakuraflow.help.available_lists', LIST_PROPERTIES) ) return RTextList( - UI.make_dividing_line(server.tr('todo.help.header')), - RText(f"{server.tr('todo.help.hint')}\n", color=RColor.gray, styles=RStyle.italic), - - help_line("list", server.tr('todo.help.list'), usage="", abbr="l"), - help_line("archive", server.tr('todo.help.archive'), usage="", abbr="ar"), - help_line("search", server.tr('todo.help.search'), usage="<query>", abbr="find"), - help_line("add", server.tr('todo.help.add'), usage="<title>", abbr="a"), - help_line("info", server.tr('todo.help.info'), usage="<id>", abbr="i"), - help_line("note", server.tr('todo.help.note'), usage="<id> <content>", abbr="n"), - help_line("set", server.tr('todo.help.set'), usage="<id> <prop> <value>", full_desc=properties_info, + UI.make_dividing_line(server.tr('sakuraflow.help.header')), + RText(f"{server.tr('sakuraflow.help.hint')}\n", color=RColor.gray, styles=RStyle.italic), + + help_line("list", server.tr('sakuraflow.help.list'), usage="", abbr="l"), + help_line("archive", server.tr('sakuraflow.help.archive'), usage="", abbr="ar"), + help_line("search", server.tr('sakuraflow.help.search'), usage="<query>", abbr="find"), + help_line("add", server.tr('sakuraflow.help.add'), usage="<title>", abbr="a"), + help_line("info", server.tr('sakuraflow.help.info'), usage="<id>", abbr="i"), + help_line("note", server.tr('sakuraflow.help.note'), usage="<id> <content>", abbr="n"), + help_line("set", server.tr('sakuraflow.help.set'), usage="<id> <prop> <value>", full_desc=properties_info, abbr="s"), - help_line("append", server.tr('todo.help.append'), usage="<id> <list> <value>", + help_line("append", server.tr('sakuraflow.help.append'), usage="<id> <list> <value>", full_desc=list_properties_info, abbr="ap"), - help_line("remove", server.tr('todo.help.remove'), usage="<id> <list> <value>", + help_line("remove", server.tr('sakuraflow.help.remove'), usage="<id> <list> <value>", full_desc=list_properties_info, abbr="rm"), - help_line("pause", server.tr('todo.help.pause'), usage="<id>"), - help_line("resume", server.tr('todo.help.resume'), usage="<id>"), - help_line("complete", server.tr('todo.help.complete'), usage="<id>"), - help_line("restore", server.tr('todo.help.restore'), usage="<id>"), + help_line("pause", server.tr('sakuraflow.help.pause'), usage="<id>"), + help_line("resume", server.tr('sakuraflow.help.resume'), usage="<id>"), + help_line("complete", server.tr('sakuraflow.help.complete'), usage="<id>"), + help_line("restore", server.tr('sakuraflow.help.restore'), usage="<id>"), UI.make_dividing_line(newline=False) ) @@ -298,18 +298,18 @@ def build_props_info(header_key: str, props_dict: dict) -> RTextList: def render_welcome(server: ServerInterface) -> RTextBase: """渲染欢迎界面""" return RTextList( - UI.make_dividing_line(server.tr('todo.welcome.header')), - RText(f"{server.tr('todo.welcome.line1')}\n", color=RColor.white), - RText(f"{server.tr('todo.welcome.line2')}\n", color=RColor.gray), - RText(f"{server.tr('todo.welcome.btn_line')}", color=RColor.gray), + UI.make_dividing_line(server.tr('sakuraflow.welcome.header')), + RText(f"{server.tr('sakuraflow.welcome.line1')}\n", color=RColor.white), + RText(f"{server.tr('sakuraflow.welcome.line2')}\n", color=RColor.gray), + RText(f"{server.tr('sakuraflow.welcome.btn_line')}", color=RColor.gray), COLON, - Utils.create_button(server.tr('todo.welcome.btn.help'), RColor.aqua, server.tr('todo.welcome.hover.help'), + Utils.create_button(server.tr('sakuraflow.welcome.btn.help'), RColor.aqua, server.tr('sakuraflow.welcome.hover.help'), f"{COMMAND_PREFIX} help"), " ", - Utils.create_button(server.tr('todo.welcome.btn.list'), RColor.green, server.tr('todo.welcome.hover.list'), + Utils.create_button(server.tr('sakuraflow.welcome.btn.list'), RColor.green, server.tr('sakuraflow.welcome.hover.list'), f"{COMMAND_PREFIX} list"), " ", - Utils.create_button(server.tr('todo.welcome.btn.add'), RColor.yellow, server.tr('todo.welcome.hover.add'), + Utils.create_button(server.tr('sakuraflow.welcome.btn.add'), RColor.yellow, server.tr('sakuraflow.welcome.hover.add'), f"{COMMAND_PREFIX} add ") ) @@ -359,7 +359,7 @@ def render_paged_list(source: CommandSource, tasks: dict, manager: TodoManager, # 上一页按钮 if page > 0: prev_cmd = f"{COMMAND_PREFIX} {cmd_prefix} {page}" - footer.append(Utils.create_button("<<", RColor.aqua, server.tr("todo.action.prev_page"), prev_cmd)) + footer.append(Utils.create_button("<<", RColor.aqua, server.tr("sakuraflow.action.prev_page"), prev_cmd)) else: footer.append(RText("[<<]", color=RColor.gray)) @@ -368,7 +368,7 @@ def render_paged_list(source: CommandSource, tasks: dict, manager: TodoManager, # 下一页按钮 if page < total_pages - 1: next_cmd = f"{COMMAND_PREFIX} {cmd_prefix} {page + 2}" - footer.append(Utils.create_button(">>", RColor.aqua, server.tr("todo.action.next_page"), next_cmd)) + footer.append(Utils.create_button(">>", RColor.aqua, server.tr("sakuraflow.action.next_page"), next_cmd)) else: footer.append(RText("[>>]", color=RColor.gray)) diff --git a/sakura_flow/mcdr_entry.py b/sakura_flow/mcdr_entry.py index 5437ad7..a3f803d 100644 --- a/sakura_flow/mcdr_entry.py +++ b/sakura_flow/mcdr_entry.py @@ -1,5 +1,5 @@ -from mcdreforged.api.all import * -from mcdreforged.api.command import * +from mcdreforged.api.all import PluginServerInterface, CommandSource, CommandContext, RText, RColor, RStyle +from mcdreforged.api.command import Literal, Integer, GreedyText, Text from .controller import TodoController from .interface import UI @@ -20,14 +20,14 @@ def on_list(source: CommandSource, context: CommandContext): page = context.get("page", 1) # 使用 search_tasks 获取非 Done 任务 tasks = controller.search_tasks({'status': '!Done'}) - UI.render_paged_list(source, tasks, controller.manager, 'todo.list.header', 'todo.list.empty', + UI.render_paged_list(source, tasks, controller.manager, 'sakuraflow.list.header', 'sakuraflow.list.empty', input_page=page, cmd_prefix="list") def on_archive(source: CommandSource, context: CommandContext): page = context.get("page", 1) # 使用 search_tasks 获取 Done 任务 tasks = controller.search_tasks({'status': 'Done'}) - UI.render_paged_list(source, tasks, controller.manager, 'todo.archive.header', 'todo.archive.empty', + UI.render_paged_list(source, tasks, controller.manager, 'sakuraflow.archive.header', 'sakuraflow.archive.empty', input_page=page, cmd_prefix="archive") def on_search(source: CommandSource, context: CommandContext): @@ -48,7 +48,7 @@ def on_search(source: CommandSource, context: CommandContext): results = controller.get_cached_search(player_key) if results is None: # 缓存过期或不存在,提示用户重新搜索 - source.reply(Utils.error_msg(server, 'todo.search.cache_expired')) + source.reply(Utils.error_msg(server, 'sakuraflow.search.cache_expired')) return else: # 执行新搜索 @@ -82,7 +82,7 @@ def on_search(source: CommandSource, context: CommandContext): page = 1 # 新搜索重置为第一页 # 渲染搜索结果 - UI.render_paged_list(source, results, controller.manager, 'todo.search.header', 'todo.search.empty', + UI.render_paged_list(source, results, controller.manager, 'sakuraflow.search.header', 'sakuraflow.search.empty', input_page=page, cmd_prefix="search") @@ -90,13 +90,13 @@ def on_add(source: CommandSource, context: CommandContext): creator = source.player if source.is_player else "Console" tid = controller.add_task(context['title'], creator) tid_text = RText(f"#{tid}", color=RColor.green, styles=RStyle.bold) - source.reply(Utils.info_msg(server, 'todo.msg.add_success', tid_text)) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.add_success', tid_text)) def on_info(source: CommandSource, context: CommandContext): tid = str(context['id']) task = controller.get_task(tid) if not task: - source.reply(Utils.error_msg(server, 'todo.msg.not_found')) + source.reply(Utils.error_msg(server, 'sakuraflow.msg.not_found')) return source.reply(UI.render_task_info(tid, task, controller.manager.data["tasks"], server)) @@ -105,17 +105,17 @@ def on_set(source: CommandSource, context: CommandContext): success, val, err = controller.set_property(str(context['id']), context['prop'], context['value'], editor) if not success: - if err == 'todo.msg.invalid_tier': + if err == 'sakuraflow.msg.invalid_tier': tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) source.reply(Utils.error_msg(server, err, len(GT_TIERS) - 1, tier_list)) - elif err == 'todo.msg.invalid_priority': + elif err == 'sakuraflow.msg.invalid_priority': prio_list = Utils.list_to_rtext([Priority.get_rtext(p.value) for p in Priority]) source.reply(Utils.error_msg(server, err, prio_list)) - elif err == 'todo.msg.invalid_status': + elif err == 'sakuraflow.msg.invalid_status': status_list = Utils.list_to_rtext([Status.get_rtext(s.value) for s in Status]) source.reply(Utils.error_msg(server, err, status_list)) else: - source.reply(Utils.error_msg(server, err or 'todo.msg.unknown_error', context.get('prop'))) + source.reply(Utils.error_msg(server, err or 'sakuraflow.msg.unknown_error', context.get('prop'))) return # 成功后的 UI 反馈 @@ -128,35 +128,35 @@ def on_set(source: CommandSource, context: CommandContext): elif Priority.validate(val): rval = Priority.get_rtext(val, server) elif Status.validate(val): rval = Status.get_rtext(val, server) - source.reply(Utils.info_msg(server, 'todo.msg.set_success', context['id'], context['prop'], rval)) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.set_success', context['id'], context['prop'], rval)) def on_append(source: CommandSource, context: CommandContext): editor = source.player if source.is_player else "Console" success, err = controller.append_list_property(str(context['id']), context['list_prop'], str(context['value']), editor) if not success: - if err == 'todo.msg.dep_not_found': + if err == 'sakuraflow.msg.dep_not_found': source.reply(Utils.error_msg(server, err, context['value'])) else: - source.reply(Utils.error_msg(server, err or 'todo.msg.unknown_error', context['list_prop'])) + source.reply(Utils.error_msg(server, err or 'sakuraflow.msg.unknown_error', context['list_prop'])) return - source.reply(Utils.info_msg(server, 'todo.msg.append_success', context['id'], context['list_prop'], context['value'])) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.append_success', context['id'], context['list_prop'], context['value'])) def on_remove(source: CommandSource, context: CommandContext): editor = source.player if source.is_player else "Console" success, err = controller.remove_list_property(str(context['id']), context['list_prop'], str(context['value']), editor) if not success: - source.reply(Utils.error_msg(server, 'todo.msg.remove_failed', context['value'])) + source.reply(Utils.error_msg(server, 'sakuraflow.msg.remove_failed', context['value'])) return - source.reply(Utils.info_msg(server, 'todo.msg.remove_success', context['id'], context['list_prop'], context['value'])) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.remove_success', context['id'], context['list_prop'], context['value'])) def on_note(source: CommandSource, context: CommandContext): author = source.player if source.is_player else "Console" if controller.add_note(str(context['id']), context['content'], author): - source.reply(Utils.info_msg(server, 'todo.msg.note_success', context['id'])) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.note_success', context['id'])) def on_status_change(source: CommandSource, context: CommandContext, status: Status, msg_key: str): editor = source.player if source.is_player else "Console" @@ -165,10 +165,10 @@ def on_status_change(source: CommandSource, context: CommandContext, status: Sta def on_default_tier(source: CommandSource, context: CommandContext): if controller.set_default_tier(context['tier']): - source.reply(Utils.info_msg(server, 'todo.msg.default_tier_success', context['tier'])) + source.reply(Utils.info_msg(server, 'sakuraflow.msg.default_tier_success', context['tier'])) else: tier_list = Utils.list_to_rtext([Tier.get_rtext(t.value) for t in Tier]) - source.reply(Utils.error_msg(server, 'todo.msg.invalid_tier', len(GT_TIERS) - 1, tier_list)) + source.reply(Utils.error_msg(server, 'sakuraflow.msg.invalid_tier', len(GT_TIERS) - 1, tier_list)) # --- Command Tree Definition --- @@ -211,10 +211,10 @@ def on_default_tier(source: CommandSource, context: CommandContext): node_note = Literal('note').then(Text('id').then(GreedyText('content').runs(on_note))) node_note_alias = Literal('n').then(Text('id').then(GreedyText('content').runs(on_note))) - node_complete = Literal('complete').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.DONE, 'todo.msg.complete_success'))) - node_pause = Literal('pause').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.ON_HOLD, 'todo.msg.pause_success'))) - node_resume = Literal('resume').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'todo.msg.resume_success'))) - node_restore = Literal('restore').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'todo.msg.restore_success'))) + node_complete = Literal('complete').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.DONE, 'sakuraflow.msg.complete_success'))) + node_pause = Literal('pause').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.ON_HOLD, 'sakuraflow.msg.pause_success'))) + node_resume = Literal('resume').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'sakuraflow.msg.resume_success'))) + node_restore = Literal('restore').then(Text('id').runs(lambda s, c: on_status_change(s, c, Status.IN_PROGRESS, 'sakuraflow.msg.restore_success'))) node_default_tier = Literal('default_tier').then(Text('tier').runs(on_default_tier)) diff --git a/sakura_flow/utils.py b/sakura_flow/utils.py index 3a85492..b42fe2b 100644 --- a/sakura_flow/utils.py +++ b/sakura_flow/utils.py @@ -1,6 +1,6 @@ from typing import Optional, Any, List -from mcdreforged.api.all import * +from mcdreforged.api.all import ServerInterface, RTextList, RText, RColor, RTextBase, RAction from .constants import * from .constants import ITEMIZE_PREFIX diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6d923bd --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest +from unittest.mock import MagicMock +from mcdreforged.api.all import PluginServerInterface + +@pytest.fixture +def mock_server(): + server = MagicMock(spec=PluginServerInterface) + # 模拟 register_command,避免报错 + server.register_command = MagicMock() + return server + +@pytest.fixture +def mock_controller(): + # 模拟 TodoController + # 这里不需要引入真实的 Controller 类,只需要一个有相应方法的 Mock 对象 + # 这样可以解耦测试 + controller = MagicMock() + return controller diff --git a/tests/test_command_registration.py b/tests/test_command_registration.py new file mode 100644 index 0000000..88179a5 --- /dev/null +++ b/tests/test_command_registration.py @@ -0,0 +1,26 @@ +import pytest +from sakura_flow.mcdr_entry import register_mcdr_commands +from mcdreforged.api.command import Literal + +def test_command_registration(mock_server, mock_controller): + """ + 测试命令注册流程是否正常,确保命令树构建没有语法错误 + """ + try: + register_mcdr_commands(mock_server, mock_controller) + except Exception as e: + pytest.fail(f"命令注册失败: {e}") + + # 验证是否调用了 server.register_command + assert mock_server.register_command.called, "未调用 server.register_command" + + # 获取注册的命令节点 + # call_args[0][0] 是第一个位置参数,即 node_root + node_root = mock_server.register_command.call_args[0][0] + + # 验证根节点是否正确 + assert isinstance(node_root, Literal) + + # 由于 MCDR 的 CommandNode 内部结构可能随版本变化且不一定公开 children 属性, + # 这里我们只验证根节点对象存在且类型正确。 + # 如果命令树构建逻辑有误(如 .then() 链式调用错误),上面的 register_mcdr_commands 通常会直接抛出异常。 diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..594580a --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,21 @@ +import pytest + +def test_plugin_structure(): + """ + 验证插件的基本结构和导入是否正常 + """ + try: + import sakura_flow + except ImportError as e: + pytest.fail(f"无法导入 sakura_flow 插件: {e}") + + # 验证必要的入口函数是否存在 + assert hasattr(sakura_flow, 'on_load'), "插件缺少 on_load 入口函数" + assert callable(sakura_flow.on_load), "on_load 必须是可调用的函数" + +def test_metadata_exists(): + """ + 验证插件元数据文件是否存在 + """ + import os + assert os.path.exists('mcdreforged.plugin.json'), "缺少 mcdreforged.plugin.json 文件" diff --git a/tests/test_translation_keys.py b/tests/test_translation_keys.py new file mode 100644 index 0000000..fea18f2 --- /dev/null +++ b/tests/test_translation_keys.py @@ -0,0 +1,67 @@ +import os +import json +import re +import pytest + +# 定义项目根目录 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LANG_FILE = os.path.join(PROJECT_ROOT, 'lang', 'zh_cn.json') +SOURCE_DIR = os.path.join(PROJECT_ROOT, 'sakura_flow') + +def get_defined_keys(): + """读取语言文件并返回所有定义的键""" + if not os.path.exists(LANG_FILE): + pytest.fail(f"语言文件不存在: {LANG_FILE}") + + with open(LANG_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return set(data.keys()) + +def scan_used_keys(): + """扫描源代码中使用的翻译键""" + used_keys = set() + # 匹配形如 'sakuraflow.xxx' 或 "sakuraflow.xxx" 的字符串 + # 假设翻译键都以 sakuraflow. 开头 + pattern = re.compile(r'["\'](sakuraflow\.[a-zA-Z0-9_.]+)["\']') + + for root, _, files in os.walk(SOURCE_DIR): + for file in files: + if file.endswith('.py'): + file_path = os.path.join(root, file) + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + matches = pattern.findall(content) + for match in matches: + used_keys.add(match) + return used_keys + +def test_translation_keys_exist(): + """测试代码中引用的所有翻译键是否都在语言文件中定义""" + defined_keys = get_defined_keys() + used_keys = scan_used_keys() + + missing_keys = used_keys - defined_keys + + # 过滤掉一些可能的动态键或误报 + # 如果有已知的动态生成的键前缀,可以在这里过滤 + # 例如: sakuraflow.msg.{variable} 这种无法静态检测,但如果代码里写了 'sakuraflow.msg.' 可能会被匹配到 + # 这里假设代码里写的都是完整的键 + + if missing_keys: + pytest.fail(f"发现 {len(missing_keys)} 个未定义的翻译键:\n" + "\n".join(missing_keys)) + +def test_unused_keys(): + """(可选) 测试是否有未使用的翻译键""" + # 这个测试通常作为警告而不是错误,因为有些键可能只在动态生成时使用 + defined_keys = get_defined_keys() + used_keys = scan_used_keys() + + unused = defined_keys - used_keys + # 这里我们只打印警告,不让测试失败 + if unused: + print(f"\n警告: 发现 {len(unused)} 个可能未使用的翻译键 (可能是动态调用):") + # 只打印前10个 + for k in list(unused)[:10]: + print(f" - {k}") + if len(unused) > 10: + print(" ...") From 83d41e9ecdca91a66f332336b9912ec2ca8ca199 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:23:00 +0000 Subject: [PATCH 07/11] Initial plan From df036758eabac3e051f5afd3ece304f962a83685 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:24:23 +0000 Subject: [PATCH 08/11] Fix test_smoke.py to use absolute path for metadata file check Co-authored-by: Sakura-Ex <49809606+Sakura-Ex@users.noreply.github.com> --- tests/test_smoke.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index 594580a..0ac6829 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -1,5 +1,9 @@ +import os import pytest +# 定义项目根目录 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + def test_plugin_structure(): """ 验证插件的基本结构和导入是否正常 @@ -17,5 +21,5 @@ def test_metadata_exists(): """ 验证插件元数据文件是否存在 """ - import os - assert os.path.exists('mcdreforged.plugin.json'), "缺少 mcdreforged.plugin.json 文件" + metadata_file = os.path.join(PROJECT_ROOT, 'mcdreforged.plugin.json') + assert os.path.exists(metadata_file), "缺少 mcdreforged.plugin.json 文件" From b561f89a0f08f2ce4d1b46882dffbd22e02023e5 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Sat, 7 Feb 2026 18:33:11 +0800 Subject: [PATCH 09/11] Refactor translation key scanning and clean up imports - Update `test_translation_keys.py` to use `ast` for parsing source code instead of regex - Replace wildcard imports with explicit imports in `interface.py` and `utils.py` --- sakura_flow/interface.py | 3 +-- sakura_flow/utils.py | 3 +-- tests/test_translation_keys.py | 34 ++++++++++++++++++++-------------- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/sakura_flow/interface.py b/sakura_flow/interface.py index 057c255..045de0a 100644 --- a/sakura_flow/interface.py +++ b/sakura_flow/interface.py @@ -1,8 +1,7 @@ from mcdreforged.api.all import RTextBase, RText, RColor, RTextList, ServerInterface, CommandSource, RAction, RStyle from . import TodoManager -from .constants import * -from .constants import PAGE_SIZE +from .constants import COMMAND_PREFIX, PAGE_SIZE, TASK_PROPERTIES, LIST_PROPERTIES, COLON from .enums import Status, Tier, Priority from .utils import Utils, ItemizeBuilder diff --git a/sakura_flow/utils.py b/sakura_flow/utils.py index b42fe2b..60a6007 100644 --- a/sakura_flow/utils.py +++ b/sakura_flow/utils.py @@ -2,8 +2,7 @@ from mcdreforged.api.all import ServerInterface, RTextList, RText, RColor, RTextBase, RAction -from .constants import * -from .constants import ITEMIZE_PREFIX +from .constants import ITEMIZE_PREFIX, LIST_ITEM_SEPERATOR class Utils: diff --git a/tests/test_translation_keys.py b/tests/test_translation_keys.py index fea18f2..824dc62 100644 --- a/tests/test_translation_keys.py +++ b/tests/test_translation_keys.py @@ -1,6 +1,6 @@ import os import json -import re +import ast import pytest # 定义项目根目录 @@ -18,21 +18,32 @@ def get_defined_keys(): return set(data.keys()) def scan_used_keys(): - """扫描源代码中使用的翻译键""" + """ + 使用 AST 扫描源代码中使用的翻译键 + 只收集硬编码的字符串常量,忽略 f-string 和动态拼接 + """ used_keys = set() - # 匹配形如 'sakuraflow.xxx' 或 "sakuraflow.xxx" 的字符串 - # 假设翻译键都以 sakuraflow. 开头 - pattern = re.compile(r'["\'](sakuraflow\.[a-zA-Z0-9_.]+)["\']') for root, _, files in os.walk(SOURCE_DIR): for file in files: if file.endswith('.py'): file_path = os.path.join(root, file) with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - matches = pattern.findall(content) - for match in matches: - used_keys.add(match) + try: + tree = ast.parse(f.read(), filename=file_path) + except SyntaxError: + continue # 忽略语法错误的文件(如果有) + + for node in ast.walk(tree): + # 在 Python 3.8+ 中,字符串常量是 ast.Constant + if isinstance(node, ast.Constant) and isinstance(node.value, str): + val = node.value + # 筛选条件: + # 1. 以 sakuraflow. 开头 + # 2. 不以 . 结尾(排除类似 "sakuraflow.prop." 的动态前缀) + if val.startswith('sakuraflow.') and not val.endswith('.'): + used_keys.add(val) + return used_keys def test_translation_keys_exist(): @@ -42,11 +53,6 @@ def test_translation_keys_exist(): missing_keys = used_keys - defined_keys - # 过滤掉一些可能的动态键或误报 - # 如果有已知的动态生成的键前缀,可以在这里过滤 - # 例如: sakuraflow.msg.{variable} 这种无法静态检测,但如果代码里写了 'sakuraflow.msg.' 可能会被匹配到 - # 这里假设代码里写的都是完整的键 - if missing_keys: pytest.fail(f"发现 {len(missing_keys)} 个未定义的翻译键:\n" + "\n".join(missing_keys)) From 77944205ccc58705e04e73a4deed71db5dde8d54 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <729929339@qq.com> Date: Sat, 7 Feb 2026 19:14:03 +0800 Subject: [PATCH 10/11] Refactor translation key scanning and clean up imports - Update `test_translation_keys.py` to use `ast` for parsing source code instead of regex - Replace wildcard imports with explicit imports in `interface.py` and `utils.py` --- __main__.py | 7 ++++--- sakura_flow/constants.py | 3 ++- sakura_flow/controller.py | 8 +++++--- sakura_flow/utils.py | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/__main__.py b/__main__.py index 6588081..25d8718 100644 --- a/__main__.py +++ b/__main__.py @@ -1,9 +1,10 @@ import argparse import os -import sys -from sakura_flow.manager import TodoManager -from sakura_flow.controller import TodoController + from sakura_flow.cli_entry import register_cli_commands, handle_cli_command +from sakura_flow.controller import TodoController +from sakura_flow.manager import TodoManager + def main(): parser = argparse.ArgumentParser(description="Sakura Flow CLI") diff --git a/sakura_flow/constants.py b/sakura_flow/constants.py index 9768087..63f632a 100644 --- a/sakura_flow/constants.py +++ b/sakura_flow/constants.py @@ -1,5 +1,6 @@ from mcdreforged.api.all import RColor, RText -from .enums import Status, Priority + +from .enums import Priority COMMAND_PREFIX = "!!todo" PAGE_SIZE = 8 diff --git a/sakura_flow/controller.py b/sakura_flow/controller.py index 738ec74..a61bf42 100644 --- a/sakura_flow/controller.py +++ b/sakura_flow/controller.py @@ -1,8 +1,10 @@ -from typing import Optional, List, Dict, Any import time -from .manager import TodoManager +from typing import Optional, Dict, Any + +from .constants import PROP_ALIASES, LIST_PROP_ALIASES from .enums import Status, Tier, Priority -from .constants import PROP_ALIASES, LIST_PROP_ALIASES, GT_TIERS +from .manager import TodoManager + class SearchCache: def __init__(self, ttl: int = 300): diff --git a/sakura_flow/utils.py b/sakura_flow/utils.py index 60a6007..d754804 100644 --- a/sakura_flow/utils.py +++ b/sakura_flow/utils.py @@ -1,4 +1,4 @@ -from typing import Optional, Any, List +from typing import Any, List from mcdreforged.api.all import ServerInterface, RTextList, RText, RColor, RTextBase, RAction From 8bc4e2164325cf09f08f68174d6192e691b62c90 Mon Sep 17 00:00:00 2001 From: Sakura-Ex <49809606+Sakura-Ex@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:55:43 +0800 Subject: [PATCH 11/11] Consolidate CI/CD pipeline and merge release workflow (#6) * Consolidate CI/CD pipeline and merge release workflow - Update `test.yml` to include `build` and `release` jobs, creating a unified pipeline - Deprecate `release.yml` and replace content with a deletion notice - Add concurrency control to cancel in-progress workflow runs - Configure release job to trigger specifically on `v*` tag pushes - Remove `dev` branch triggers and clean up inline comments in `test.yml` * Consolidate CI/CD pipeline and merge release workflow - Update `test.yml` to include `build` and `release` jobs, creating a unified pipeline - Deprecate `release.yml` and replace content with a deletion notice - Add concurrency control to cancel in-progress workflow runs - Configure release job to trigger specifically on `v*` tag pushes - Remove `dev` branch triggers and clean up inline comments in `test.yml` * Initial plan * Apply least-privilege principle to workflow permissions Co-authored-by: Sakura-Ex <49809606+Sakura-Ex@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 65 ------------------------------ .github/workflows/test.yml | 76 ++++++++++++++++++++++++++++------- 2 files changed, 62 insertions(+), 79 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 5de9cea..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: CI for MCDR Plugin - -on: - push: - branches: - - main - - dev - tags: - - 'v*' - paths-ignore: - - '**.md' - - '.gitignore' - pull_request: - branches: - - main - - dev - paths-ignore: - - '**.md' - - '.gitignore' - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Setup python - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Pack Plugin - run: | - # Pack the plugin using MCDR's official tool - # -o specifies the output directory - python -m mcdreforged pack -o ./package - - - uses: actions/upload-artifact@v4 - with: - name: Sakura Flow distribution for ${{ github.sha }} - path: package/ - - - name: Publish distribution to release - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v2 - with: - # MCDR pack command generates .mcdr files by default - files: package/*.mcdr - generate_release_notes: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 43be232..be69946 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,11 @@ -name: Tests +name: CI/CD on: push: branches: - main - - dev + tags: + - 'v*' paths-ignore: - '**.md' - '.gitignore' @@ -12,14 +13,22 @@ on: pull_request: branches: - main - - dev paths-ignore: - '**.md' - '.gitignore' - '.idea/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: + # 1. 测试任务 test: + name: Test & Lint runs-on: ubuntu-latest strategy: matrix: @@ -48,21 +57,60 @@ jobs: - name: Lint with flake8 run: | - # 1. 检查严重错误和不安全的导入习惯 - # E9: 语法错误 - # F63: 总是为真的测试 - # F7: 编译错误 - # F82: 未定义的变量 - # F401: 导入但未使用的模块 - # F403: 使用了 'from module import *' flake8 . --count --select=E9,F63,F7,F82,F401,F403 --show-source --statistics - - # 2. 其他风格检查 (作为警告,不中断构建) flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Run tests run: | - # 确保当前目录在 PYTHONPATH 中,以便测试脚本可以导入插件包 export PYTHONPATH=$PYTHONPATH:. - # 运行所有测试并显示详细输出 pytest -v + + # 2. 构建任务 (仅当测试通过后运行) + build: + name: Build Plugin + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Pack Plugin + run: | + python -m mcdreforged pack -o ./package + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: SakuraFlow-Build + path: package/*.mcdr + retention-days: 5 + + # 3. 发布任务 (仅当构建成功且是 Tag 推送时运行) + release: + name: Publish Release + needs: build + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download Artifact + uses: actions/download-artifact@v4 + with: + name: SakuraFlow-Build + path: package/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: package/*.mcdr + generate_release_notes: true