diff --git a/CHANGELOG.md b/CHANGELOG.md index e1db9db..b158a46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,15 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.126 + +- 沉淀 `replaces: import` 宿主契约:表头必填红星在前(`*显示名`),技能须同时兼容前/后缀 `*`;lookup 单元格可为 `{value} · {label}` +- 新增可复用模块 `scripts/service/table_file_import.py` 与单测;更新 ACTIONS / SCHEMA / CHECKLIST A4-14 / replaces-import 示例 + ## 1.0.125 - 固化免费 PyArmor Trial 发布约定:行数门槛是预防线;实际失败时按日志定位模块并按职责拆分,保留兼容门面,以 Gitea Trial 加密结果验收 -- 记录 field_locator 拆分后同一免费 Trial 链路成功的复盘案例;不使用未经验证的统一字节数阈值 +- 记录 field_locator 拆分后同一免费 Trial 链路成功的复盘案例;不使用未经验证的统一字节数规则 ## 1.0.124 diff --git a/SKILL.md b/SKILL.md index 71ff8ab..fa5b7f1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.125 +version: 1.0.126 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/assets/examples/README.md b/assets/examples/README.md index 24c4b11..48cb5e5 100644 --- a/assets/examples/README.md +++ b/assets/examples/README.md @@ -14,4 +14,4 @@ - `bind-visible-when-row.action.example.json`:行菜单显隐 `bind.visibleWhen` 形状参考(按当前行状态显示/隐藏行按钮;见 `references/ACTIONS.md`「行菜单显隐」)。 - `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。 - `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考(含 `cron`:配置页按无勾选分支,只见数量字段);用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。 -- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。 +- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。实现 import 解析时复用 `scripts/service/table_file_import.py`(兼容宿主前缀 `*` 表头与 `{value} · {label}` lookup 单元格)。 diff --git a/assets/examples/replaces-import.action.example.json b/assets/examples/replaces-import.action.example.json index 16c4ce0..983ed83 100644 --- a/assets/examples/replaces-import.action.example.json +++ b/assets/examples/replaces-import.action.example.json @@ -1,7 +1,7 @@ { "id": "item-import", "label": "导入记录", - "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。", + "description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。表头可带前/后缀*;lookup 列可为「编号 · 名称」。", "replaces": "import", "placements": [], "executionProfile": "sync", @@ -19,7 +19,7 @@ "filePath": { "type": "string", "title": "导入文件", - "description": "由宿主导入面板传入的 Excel/CSV 路径。", + "description": "由宿主导入面板传入的 Excel/CSV 路径。表头:显示名(可带前/后缀*);关联列可填编号或「编号 · 名称」。", "format": "file" }, "onError": { diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index e17730e..7bbaa9a 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -116,6 +116,7 @@ checklist_version: 1.0 - [ ] A4-11 **P1** 若行操作应按业务状态显隐(如仅待确认可「采用」、仅已采用可「加入待写」):用 **`bind.visibleWhen`**(对当前行列值求值;存库原始值如 `approved`);勿指望 Policy L3;CLI 侧仍须校验状态(见 `references/ACTIONS.md`「行菜单显隐」) - [ ] A4-12 **P1** 若要在表单里翻页勾选一批业务行(文章、平台等):用 Lookup **`presentation: "paged_table"`**(仍是 `display_type: lookup`,勿新造类型);`skill_cli` 的 stdout 为一行 `{"items","total"}`,0 条也成功退出;该列表命令不要在入口加载整包 platform-kit 或 Playwright(见 `references/ACTIONS.md`「分页勾选小表」) - [ ] A4-13 **P1** 若 Action 有固定小枚举(状态/船司/优先级等)需中文展示:用 `enum` + **`enumNames`**,或绑列复用列 `options_json` 数组;宿主优先序为绑列 options → enumNames → 纯 enum;**禁止**为中文展示造假 Lookup(见 `references/ACTIONS.md`「静态枚举 label」) +- [ ] A4-14 **P0** 若声明 `replaces: import`:表头清洗须兼容宿主**前缀**必填星(`*显示名`)与旧后缀(`显示名*`);lookup 列须能解析 `{value} · {label}`;推荐复用 `scripts/service/table_file_import.py`;**禁止**只 `removesuffix("*")` 导致「字段不能为空」(见 `references/ACTIONS.md`「导入表头与 lookup 单元格」) --- ## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项) diff --git a/references/ACTIONS.md b/references/ACTIONS.md index dfc2a41..aba864e 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -291,6 +291,20 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表** 可抄文件见 [`assets/examples/`](../assets/examples/):`replaces-*.action.example.json`。 +### 导入表头与 lookup 单元格(宿主契约,`replaces: import` 必读) + +宿主「下载导入模板」与导入预览已统一: + +| 现象 | 宿主实际写出 | 技能解析须兼容 | +|------|--------------|----------------| +| 必填红星 | 写在**显示名前**:`*长尾词`、`* 名称`(表单标签同此) | **前缀 / 后缀** `*` / `*` 都要剥掉后再对 `display_name`;**禁止**只 `removesuffix("*")` | +| lookup / 关联列 | 单元格常为 `{value} · {label}`,如 `157 · 波兰小包专线` | 取前导整数写库;勿要求用户只填纯数字 | +| 示例行 | 单元格以 `【示例】` 开头 | 整行跳过,勿当业务数据 | + +写库链路:宿主面板只把 **`filePath` + `onError`** 交给技能;**字段映射下拉结果不传入 CLI**。技能必须自己读文件、洗表头、映射列。预览「看起来有字」不等于技能已认到必填列——只剥后缀 `*` 时,前缀模板会导致「xxx不能为空」。 + +**推荐实现:** 直接复用模板内 [`scripts/service/table_file_import.py`](../scripts/service/table_file_import.py)(`normalize_header` / `optional_int` / `load_table_rows`)。Action 文案里写「表头:名称(可带前/后缀*)」即可,**不要**再写死「表头:名称*」误导后人。 + ### 示例:create ```json @@ -437,7 +451,7 @@ Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表** - 点默认「新增/编辑/删除」时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。 - 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。 - `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。 -- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。 +- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头(须兼容宿主前缀 `*` 与 lookup `编号 · 名称`,见上文「导入表头与 lookup 单元格」)。 - 整表 `_jiangchang_tables.readonly=1` 时仍隐藏宿主默认新增/编辑/导入与直写删除;**若声明了 `replaces: delete`,宿主仍显示「删除」并走技能 CLI**(不直写 SQLite)。 - **列元数据复用**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与列上 **options**(静态枚举 `options_json` **数组**,或已解析的 lookup): - 优先让属性名 = 物理列名(如 `carrier_code` / `platform_key`); diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 77a740b..204ca1f 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -38,6 +38,8 @@ - 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果 - 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为格子 / 行 / 列 / 区域映射语义样式(`tone`);不猜测业务规则;整行色只涂表格行,不铺到详情字段 - **导入模板下拉(宿主能力)**:下载「Excel 模板」时,宿主按本表可导入列的元数据自动加 Excel 数据有效性下拉——凡列已有可选集合(`options_json` 静态枚举、schema 已解析的 lookup 选项、未写 options 的 `boolean` 默认「是/否」等)即加下拉;无选项的列仍为自由文本。下拉展示 **label**;导入写库时宿主(或 `replaces: import` 技能)再 **label → value**。CSV 模板**不能**带下拉,可选值仍写在导入弹窗「可导入字段说明」与 Excel「填写说明」页。技能**无需**自备带下拉的模板文件;把字段选项声明在 `_jiangchang_columns` 即可让所有技能受益。宿主不按技能名写死「只支持某几种业务字段」。 +- **导入模板表头必填标记(宿主能力)**:Excel/CSV 表头对必填列统一加**前缀**红星(`*显示名` / `* 显示名`),与新增表单标签一致;**不是**后缀 `显示名*`。走 `replaces: import` 的技能必须按 [`ACTIONS.md`](ACTIONS.md)「导入表头与 lookup 单元格」剥前/后缀 `*` 后再对 `display_name`;推荐复用 [`scripts/service/table_file_import.py`](../scripts/service/table_file_import.py)。 +- **导入模板 lookup 单元格**:用户从下拉选中的值常以 `{value} · {label}` 写入表格(与列表展示一致)。`replaces: import` 技能解析关联列时须能从该展示串取出编号(`optional_int`),不能只接受纯数字。 ## 初始化入口 diff --git a/scripts/service/table_file_import.py b/scripts/service/table_file_import.py new file mode 100644 index 0000000..3f6bc68 --- /dev/null +++ b/scripts/service/table_file_import.py @@ -0,0 +1,216 @@ +"""解析数据管理导入用的 Excel / CSV(中文表头、【示例】行跳过)。 + +实现 ``replaces: import`` 时请复用本模块,勿只认「后缀 *」表头。 + +宿主契约(须兼容): + +- 必填红星写在显示名前:``*长尾词`` / ``* 名称``(旧技能文档可能写 ``名称*``) +- lookup 单元格可能是 ``157 · 波兰小包专线``(``{value} · {label}``),不只是纯数字 +- 以 ``【示例】`` 开头的单元格所在行应跳过 +""" + +from __future__ import annotations + +import csv +import re +from pathlib import Path +from typing import Any, Iterable, Optional + +EXAMPLE_MARKER = "【示例】" +DATA_SHEET_NAMES = ("数据", "Sheet1", "sheet1") + + +def normalize_on_error(raw: Optional[str]) -> str: + text = str(raw or "").strip().lower() + if text in { + "abort", + "stop", + "立即停止,不再导入后续行", + "立即停止", + "停止", + }: + return "abort" + # Chinese default / skip variants + return "skip" + + +def normalize_header(header: str) -> str: + """归一化导入表头。 + + 宿主模板把必填红星写在名称前(``*长尾词`` / ``* 长尾词``); + 旧技能文档常用后缀(``长尾词*``)。两侧都要认。 + """ + text = str(header or "").strip().replace("\ufeff", "") + for _ in range(4): + prev = text + text = text.strip() + for star in ("*", "*"): + text = text.removeprefix(star).removesuffix(star).strip() + for marker in ("(必填)", "(required)"): + text = text.removeprefix(marker).removesuffix(marker).strip() + if text == prev: + break + return text + + +def _is_example_row(cells: Iterable[Any]) -> bool: + for cell in cells: + text = str(cell or "").strip() + if text.startswith(EXAMPLE_MARKER): + return True + return False + + +def _cell_str(value: Any) -> str: + if value is None: + return "" + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value).strip() + + +def map_headers( + headers: list[str], + aliases: dict[str, tuple[str, ...]], +) -> dict[str, int]: + """aliases: field_name -> accepted Chinese/English header names.""" + normalized = [normalize_header(h) for h in headers] + mapping: dict[str, int] = {} + for field, names in aliases.items(): + accepted = {normalize_header(n) for n in names} + accepted.add(normalize_header(field)) + for index, header in enumerate(normalized): + if header in accepted: + mapping[field] = index + break + return mapping + + +def rows_as_dicts( + headers: list[str], + data_rows: list[list[Any]], + *, + aliases: dict[str, tuple[str, ...]], +) -> list[dict[str, str]]: + mapping = map_headers(headers, aliases) + if not mapping: + raise ValueError("未识别到有效表头,请使用技能模板或中文列名") + out: list[dict[str, str]] = [] + for row_index, raw in enumerate(data_rows, start=2): + if _is_example_row(raw): + continue + if not any(_cell_str(c) for c in raw): + continue + item: dict[str, str] = {"_row": str(row_index)} + for field, col in mapping.items(): + item[field] = _cell_str(raw[col] if col < len(raw) else "") + out.append(item) + return out + + +def read_csv_matrix(path: Path) -> tuple[list[str], list[list[str]]]: + raw = path.read_bytes() + text = raw.decode("utf-8-sig") + try: + dialect = csv.Sniffer().sniff(text[:4096], delimiters=",\t;") + except csv.Error: + dialect = csv.excel + reader = csv.reader(text.splitlines(), dialect) + rows = [list(r) for r in reader] + if not rows: + raise ValueError("CSV 为空") + return [str(c) for c in rows[0]], rows[1:] + + +def read_xlsx_matrix(path: Path) -> tuple[list[str], list[list[Any]]]: + try: + from openpyxl import load_workbook + except ImportError as exc: + raise ValueError("读取 Excel 需要 openpyxl,请安装依赖或另存为 CSV 后再导入") from exc + + wb = load_workbook(path, read_only=True, data_only=True) + try: + sheet = None + for name in DATA_SHEET_NAMES: + if name in wb.sheetnames: + sheet = wb[name] + break + if sheet is None: + # Prefer first non-guide sheet + for name in wb.sheetnames: + if name not in ("填写说明",): + sheet = wb[name] + break + if sheet is None: + raise ValueError("Excel 中没有可用工作表") + matrix: list[list[Any]] = [] + for row in sheet.iter_rows(values_only=True): + matrix.append(list(row)) + if not matrix: + raise ValueError("Excel 数据表为空") + headers = [_cell_str(c) for c in matrix[0]] + return headers, matrix[1:] + finally: + wb.close() + + +def load_table_rows( + file_path: str, + *, + aliases: dict[str, tuple[str, ...]], +) -> list[dict[str, str]]: + path = Path(file_path) + if not path.is_file(): + raise FileNotFoundError(f"文件不存在:{path}") + suffix = path.suffix.lower() + if suffix in {".xlsx", ".xlsm"}: + headers, data = read_xlsx_matrix(path) + elif suffix in {".csv", ".txt"}: + headers, data = read_csv_matrix(path) + else: + raise ValueError("仅支持 .xlsx / .csv 文件") + return rows_as_dicts(headers, data, aliases=aliases) + + +_STATUS_ALIASES = { + "active": "active", + "启用": "active", + "archived": "archived", + "停用": "archived", + "pending_review": "pending_review", + "待确认": "pending_review", + "approved": "approved", + "已采用": "approved", + "skipped": "skipped", + "已跳过": "skipped", + "pending": "pending", + "待生成": "pending", + "generated": "generated", + "已生成": "generated", + "failed": "failed", + "失败": "failed", +} + + +def map_status(raw: str, *, allowed: set[str], default: str) -> str: + text = str(raw or "").strip() + if not text: + return default + key = _STATUS_ALIASES.get(text) or _STATUS_ALIASES.get(text.lower()) or text + return key if key in allowed else default + + +def optional_int(raw: str) -> Optional[int]: + """解析编号;兼容宿主 lookup 展示值 ``157 · 波兰小包专线``。""" + text = str(raw or "").strip() + if not text or text.lower() in {"null", "none", "undefined"}: + return None + if re.fullmatch(r"-?\d+", text): + value = int(text) + return None if value <= 0 else value + # Host lookup labels: "157 · 名称" / "157·名称" / "157 - 名称" + matched = re.match(r"^(-?\d+)\s*[·•\-—||]\s*\S", text) + if matched: + value = int(matched.group(1)) + return None if value <= 0 else value + raise ValueError(f"编号必须是整数:{text}") diff --git a/scripts/util/constants.py b/scripts/util/constants.py index 4468bb5..38ef805 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.125" +SKILL_VERSION = "1.0.126" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.11" diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index 90e1d2b..5390e6f 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -241,6 +241,8 @@ LOGGING_006_EXEMPT_BASENAMES = frozenset( "task_run_support.py", "entitlement_service.py", "sibling_bridge.py", + # Pure Excel/CSV header helpers for replaces:import; no domain side effects. + "table_file_import.py", } ) diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index add8963..dc50080 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -382,6 +382,10 @@ class TestDocsStandards(unittest.TestCase): "drop_deprecated_user_table", "孤儿物理表", "_jiangchang_grid_presentation", + "导入模板表头必填标记", + "*显示名", + "table_file_import.py", + "导入模板 lookup 单元格", ): self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}") @@ -432,8 +436,21 @@ class TestDocsStandards(unittest.TestCase): "visibleWhen", "grid_help", "列筛选", + "导入表头与 lookup 单元格", + "table_file_import.py", + "*长尾词", + "{value} · {label}", ): self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + checklist = self._read("development/CHECKLIST.md") + self.assertIn("A4-14", checklist) + self.assertIn("table_file_import.py", checklist) + self.assertTrue( + os.path.isfile( + os.path.join(get_skill_root(), "scripts/service/table_file_import.py") + ), + msg="scripts/service/table_file_import.py missing", + ) self.assertTrue( os.path.isfile( os.path.join(get_skill_root(), "assets/examples/lookup-skill-cli.action.example.json") diff --git a/tests/test_table_file_import.py b/tests/test_table_file_import.py new file mode 100644 index 0000000..38f5329 --- /dev/null +++ b/tests/test_table_file_import.py @@ -0,0 +1,67 @@ +"""宿主导入模板表头 / lookup 展示值契约(replaces:import)。""" + +from __future__ import annotations + +import csv +import tempfile +import unittest +from pathlib import Path + + +class TableFileImportTests(unittest.TestCase): + def test_normalize_header_host_leading_and_legacy_trailing_star(self) -> None: + from service.table_file_import import map_headers, normalize_header + + self.assertEqual(normalize_header("*长尾词"), "长尾词") + self.assertEqual(normalize_header("* 长尾词"), "长尾词") + self.assertEqual(normalize_header("长尾词*"), "长尾词") + self.assertEqual(normalize_header("*关键词*"), "关键词") + self.assertEqual(normalize_header("*关键词(必填)"), "关键词") + self.assertEqual(normalize_header("名称"), "名称") + + aliases = { + "phrase": ("长尾词", "phrase", "问题"), + "keyword_id": ("所属关键词", "keyword_id", "关键词编号"), + } + mapping = map_headers(["*长尾词", "所属关键词"], aliases) + self.assertEqual(mapping.get("phrase"), 0) + self.assertEqual(mapping.get("keyword_id"), 1) + + mapping_legacy = map_headers(["长尾词*", "所属关键词"], aliases) + self.assertEqual(mapping_legacy.get("phrase"), 0) + + def test_optional_int_accepts_lookup_display_label(self) -> None: + from service.table_file_import import optional_int + + self.assertEqual(optional_int("157"), 157) + self.assertEqual(optional_int("157 · 波兰小包专线"), 157) + self.assertEqual(optional_int("157·波兰小包专线"), 157) + self.assertEqual(optional_int("2 · 通用私信-留资"), 2) + self.assertIsNone(optional_int("")) + with self.assertRaises(ValueError): + optional_int("波兰小包专线") + + def test_load_table_rows_skips_example_and_maps_leading_star(self) -> None: + from service.table_file_import import load_table_rows + + aliases = { + "phrase": ("长尾词",), + "keyword_id": ("所属关键词",), + } + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "rows.csv" + with path.open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(["*长尾词", "所属关键词"]) + writer.writerow(["【示例】示例长尾词", "152 · 示例"]) + writer.writerow(["波兰小包专线怎么选", "157 · 波兰小包专线"]) + + rows = load_table_rows(str(path), aliases=aliases) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["phrase"], "波兰小包专线怎么选") + self.assertEqual(rows[0]["keyword_id"], "157 · 波兰小包专线") + self.assertEqual(rows[0]["_row"], "3") + + +if __name__ == "__main__": + unittest.main()