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:
QiuSW
2026-08-03 17:00:41 +08:00
co-authored by Claude Opus 5
parent b9e29ed970
commit 421c865ad2
12 changed files with 1098 additions and 4 deletions
+195 -2
View File
@@ -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)
+67
View File
@@ -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}" "$@"
+476
View File
@@ -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())