feat(tasks): adopt Vikunja as task authority with one-way export
把任务的协调状态迁到自建 Vikunja 的 cmbuyer 项目,git 保留单向投影, 并把「活跃任务写路径不得重叠」从自然语言规则变成机器门禁。 - scripts/vikunja_export.py:单向导出(只发 GET),stdlib-only 的 HTML→Markdown,只覆盖标记区块内,幂等,含 --selftest - scripts/vikunja-mcp.sh:MCP 启动包装从家目录迁入仓库,版本写死 1.1.1 - scripts/validate_agent_context.py:新增四项离线检查——DOING 任务写路径 互斥、通配不得圈走共享文档、导出区块 sha256、连接键一致性 - docs/agent-context.json:新增 shared_documents 与 tracker,schema_version 2 - vikunja.env 比照 gitea.env 加入 gitignore,提交 vikunja.env.example write_paths、## 边界与安全边界条目的权威留在 git,不迁往 Vikunja: 安全边界能否收紧靠 git diff 逐条复核,权威搬到远端会切断审计链。 门禁上线即报出存量违规:T-005 与 T-006 同为 DOING,在 06-tasks.md、 08-interaction-checklist.md、routes.md、current-state.md 四处重叠。 按 T-008 规定不得绕过,须由两者所有者收窄写路径或人工确认后转 DONE。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import itertools
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
@@ -19,9 +22,21 @@ REQUIRED_TOP_LEVEL = {
|
||||
"bootstrap",
|
||||
"routes",
|
||||
"tasks",
|
||||
"shared_documents",
|
||||
"tracker",
|
||||
"refresh",
|
||||
"degraded_mode",
|
||||
}
|
||||
TRACKER_KEYS = {"kind", "project", "export_script", "direction", "git_native_fields"}
|
||||
ACTIVE_STATUS = "DOING"
|
||||
|
||||
# 导出区块标记。区块内是 Vikunja 投影,区块外是 git 原生内容。
|
||||
# sha256 覆盖两行标记之间的正文,用于发现手工改动投影。
|
||||
EXPORT_BEGIN = re.compile(
|
||||
r"<!-- BEGIN VIKUNJA EXPORT id=(?P<id>\d+) synced=(?P<synced>\S+) "
|
||||
r"sha256=(?P<sha>[0-9a-f]{64}) -->"
|
||||
)
|
||||
EXPORT_END = "<!-- END VIKUNJA EXPORT -->"
|
||||
REQUIRED_BOOTSTRAP = {
|
||||
"AGENTS.md",
|
||||
"docs/00-ai-start-here.md",
|
||||
@@ -98,6 +113,142 @@ def find_sensitive_keys(value: Any, location: str, errors: list[str]) -> None:
|
||||
find_sensitive_keys(child, f"{location}[{index}]", errors)
|
||||
|
||||
|
||||
def parse_task_file(path: Path) -> dict[str, Any]:
|
||||
"""抽取任务文件里门禁需要的字段。只认约定的扁平 frontmatter,不引入 YAML 依赖。"""
|
||||
text = path.read_text(encoding="utf-8")
|
||||
parts = text.split("---\n", 2)
|
||||
front = parts[1] if len(parts) >= 3 else ""
|
||||
|
||||
status = re.search(r"^status:[ \t]*(\S+)", front, re.MULTILINE)
|
||||
task_id = re.search(r"^vikunja_task_id:[ \t]*(\S+)", front, re.MULTILINE)
|
||||
|
||||
write_paths: list[str] = []
|
||||
in_block = False
|
||||
for line in front.splitlines():
|
||||
if re.match(r"^write_paths:", line):
|
||||
in_block = True
|
||||
continue
|
||||
if in_block:
|
||||
item = re.match(r"^[ \t]+-[ \t]+(\S+)", line)
|
||||
if item:
|
||||
write_paths.append(item.group(1))
|
||||
elif line.strip():
|
||||
break
|
||||
|
||||
return {
|
||||
"path": path,
|
||||
"status": status.group(1) if status else None,
|
||||
"vikunja_task_id": task_id.group(1) if task_id else None,
|
||||
"write_paths": write_paths,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
def find_export_markers(text: str) -> tuple[int | None, int | None, re.Match | None]:
|
||||
"""按行定位导出标记,跳过围栏代码块内的同名文本。
|
||||
|
||||
任务文件会在代码块里举例说明区块格式,直接做子串匹配会把示例当成真标记。
|
||||
"""
|
||||
lines = text.splitlines(keepends=True)
|
||||
in_fence = False
|
||||
begin_idx = end_idx = None
|
||||
begin_match = None
|
||||
for index, line in enumerate(lines):
|
||||
if line.lstrip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
stripped = line.rstrip("\n")
|
||||
if begin_idx is None:
|
||||
match = EXPORT_BEGIN.fullmatch(stripped)
|
||||
if match:
|
||||
begin_idx, begin_match = index, match
|
||||
continue
|
||||
if stripped == EXPORT_END and end_idx is None:
|
||||
end_idx = index
|
||||
return begin_idx, end_idx, begin_match
|
||||
|
||||
|
||||
def check_export_block(task: dict[str, Any], errors: list[str], root: Path) -> None:
|
||||
"""校验导出区块未被手工改动,且与 frontmatter 的 vikunja_task_id 一致。"""
|
||||
text = task["text"]
|
||||
name = display_path(task["path"], root)
|
||||
begin_idx, end_idx, begin = find_export_markers(text)
|
||||
|
||||
if begin is None:
|
||||
if end_idx is not None:
|
||||
errors.append(f"{name}:有 END VIKUNJA EXPORT 标记但缺少合法的 BEGIN 标记。")
|
||||
# 尚未迁移的任务文件豁免,避免为让门禁转绿去改属于其他活跃任务的文件。
|
||||
return
|
||||
if end_idx is None:
|
||||
errors.append(f"{name}:有 BEGIN VIKUNJA EXPORT 标记但缺少 END 标记。")
|
||||
return
|
||||
if end_idx < begin_idx:
|
||||
errors.append(f"{name}:END VIKUNJA EXPORT 标记出现在 BEGIN 之前。")
|
||||
return
|
||||
|
||||
lines = text.splitlines(keepends=True)
|
||||
body = "".join(lines[begin_idx + 1 : end_idx])
|
||||
actual = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
if actual != begin.group("sha"):
|
||||
errors.append(
|
||||
f"{name}:导出区块内容与标记中的 sha256 不符,"
|
||||
"说明投影被手工修改。请改 Vikunja 后重新导出,不要直接编辑区块内容。"
|
||||
)
|
||||
|
||||
declared = task["vikunja_task_id"]
|
||||
if declared is None:
|
||||
errors.append(f"{name}:存在导出区块但 frontmatter 缺少 vikunja_task_id。")
|
||||
elif declared != begin.group("id"):
|
||||
errors.append(
|
||||
f"{name}:frontmatter 的 vikunja_task_id={declared} "
|
||||
f"与导出区块的 id={begin.group('id')} 不一致。"
|
||||
)
|
||||
|
||||
|
||||
def validate_task_files(root: Path, shared_documents: list[str]) -> list[str]:
|
||||
"""任务文件层面的门禁,全部离线可跑,不访问 Vikunja。"""
|
||||
errors: list[str] = []
|
||||
task_dir = root / "docs" / "tasks"
|
||||
if not task_dir.is_dir():
|
||||
return [f"任务目录不存在:{display_path(task_dir, root)}"]
|
||||
|
||||
tasks = [parse_task_file(p) for p in sorted(task_dir.glob("T-*.md"))]
|
||||
|
||||
# 检查 1:同时活跃的任务不得写入同一路径。单写入者是并行的前提。
|
||||
active = [t for t in tasks if t["status"] == ACTIVE_STATUS]
|
||||
for left, right in itertools.combinations(active, 2):
|
||||
overlap = sorted(set(left["write_paths"]) & set(right["write_paths"]))
|
||||
if overlap:
|
||||
errors.append(
|
||||
f"{display_path(left['path'], root)} 与 "
|
||||
f"{display_path(right['path'], root)} 同为 {ACTIVE_STATUS},"
|
||||
"write_paths 重叠:" + "、".join(overlap)
|
||||
)
|
||||
|
||||
# 检查 2:通配条目不得悄悄圈走共享文档,共享文档必须逐条显式声明。
|
||||
for task in tasks:
|
||||
for pattern in task["write_paths"]:
|
||||
if "*" not in pattern:
|
||||
continue
|
||||
swallowed = sorted(
|
||||
doc for doc in shared_documents if fnmatch.fnmatch(doc, pattern)
|
||||
)
|
||||
if swallowed:
|
||||
errors.append(
|
||||
f"{display_path(task['path'], root)}:write_paths 的通配条目 "
|
||||
f"{pattern} 覆盖了共享文档 " + "、".join(swallowed) +
|
||||
"。共享文档必须逐条显式列出,便于检查 1 判定排他。"
|
||||
)
|
||||
|
||||
# 检查 3、4:导出区块完整性与连接键一致性。
|
||||
for task in tasks:
|
||||
check_export_block(task, errors, root)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_manifest(root: Path) -> list[str]:
|
||||
root = root.resolve()
|
||||
manifest_path = root / "docs" / "agent-context.json"
|
||||
@@ -119,8 +270,8 @@ def validate_manifest(root: Path) -> list[str]:
|
||||
errors.append("存在未知顶层字段:" + ", ".join(unexpected))
|
||||
if root_object.get("schema") != EXPECTED_SCHEMA:
|
||||
errors.append(f"schema 必须是 {EXPECTED_SCHEMA}。")
|
||||
if root_object.get("schema_version") != 1:
|
||||
errors.append("schema_version 必须为 1。")
|
||||
if root_object.get("schema_version") != 2:
|
||||
errors.append("schema_version 必须为 2。")
|
||||
|
||||
authority = require_mapping(root_object.get("authority"), "authority", errors)
|
||||
for key in ("bootstrap", "framework_templates", "project_facts", "coordination"):
|
||||
@@ -155,6 +306,45 @@ def validate_manifest(root: Path) -> list[str]:
|
||||
else:
|
||||
errors.append(f"tasks.{key} 必须是非空字符串。")
|
||||
|
||||
shared_documents = require_string_list(
|
||||
root_object.get("shared_documents"), "shared_documents", errors
|
||||
)
|
||||
if not shared_documents:
|
||||
errors.append("shared_documents 至少需要登记一份跨任务共享文档。")
|
||||
path_values.extend((path, "shared_documents") for path in shared_documents)
|
||||
|
||||
tracker = require_mapping(root_object.get("tracker"), "tracker", errors)
|
||||
if set(tracker) != TRACKER_KEYS:
|
||||
errors.append(
|
||||
"tracker 必须且只能包含 "
|
||||
+ "、".join(sorted(TRACKER_KEYS))
|
||||
+ "。"
|
||||
)
|
||||
if tracker.get("kind") != "vikunja":
|
||||
errors.append("tracker.kind 目前只支持 vikunja。")
|
||||
if tracker.get("direction") != "tracker_to_git":
|
||||
errors.append(
|
||||
"tracker.direction 必须是 tracker_to_git:"
|
||||
"反向同步会重新引入两个权威,本项目不实现。"
|
||||
)
|
||||
if not isinstance(tracker.get("project"), str) or not tracker.get("project"):
|
||||
errors.append("tracker.project 必须是非空字符串。")
|
||||
export_script = tracker.get("export_script")
|
||||
if isinstance(export_script, str) and export_script:
|
||||
path_values.append((export_script, "tracker.export_script"))
|
||||
else:
|
||||
errors.append("tracker.export_script 必须是非空字符串。")
|
||||
git_native = require_string_list(
|
||||
tracker.get("git_native_fields"), "tracker.git_native_fields", errors
|
||||
)
|
||||
# write_paths 与边界章节的权威必须留在 git,否则安全边界被放宽时 git diff 看不出来。
|
||||
for required in ("write_paths", "boundaries_section"):
|
||||
if required not in git_native:
|
||||
errors.append(
|
||||
f"tracker.git_native_fields 必须包含 {required}:"
|
||||
"该项迁往 Vikunja 会切断安全边界的审计链。"
|
||||
)
|
||||
|
||||
refresh = require_mapping(root_object.get("refresh"), "refresh", errors)
|
||||
expected_refresh = {
|
||||
"context_ref": "default_branch_head_sha",
|
||||
@@ -177,6 +367,7 @@ def validate_manifest(root: Path) -> list[str]:
|
||||
for value, name in path_values:
|
||||
validate_repo_path(root, value, name, errors)
|
||||
find_sensitive_keys(root_object, "manifest", errors)
|
||||
errors.extend(validate_task_files(root, shared_documents))
|
||||
return errors
|
||||
|
||||
|
||||
@@ -189,6 +380,8 @@ def manifest_summary(root: Path) -> tuple[int, int]:
|
||||
for values in manifest["routes"].values():
|
||||
paths.update(values)
|
||||
paths.update(manifest["tasks"].values())
|
||||
paths.update(manifest["shared_documents"])
|
||||
paths.add(manifest["tracker"]["export_script"])
|
||||
return len(manifest["routes"]), len(paths)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Vikunja MCP 启动包装。
|
||||
#
|
||||
# 存在理由:
|
||||
# 1. 凭据只留在 vikunja.env(已 gitignore),不进 .mcp.json、不进仓库、不进 shell 历史。
|
||||
# 2. @0xk3vin/vikunja-mcp 内部自己拼 /api/v1(dist/vikunja-client.js:128),
|
||||
# 而 vikunja.env 里的 apiurl 是带 /api/v1 的完整地址。直接透传会请求
|
||||
# /api/v1/api/v1/... 并 404,所以这里必须剥掉一次。
|
||||
# 3. 版本写死。上游改了工具名或行为会直接改变 agent 行为,与
|
||||
# 「交付产物新旧以 SHA-256 判断」同源,不接受静默升级。
|
||||
# 4. 优先用全局安装的二进制,起得快且不依赖网络;缺失时才回退 npx。
|
||||
#
|
||||
# 本脚本不含凭据,必须提交进仓库:其他 agent 与其他机器都靠它接入同一套配置。
|
||||
set -euo pipefail
|
||||
|
||||
MCP_VERSION="${VIKUNJA_MCP_VERSION:-1.1.1}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# 凭据查找顺序:显式指定 > 仓库根目录 > 旧的家目录位置(向后兼容)。
|
||||
find_env_file() {
|
||||
if [ -n "${VIKUNJA_ENV_FILE:-}" ]; then
|
||||
printf '%s\n' "$VIKUNJA_ENV_FILE"
|
||||
return
|
||||
fi
|
||||
for candidate in "$REPO_ROOT/vikunja.env" "$HOME/.config/vikunja/env" "$HOME/.claude/vikunja.env"; do
|
||||
if [ -r "$candidate" ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
return
|
||||
fi
|
||||
done
|
||||
printf '%s\n' "$REPO_ROOT/vikunja.env"
|
||||
}
|
||||
|
||||
ENV_FILE="$(find_env_file)"
|
||||
|
||||
if [ ! -r "$ENV_FILE" ]; then
|
||||
echo "vikunja-mcp: 读不到凭据文件 $ENV_FILE(参照 vikunja.env.example 创建)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 只取需要的两个键,忽略文件里其余内容;tr -d '\r' 兼容 Windows 侧写出的 CRLF。
|
||||
read_key() {
|
||||
sed -n "s/^[[:space:]]*$1[[:space:]]*=[[:space:]]*//Ip" "$ENV_FILE" | tr -d '\r' | tail -n1
|
||||
}
|
||||
|
||||
apiurl="$(read_key apiurl)"
|
||||
apikey="$(read_key apikey)"
|
||||
|
||||
if [ -z "$apiurl" ] || [ -z "$apikey" ]; then
|
||||
echo "vikunja-mcp: $ENV_FILE 缺少 apiurl 或 apikey" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VIKUNJA_URL="${apiurl%/}"
|
||||
VIKUNJA_URL="${VIKUNJA_URL%/api/v1}"
|
||||
|
||||
export VIKUNJA_URL
|
||||
export VIKUNJA_API_TOKEN="$apikey"
|
||||
|
||||
# 全局安装存在且版本相符时直接用它;否则回退 npx(首次启动会联网拉包)。
|
||||
if command -v vikunja-mcp >/dev/null 2>&1 &&
|
||||
[ "$(npm ls -g --depth=0 --json @0xk3vin/vikunja-mcp 2>/dev/null |
|
||||
sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | tail -n1)" = "$MCP_VERSION" ]; then
|
||||
exec vikunja-mcp "$@"
|
||||
fi
|
||||
|
||||
exec npx -y "@0xk3vin/vikunja-mcp@${MCP_VERSION}" "$@"
|
||||
@@ -0,0 +1,476 @@
|
||||
#!/usr/bin/env python3
|
||||
"""把 Vikunja 上的任务内容单向导出到 docs/tasks/T-XXX.md 的标记区块。
|
||||
|
||||
设计约束(改动前先读 docs/tasks/T-008.md 的「方案」与「边界」):
|
||||
|
||||
- **单向**。本脚本只对 Vikunja 发 GET。任何写回路径都会重新引入两个权威,
|
||||
因此 http_get() 把方法写死为 GET,并拒绝其余方法;--selftest 会验证这一点。
|
||||
- **只写标记区块内**。区块外是 git 原生内容(write_paths、context_ref、
|
||||
## 边界),承载安全边界的审计链,脚本绝不触碰。
|
||||
- **只用标准库**。T-002 完成前仓库没有 Python 虚拟环境,本脚本必须能用系统
|
||||
python3 直接跑通,和 validate_agent_context.py 一致。
|
||||
- **未支持的 HTML 标签一律报错退出**,不静默丢弃。静默丢弃会让安全相关文字
|
||||
无声消失,与本项目 fail closed 的要求冲突。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
MANIFEST = REPO_ROOT / "docs" / "agent-context.json"
|
||||
ENV_CANDIDATES = (
|
||||
REPO_ROOT / "vikunja.env",
|
||||
Path.home() / ".config" / "vikunja" / "env",
|
||||
Path.home() / ".claude" / "vikunja.env",
|
||||
)
|
||||
TIMEOUT_SECONDS = 20
|
||||
|
||||
EXPORT_BEGIN_TEMPLATE = (
|
||||
"<!-- BEGIN VIKUNJA EXPORT id={task_id} synced={synced} sha256={sha256} -->"
|
||||
)
|
||||
EXPORT_END = "<!-- END VIKUNJA EXPORT -->"
|
||||
EXPORT_BEGIN_RE = re.compile(
|
||||
r"<!-- BEGIN VIKUNJA EXPORT id=(?P<id>\d+) synced=(?P<synced>\S+) "
|
||||
r"sha256=(?P<sha>[0-9a-f]{64}) -->"
|
||||
)
|
||||
|
||||
|
||||
class ExportError(RuntimeError):
|
||||
"""导出失败。一律以非零码退出,不产生半截文件。"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 凭据与 HTTP(只读)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def read_credentials() -> tuple[str, str]:
|
||||
for candidate in ENV_CANDIDATES:
|
||||
if candidate.is_file():
|
||||
values: dict[str, str] = {}
|
||||
for line in candidate.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip().lstrip("")
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip().lower()] = value.strip()
|
||||
apiurl, apikey = values.get("apiurl"), values.get("apikey")
|
||||
if not apiurl or not apikey:
|
||||
raise ExportError(f"{candidate} 缺少 apiurl 或 apikey。")
|
||||
return apiurl.rstrip("/"), apikey
|
||||
raise ExportError(
|
||||
"找不到凭据文件,尝试过:"
|
||||
+ "、".join(str(p) for p in ENV_CANDIDATES)
|
||||
+ "(参照 vikunja.env.example 创建)"
|
||||
)
|
||||
|
||||
|
||||
def http_get(apiurl: str, apikey: str, path: str, method: str = "GET") -> Any:
|
||||
"""只读取。method 参数存在是为了让「拒绝写方法」这条约束可被测试断言。"""
|
||||
if method != "GET":
|
||||
raise ExportError(
|
||||
f"本脚本只允许 GET,收到 {method}。"
|
||||
"写回 Vikunja 会重新引入两个权威,见 docs/tasks/T-008.md 的边界。"
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
f"{apiurl}{path}",
|
||||
headers={"Authorization": f"Bearer {apikey}", "Accept": "application/json"},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
raise ExportError(f"GET {path} 返回 HTTP {error.code}。") from error
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as error:
|
||||
raise ExportError(
|
||||
f"GET {path} 连接失败:{error}。"
|
||||
"Vikunja 不可达时不导出,本地投影保持上一次的内容。"
|
||||
) from error
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# HTML -> Markdown
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
INLINE_MARKS = {
|
||||
"strong": "**",
|
||||
"b": "**",
|
||||
"em": "*",
|
||||
"i": "*",
|
||||
"del": "~~",
|
||||
"s": "~~",
|
||||
}
|
||||
BLOCK_TAGS = {
|
||||
"p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li",
|
||||
"pre", "blockquote", "table", "thead", "tbody", "tr", "th", "td", "hr", "div",
|
||||
}
|
||||
SUPPORTED = BLOCK_TAGS | set(INLINE_MARKS) | {"a", "code", "br", "span"}
|
||||
|
||||
|
||||
class _Node:
|
||||
__slots__ = ("tag", "attrs", "children")
|
||||
|
||||
def __init__(self, tag: str, attrs: dict[str, str] | None = None) -> None:
|
||||
self.tag = tag
|
||||
self.attrs = attrs or {}
|
||||
self.children: list[Any] = []
|
||||
|
||||
|
||||
class _TreeBuilder(HTMLParser):
|
||||
"""把 Vikunja(TipTap) 产出的 HTML 建成树;遇到未知标签立即失败。"""
|
||||
|
||||
VOID = {"br", "hr"}
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.root = _Node("#root")
|
||||
self.stack = [self.root]
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
if tag not in SUPPORTED:
|
||||
raise ExportError(
|
||||
f"description 含未支持的 HTML 标签 <{tag}>。"
|
||||
"不静默丢弃:请在 scripts/vikunja_export.py 显式支持它,"
|
||||
"或改用已支持的写法。"
|
||||
)
|
||||
node = _Node(tag, {k: (v or "") for k, v in attrs})
|
||||
self.stack[-1].children.append(node)
|
||||
if tag not in self.VOID:
|
||||
self.stack.append(node)
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in self.VOID:
|
||||
return
|
||||
for index in range(len(self.stack) - 1, 0, -1):
|
||||
if self.stack[index].tag == tag:
|
||||
del self.stack[index:]
|
||||
return
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
self.stack[-1].children.append(data)
|
||||
|
||||
|
||||
def _render_inline(node: _Node) -> str:
|
||||
out: list[str] = []
|
||||
for child in node.children:
|
||||
if isinstance(child, str):
|
||||
out.append(child.replace("\xa0", " "))
|
||||
elif child.tag == "br":
|
||||
out.append("\n")
|
||||
elif child.tag in INLINE_MARKS:
|
||||
inner = _render_inline(child).strip()
|
||||
out.append(f"{INLINE_MARKS[child.tag]}{inner}{INLINE_MARKS[child.tag]}" if inner else "")
|
||||
elif child.tag == "code":
|
||||
out.append(f"`{_render_inline(child)}`")
|
||||
elif child.tag == "a":
|
||||
text = _render_inline(child).strip()
|
||||
href = child.attrs.get("href", "")
|
||||
out.append(f"[{text}]({href})" if href else text)
|
||||
elif child.tag == "span":
|
||||
out.append(_render_inline(child))
|
||||
else:
|
||||
out.append(_render_block(child, 0))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _render_rows(node: _Node) -> list[list[str]]:
|
||||
rows: list[list[str]] = []
|
||||
for child in node.children:
|
||||
if isinstance(child, str):
|
||||
continue
|
||||
if child.tag in {"thead", "tbody"}:
|
||||
rows.extend(_render_rows(child))
|
||||
elif child.tag == "tr":
|
||||
cells = [
|
||||
_render_inline(cell).strip().replace("\n", " ").replace("|", "\\|")
|
||||
for cell in child.children
|
||||
if isinstance(cell, _Node) and cell.tag in {"th", "td"}
|
||||
]
|
||||
rows.append(cells)
|
||||
return rows
|
||||
|
||||
|
||||
def _render_block(node: _Node, depth: int) -> str:
|
||||
tag = node.tag
|
||||
if tag in {"p", "div"}:
|
||||
return _render_inline(node).strip()
|
||||
if tag in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
||||
return "#" * int(tag[1]) + " " + _render_inline(node).strip()
|
||||
if tag == "hr":
|
||||
return "---"
|
||||
if tag == "blockquote":
|
||||
inner = _render_children(node, depth)
|
||||
return "\n".join(f"> {line}" if line else ">" for line in inner.split("\n"))
|
||||
if tag == "pre":
|
||||
text = "".join(_collect_text(child) for child in node.children)
|
||||
return "```\n" + text.rstrip("\n") + "\n```"
|
||||
if tag in {"ul", "ol"}:
|
||||
items: list[str] = []
|
||||
counter = 0
|
||||
for child in node.children:
|
||||
if not isinstance(child, _Node) or child.tag != "li":
|
||||
continue
|
||||
counter += 1
|
||||
marker = "- " if tag == "ul" else f"{counter}. "
|
||||
body = _render_children(child, depth + 1).strip()
|
||||
pad = " " * len(marker)
|
||||
lines = body.split("\n")
|
||||
items.append(
|
||||
marker + lines[0]
|
||||
+ "".join("\n" + (pad + line if line else "") for line in lines[1:])
|
||||
)
|
||||
return "\n".join(items)
|
||||
if tag == "table":
|
||||
rows = _render_rows(node)
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(row) for row in rows)
|
||||
rows = [row + [""] * (width - len(row)) for row in rows]
|
||||
head, *body = rows
|
||||
out = ["| " + " | ".join(head) + " |",
|
||||
"| " + " | ".join(["---"] * width) + " |"]
|
||||
out.extend("| " + " | ".join(row) + " |" for row in body)
|
||||
return "\n".join(out)
|
||||
return _render_inline(node).strip()
|
||||
|
||||
|
||||
def _collect_text(node: Any) -> str:
|
||||
if isinstance(node, str):
|
||||
return node
|
||||
return "".join(_collect_text(child) for child in node.children)
|
||||
|
||||
|
||||
def _render_children(node: _Node, depth: int) -> str:
|
||||
blocks: list[str] = []
|
||||
inline_buffer: list[Any] = []
|
||||
|
||||
def flush() -> None:
|
||||
if inline_buffer:
|
||||
holder = _Node("p")
|
||||
holder.children = list(inline_buffer)
|
||||
text = _render_inline(holder).strip()
|
||||
if text:
|
||||
blocks.append(text)
|
||||
inline_buffer.clear()
|
||||
|
||||
for child in node.children:
|
||||
if isinstance(child, str):
|
||||
if child.strip():
|
||||
inline_buffer.append(child)
|
||||
elif child.tag in BLOCK_TAGS:
|
||||
flush()
|
||||
rendered = _render_block(child, depth)
|
||||
if rendered:
|
||||
blocks.append(rendered)
|
||||
else:
|
||||
inline_buffer.append(child)
|
||||
flush()
|
||||
return "\n\n".join(blocks)
|
||||
|
||||
|
||||
def html_to_markdown(source: str) -> str:
|
||||
if not source or not source.strip():
|
||||
return ""
|
||||
builder = _TreeBuilder()
|
||||
builder.feed(source)
|
||||
builder.close()
|
||||
text = _render_children(builder.root, 0)
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 组装区块
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_block_body(task: dict[str, Any], comments: list[dict[str, Any]]) -> str:
|
||||
parts = [html_to_markdown(task.get("description") or "")]
|
||||
|
||||
records: list[str] = []
|
||||
for comment in comments:
|
||||
author = (comment.get("author") or {}).get("username", "unknown")
|
||||
created = comment.get("created", "")
|
||||
body = html_to_markdown(comment.get("comment") or "")
|
||||
records.append(f"### {created} · {author}\n\n{body}".rstrip())
|
||||
|
||||
parts.append("## 执行记录\n\n" + ("\n\n".join(records) if records else "(暂无)"))
|
||||
body = "\n\n".join(part for part in parts if part.strip())
|
||||
return body.strip("\n") + "\n"
|
||||
|
||||
|
||||
def splice(original: str, task_id: int, body: str, synced: str) -> str:
|
||||
"""替换标记区块,区块外内容原样保留。"""
|
||||
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||||
begin = EXPORT_BEGIN_TEMPLATE.format(task_id=task_id, synced=synced, sha256=digest)
|
||||
block = f"{begin}\n{body}{EXPORT_END}\n"
|
||||
|
||||
lines = original.splitlines(keepends=True)
|
||||
in_fence = False
|
||||
begin_idx = end_idx = None
|
||||
for index, line in enumerate(lines):
|
||||
if line.lstrip().startswith("```"):
|
||||
in_fence = not in_fence
|
||||
continue
|
||||
if in_fence:
|
||||
continue
|
||||
stripped = line.rstrip("\n")
|
||||
if begin_idx is None and EXPORT_BEGIN_RE.fullmatch(stripped):
|
||||
begin_idx = index
|
||||
elif stripped == EXPORT_END and end_idx is None:
|
||||
end_idx = index
|
||||
|
||||
if begin_idx is None or end_idx is None or end_idx < begin_idx:
|
||||
raise ExportError(
|
||||
"目标文件缺少成对的导出标记。首次迁移时请先手工插入空区块:"
|
||||
f"\n{EXPORT_BEGIN_TEMPLATE.format(task_id=task_id, synced=synced, sha256='0' * 64)}"
|
||||
f"\n{EXPORT_END}"
|
||||
)
|
||||
|
||||
return "".join(lines[:begin_idx]) + block + "".join(lines[end_idx + 1 :])
|
||||
|
||||
|
||||
def frontmatter_task_id(text: str) -> int | None:
|
||||
parts = text.split("---\n", 2)
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
match = re.search(r"^vikunja_task_id:[ \t]*(\d+)", parts[1], re.MULTILINE)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 自检
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def selftest() -> int:
|
||||
failures: list[str] = []
|
||||
|
||||
def check(name: str, actual: Any, expected: Any) -> None:
|
||||
if actual != expected:
|
||||
failures.append(f"{name}\n 实际: {actual!r}\n 期望: {expected!r}")
|
||||
|
||||
check("段落与粗体", html_to_markdown("<p>纸面<b>价格</b></p>"), "纸面**价格**")
|
||||
check("行内代码", html_to_markdown("<p>调用 <code>SavePerson</code></p>"),
|
||||
"调用 `SavePerson`")
|
||||
check("链接", html_to_markdown('<p><a href="https://x/y">文档</a></p>'),
|
||||
"[文档](https://x/y)")
|
||||
check("无序列表", html_to_markdown("<ul><li><p>甲</p></li><li><p>乙</p></li></ul>"),
|
||||
"- 甲\n- 乙")
|
||||
check("有序列表", html_to_markdown("<ol><li><p>先</p></li><li><p>后</p></li></ol>"),
|
||||
"1. 先\n2. 后")
|
||||
check("标题", html_to_markdown("<h2>方案</h2>"), "## 方案")
|
||||
check("代码块", html_to_markdown("<pre><code>go test ./...</code></pre>"),
|
||||
"```\ngo test ./...\n```")
|
||||
check("表格",
|
||||
html_to_markdown("<table><tr><th>项</th><th>值</th></tr>"
|
||||
"<tr><td>闸门</td><td>三道</td></tr></table>"),
|
||||
"| 项 | 值 |\n| --- | --- |\n| 闸门 | 三道 |")
|
||||
check("引用", html_to_markdown("<blockquote><p>不碰钱</p></blockquote>"), "> 不碰钱")
|
||||
check("换行", html_to_markdown("<p>上<br>下</p>"), "上\n下")
|
||||
check("空输入", html_to_markdown(""), "")
|
||||
|
||||
# 未支持标签必须失败,不得静默丢弃。
|
||||
try:
|
||||
html_to_markdown("<p><marquee>价格</marquee></p>")
|
||||
failures.append("未支持标签 <marquee> 应当报错,但通过了。")
|
||||
except ExportError:
|
||||
pass
|
||||
|
||||
# 只读约束:任何非 GET 方法必须被拒绝。
|
||||
for method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
try:
|
||||
http_get("http://unused.invalid", "tk_x", "/tasks/1", method=method)
|
||||
failures.append(f"{method} 应当被拒绝,但通过了。")
|
||||
except ExportError as error:
|
||||
if "只允许 GET" not in str(error):
|
||||
failures.append(f"{method} 被拒绝的原因不对:{error}")
|
||||
|
||||
# 幂等:同样输入两次拼接结果必须一致。
|
||||
template = ("---\nid: T-999\nvikunja_task_id: 7\n---\n\n"
|
||||
f"{EXPORT_BEGIN_TEMPLATE.format(task_id=7, synced='x', sha256='0' * 64)}\n"
|
||||
f"旧内容\n{EXPORT_END}\n\n## 边界\n\n不可放宽。\n")
|
||||
first = splice(template, 7, "新内容\n", "2026-08-03T00:00:00Z")
|
||||
second = splice(first, 7, "新内容\n", "2026-08-03T00:00:00Z")
|
||||
check("幂等", first, second)
|
||||
if "## 边界" not in first or "不可放宽。" not in first:
|
||||
failures.append("区块外的 git 原生内容被破坏。")
|
||||
|
||||
for failure in failures:
|
||||
print(f"FAIL {failure}", file=sys.stderr)
|
||||
print(f"自检:{'通过' if not failures else str(len(failures)) + ' 项失败'}")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def export_file(path: Path, apiurl: str, apikey: str, check_only: bool) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
task_id = frontmatter_task_id(text)
|
||||
if task_id is None:
|
||||
return False
|
||||
|
||||
task = http_get(apiurl, apikey, f"/tasks/{task_id}")
|
||||
comments = http_get(apiurl, apikey, f"/tasks/{task_id}/comments") or []
|
||||
body = build_block_body(task, comments)
|
||||
|
||||
existing = EXPORT_BEGIN_RE.search(text)
|
||||
if existing and existing.group("sha") == hashlib.sha256(body.encode("utf-8")).hexdigest():
|
||||
return False # 内容未变,不重写,避免噪音 diff。
|
||||
|
||||
synced = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
updated = splice(text, task_id, body, synced)
|
||||
if check_only:
|
||||
print(f"DIFF {path.resolve().relative_to(REPO_ROOT)}")
|
||||
return True
|
||||
path.write_text(updated, encoding="utf-8", newline="\n")
|
||||
print(f"更新 {path.resolve().relative_to(REPO_ROOT)}(vikunja#{task_id})")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--file", type=Path, help="只导出指定任务文件")
|
||||
parser.add_argument("--check", action="store_true", help="只报告差异,不写文件")
|
||||
parser.add_argument("--selftest", action="store_true", help="离线自检,不访问网络")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.selftest:
|
||||
return selftest()
|
||||
|
||||
try:
|
||||
apiurl, apikey = read_credentials()
|
||||
targets = [args.file] if args.file else sorted(
|
||||
(REPO_ROOT / "docs" / "tasks").glob("T-*.md")
|
||||
)
|
||||
changed = sum(export_file(p, apiurl, apikey, args.check) for p in targets)
|
||||
except ExportError as error:
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.check and changed:
|
||||
print(f"{changed} 个文件与 Vikunja 不同步。", file=sys.stderr)
|
||||
return 1
|
||||
print(f"完成:{changed} 个文件有变更。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user