draftgo-cli 3.0.33 → 3.0.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -269
- package/package.json +6 -2
- package/resources/skill/SKILL.md +114 -55
- package/resources/skill/init/SKILL.md +29 -15
- package/resources/skill/manifest.json +13 -5
- package/resources/skill/push/SKILL.md +41 -29
- package/resources/skill/references/aihub.md +8 -5
- package/resources/skill/references/api-endpoints.md +5 -3
- package/resources/skill/references/architecture.md +1 -1
- package/resources/skill/references/checkout.md +116 -0
- package/resources/skill/references/custom-services.md +9 -10
- package/resources/skill/references/data.md +4 -2
- package/resources/skill/references/frontend.md +99 -23
- package/resources/skill/references/mcp.md +101 -0
- package/resources/skill/references/modules.md +8 -8
- package/resources/skill/references/parallel.md +6 -3
- package/resources/skill/references/runtime.md +7 -10
- package/resources/skill/scripts/README.md +8 -0
- package/resources/skill/story/SKILL.md +8 -8
- package/src/cli.js +5 -0
- package/src/commandRegistry.js +7 -1
- package/src/commands/api.js +24 -187
- package/src/commands/autoPush.js +48 -17
- package/src/commands/check.js +17 -47
- package/src/commands/checkout.js +18 -0
- package/src/commands/commit.js +21 -0
- package/src/commands/conflict.js +30 -0
- package/src/commands/conflicts.js +16 -0
- package/src/commands/connect.js +60 -48
- package/src/commands/delete.js +79 -64
- package/src/commands/deploy.js +18 -10
- package/src/commands/diff.js +23 -0
- package/src/commands/help.js +99 -75
- package/src/commands/init.js +4 -10
- package/src/commands/local.js +23 -6
- package/src/commands/map.js +89 -89
- package/src/commands/mcp.js +126 -0
- package/src/commands/sync.js +28 -43
- package/src/commands/verifyUi.js +3 -2
- package/src/localdev/index.js +37 -7
- package/src/localdev/mysqlClient.js +1 -1
- package/src/mcp/client.js +275 -0
- package/src/mcp/hosts.js +520 -0
- package/src/mcp/protocol.js +173 -0
- package/src/mcp/stdio.js +300 -0
- package/src/mcp/tools.js +37 -0
- package/src/platforms.js +3 -4
- package/src/projectConfig.js +91 -49
- package/src/projectMap.js +123 -460
- package/src/skill.js +6 -28
- package/src/worktree/backend.js +250 -0
- package/src/worktree/errors.js +28 -0
- package/src/worktree/index.js +461 -0
- package/src/worktree/manifest.js +75 -0
- package/src/worktree/streams.js +200 -0
- package/src/worktree/types.js +103 -0
- package/src/worktree/validate.js +37 -0
- package/resources/skill/pull/SKILL.md +0 -33
- package/resources/skill/references/api.json +0 -20248
- package/resources/skill/scripts/draftgo_delete.py +0 -149
- package/resources/skill/scripts/draftgo_init.py +0 -80
- package/resources/skill/scripts/draftgo_pull.py +0 -427
- package/resources/skill/scripts/draftgo_push.py +0 -1022
- package/src/python.js +0 -27
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
DraftGo Delete Script
|
|
4
|
-
删除云端资源,并同步移除本地 index.json 条目和文件。
|
|
5
|
-
|
|
6
|
-
用法:
|
|
7
|
-
python draftgo_delete.py pages <id>
|
|
8
|
-
python draftgo_delete.py nav <id>
|
|
9
|
-
python draftgo_delete.py db_meta <id>
|
|
10
|
-
python draftgo_delete.py custom_scripts <id>
|
|
11
|
-
python draftgo_delete.py docs <id>
|
|
12
|
-
python draftgo_delete.py doc_categories <id>
|
|
13
|
-
python draftgo_delete.py aihub <id>
|
|
14
|
-
"""
|
|
15
|
-
import json, sys
|
|
16
|
-
from pathlib import Path
|
|
17
|
-
import urllib.request, urllib.error
|
|
18
|
-
|
|
19
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def find_project_root(start: Path) -> Path:
|
|
23
|
-
cur = start
|
|
24
|
-
for _ in range(10):
|
|
25
|
-
if (cur / ".draftgo").is_dir():
|
|
26
|
-
return cur
|
|
27
|
-
if cur.parent == cur:
|
|
28
|
-
break
|
|
29
|
-
cur = cur.parent
|
|
30
|
-
cwd = Path.cwd()
|
|
31
|
-
if (cwd / ".draftgo").is_dir():
|
|
32
|
-
return cwd
|
|
33
|
-
return start.parents[3] if len(start.parents) >= 4 else start
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def load_config():
|
|
40
|
-
cfg_path = DEFAULT_ROOT / ".draftgo/config.json"
|
|
41
|
-
if not cfg_path.exists():
|
|
42
|
-
print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
|
|
43
|
-
sys.exit(1)
|
|
44
|
-
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
45
|
-
return cfg["server"].rstrip("/"), cfg["token"]
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def api_delete(server, token, path):
|
|
49
|
-
headers = {
|
|
50
|
-
"Authorization": f"Bearer {token}",
|
|
51
|
-
"Accept": "application/json",
|
|
52
|
-
}
|
|
53
|
-
req = urllib.request.Request(
|
|
54
|
-
f"{server}{path}",
|
|
55
|
-
method="DELETE",
|
|
56
|
-
headers=headers,
|
|
57
|
-
)
|
|
58
|
-
try:
|
|
59
|
-
with urllib.request.urlopen(req, timeout=15) as r:
|
|
60
|
-
return True, r.status, r.read().decode("utf-8", errors="replace")
|
|
61
|
-
except urllib.error.HTTPError as e:
|
|
62
|
-
return False, e.code, e.read().decode(errors="replace")
|
|
63
|
-
except Exception as e:
|
|
64
|
-
return False, None, str(e)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _load_index(rel_index):
|
|
68
|
-
new_path = DEFAULT_ROOT / ".draftgo" / rel_index
|
|
69
|
-
legacy_path = DEFAULT_ROOT / rel_index
|
|
70
|
-
path = new_path if new_path.exists() else legacy_path
|
|
71
|
-
if not path.exists():
|
|
72
|
-
print(f"ERR: {rel_index} not found", file=sys.stderr)
|
|
73
|
-
sys.exit(1)
|
|
74
|
-
return path, json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _remove_local_file(rel_path):
|
|
78
|
-
if not rel_path:
|
|
79
|
-
return
|
|
80
|
-
full = DEFAULT_ROOT / rel_path
|
|
81
|
-
if full.exists():
|
|
82
|
-
full.unlink()
|
|
83
|
-
print(f" deleted local file: {rel_path}")
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
# type → (api_prefix, index_rel, id_field, file_field)
|
|
87
|
-
TYPE_MAP = {
|
|
88
|
-
"pages": ("/api/pages", "pages/index.json", "id", "html_file"),
|
|
89
|
-
"nav": ("/api/navigations", "navigations/index.json", "id", "html_file"),
|
|
90
|
-
"db_meta": ("/api/db-meta", "db_meta/index.json", "id", None),
|
|
91
|
-
"custom_scripts": ("/api/scripts", "custom_scripts/index.json", "id", "code_file"),
|
|
92
|
-
"docs": ("/api/docs/articles", "docs/articles/index.json", "id", "content_file"),
|
|
93
|
-
"doc_categories": ("/api/docs/categories", "doc_categories/index.json", "id", None),
|
|
94
|
-
"aihub": ("/api/aihub", "aihub/index.json", "id", None),
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def delete_resource(type_name, resource_id):
|
|
99
|
-
if type_name not in TYPE_MAP:
|
|
100
|
-
print(f"ERR: unknown type '{type_name}'. Supported: {', '.join(TYPE_MAP)}", file=sys.stderr)
|
|
101
|
-
sys.exit(1)
|
|
102
|
-
|
|
103
|
-
api_prefix, index_rel, id_field, file_field = TYPE_MAP[type_name]
|
|
104
|
-
server, token = load_config()
|
|
105
|
-
|
|
106
|
-
# 1. 找到 index 条目
|
|
107
|
-
index_path, items = _load_index(index_rel)
|
|
108
|
-
item = next((it for it in items if str(it.get(id_field)) == str(resource_id)), None)
|
|
109
|
-
label = (
|
|
110
|
-
item.get("title") or item.get("name") or item.get("type") or item.get("slug") or resource_id
|
|
111
|
-
if item else resource_id
|
|
112
|
-
)
|
|
113
|
-
|
|
114
|
-
# 2. 调用 DELETE API
|
|
115
|
-
ok, status, body = api_delete(server, token, f"{api_prefix}/{resource_id}")
|
|
116
|
-
if not ok and status != 404:
|
|
117
|
-
print(f" ERR [{label}] 删除失败: HTTP {status}: {body}")
|
|
118
|
-
sys.exit(1)
|
|
119
|
-
|
|
120
|
-
if status == 404:
|
|
121
|
-
print(f" WARN [{label}] 云端不存在(id={resource_id}),仅清理本地")
|
|
122
|
-
else:
|
|
123
|
-
print(f" OK [{label}] 已从云端删除(id={resource_id})")
|
|
124
|
-
|
|
125
|
-
# 3. 删除本地文件
|
|
126
|
-
if item and file_field:
|
|
127
|
-
_remove_local_file(item.get(file_field))
|
|
128
|
-
|
|
129
|
-
# 4. 从 index 移除条目
|
|
130
|
-
new_items = [it for it in items if str(it.get(id_field)) != str(resource_id)]
|
|
131
|
-
if len(new_items) < len(items):
|
|
132
|
-
index_path.write_text(json.dumps(new_items, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
133
|
-
print(f" OK [{label}] 已从本地 index 移除")
|
|
134
|
-
else:
|
|
135
|
-
print(f" WARN [{label}] 本地 index 中未找到 id={resource_id}")
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def main():
|
|
139
|
-
if len(sys.argv) < 3:
|
|
140
|
-
print(f"usage: draftgo_delete.py <{'|'.join(TYPE_MAP)}> <id>", file=sys.stderr)
|
|
141
|
-
sys.exit(1)
|
|
142
|
-
|
|
143
|
-
type_name = sys.argv[1]
|
|
144
|
-
resource_id = sys.argv[2]
|
|
145
|
-
delete_resource(type_name, resource_id)
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if __name__ == "__main__":
|
|
149
|
-
main()
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
DraftGo Init Script
|
|
4
|
-
初始化项目配置,并从云端拉取全量数据到本地。
|
|
5
|
-
|
|
6
|
-
用法:
|
|
7
|
-
DRAFTGO_TOKEN=yyy python draftgo_init.py --server https://xxx [project_dir]
|
|
8
|
-
"""
|
|
9
|
-
import argparse, json, os, sys
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
13
|
-
|
|
14
|
-
# 同目录下的 pull 模块
|
|
15
|
-
sys.path.insert(0, str(SCRIPT_DIR))
|
|
16
|
-
from draftgo_pull import find_project_root, pull_all, load_config # noqa: E402
|
|
17
|
-
|
|
18
|
-
DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def main():
|
|
22
|
-
parser = argparse.ArgumentParser()
|
|
23
|
-
parser.add_argument("--server", required=True)
|
|
24
|
-
parser.add_argument("--token", required=False, help=argparse.SUPPRESS)
|
|
25
|
-
parser.add_argument(
|
|
26
|
-
"project_dir",
|
|
27
|
-
nargs="?",
|
|
28
|
-
default=None,
|
|
29
|
-
help="项目根目录(可选),默认自动推导",
|
|
30
|
-
)
|
|
31
|
-
args = parser.parse_args()
|
|
32
|
-
|
|
33
|
-
server = args.server.rstrip("/")
|
|
34
|
-
token = args.token or os.environ.get("DRAFTGO_TOKEN")
|
|
35
|
-
if not token:
|
|
36
|
-
parser.error("token is required: set DRAFTGO_TOKEN or pass --token")
|
|
37
|
-
root = Path(args.project_dir).resolve() if args.project_dir else DEFAULT_ROOT
|
|
38
|
-
dg_dir = root / ".draftgo"
|
|
39
|
-
dg_dir.mkdir(parents=True, exist_ok=True)
|
|
40
|
-
|
|
41
|
-
# 写入配置。重复初始化时保留用户明确启用的自动推送开关。
|
|
42
|
-
config_path = dg_dir / "config.json"
|
|
43
|
-
old_cfg = {}
|
|
44
|
-
if config_path.exists():
|
|
45
|
-
try:
|
|
46
|
-
old_cfg = json.loads(config_path.read_text(encoding="utf-8"))
|
|
47
|
-
except Exception:
|
|
48
|
-
pass
|
|
49
|
-
cfg = {
|
|
50
|
-
"server": server,
|
|
51
|
-
"token": token,
|
|
52
|
-
"lessons_on_push": old_cfg.get("lessons_on_push", True),
|
|
53
|
-
"auto_push": old_cfg.get("auto_push", False) is True,
|
|
54
|
-
}
|
|
55
|
-
config_path.write_text(
|
|
56
|
-
json.dumps(cfg, indent=2),
|
|
57
|
-
encoding="utf-8",
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
# .gitignore 保护敏感配置
|
|
61
|
-
gi = root / ".gitignore"
|
|
62
|
-
content = gi.read_text(encoding="utf-8") if gi.exists() else ""
|
|
63
|
-
if ".draftgo/config.json" not in content:
|
|
64
|
-
with gi.open("a", encoding="utf-8") as f:
|
|
65
|
-
f.write("\n.draftgo/config.json\n")
|
|
66
|
-
|
|
67
|
-
# 拉取全量数据
|
|
68
|
-
print("Pulling all resources from cloud...")
|
|
69
|
-
pull_all(server, token, root)
|
|
70
|
-
|
|
71
|
-
# 创建辅助目录
|
|
72
|
-
for d in ("lessons", "Task"):
|
|
73
|
-
(dg_dir / d).mkdir(parents=True, exist_ok=True)
|
|
74
|
-
print(" OK .draftgo/lessons/ .draftgo/Task/")
|
|
75
|
-
|
|
76
|
-
print(f"\nDone. server: {server}")
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if __name__ == "__main__":
|
|
80
|
-
main()
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
DraftGo Pull Script
|
|
4
|
-
从云端拉取数据到本地 .draftgo/ 目录,支持按类型增量拉取。
|
|
5
|
-
|
|
6
|
-
用法:
|
|
7
|
-
python draftgo_pull.py pages [page_id ...]
|
|
8
|
-
python draftgo_pull.py nav [nav_id ...]
|
|
9
|
-
python draftgo_pull.py db_meta [db_meta_id ...]
|
|
10
|
-
python draftgo_pull.py aihub [aihub_id ...]
|
|
11
|
-
python draftgo_pull.py system_config [config_key ...]
|
|
12
|
-
python draftgo_pull.py docs [article_id ...]
|
|
13
|
-
python draftgo_pull.py doc_categories [category_id ...]
|
|
14
|
-
python draftgo_pull.py custom_scripts [script_id ...]
|
|
15
|
-
python draftgo_pull.py roles [role_id ...]
|
|
16
|
-
python draftgo_pull.py users [user_id ...]
|
|
17
|
-
python draftgo_pull.py --all
|
|
18
|
-
"""
|
|
19
|
-
import json, sys
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
import urllib.request, urllib.error, urllib.parse
|
|
22
|
-
|
|
23
|
-
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def find_project_root(start: Path) -> Path:
|
|
27
|
-
cur = start
|
|
28
|
-
for _ in range(10):
|
|
29
|
-
if (cur / ".draftgo").is_dir():
|
|
30
|
-
return cur
|
|
31
|
-
if cur.parent == cur:
|
|
32
|
-
break
|
|
33
|
-
cur = cur.parent
|
|
34
|
-
# CWD 回退:脚本可能在中文路径下被调用,SCRIPT_DIR 解析失败时用 CWD
|
|
35
|
-
cwd = Path.cwd()
|
|
36
|
-
if (cwd / ".draftgo").is_dir():
|
|
37
|
-
return cwd
|
|
38
|
-
return start.parents[3] if len(start.parents) >= 4 else start
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
DEFAULT_ROOT = find_project_root(SCRIPT_DIR)
|
|
42
|
-
|
|
43
|
-
# ── 端点映射 ──
|
|
44
|
-
ENDPOINTS = {
|
|
45
|
-
"pages": "/api/pages/",
|
|
46
|
-
"navigations": "/api/navigations",
|
|
47
|
-
"roles": "/api/roles",
|
|
48
|
-
"users": "/api/users",
|
|
49
|
-
"db_meta": "/api/db-meta",
|
|
50
|
-
"aihub": "/api/aihub",
|
|
51
|
-
"system_config": "/api/system/config",
|
|
52
|
-
"doc_categories": "/api/docs/categories?flat=true",
|
|
53
|
-
"docs": "/api/docs/admin/articles",
|
|
54
|
-
"custom_scripts": "/api/scripts/",
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
# ── 通用工具函数 ──
|
|
58
|
-
|
|
59
|
-
def load_config(root=None):
|
|
60
|
-
root = root or DEFAULT_ROOT
|
|
61
|
-
cfg_path = root / ".draftgo/config.json"
|
|
62
|
-
if not cfg_path.exists():
|
|
63
|
-
print("ERR: .draftgo/config.json not found, run /draftgo init first", file=sys.stderr)
|
|
64
|
-
sys.exit(1)
|
|
65
|
-
cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
66
|
-
return cfg["server"].rstrip("/"), cfg["token"]
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
_UA = (
|
|
70
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
71
|
-
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def fetch(server, token, path):
|
|
76
|
-
url = f"{server}{path}"
|
|
77
|
-
req = urllib.request.Request(
|
|
78
|
-
url,
|
|
79
|
-
headers={
|
|
80
|
-
"Authorization": f"Bearer {token}",
|
|
81
|
-
"User-Agent": _UA,
|
|
82
|
-
"Accept": "application/json",
|
|
83
|
-
},
|
|
84
|
-
)
|
|
85
|
-
try:
|
|
86
|
-
with urllib.request.urlopen(req, timeout=15) as r:
|
|
87
|
-
return json.loads(r.read())
|
|
88
|
-
except urllib.error.HTTPError as e:
|
|
89
|
-
print(f" ✗ {path} → HTTP {e.code}", file=sys.stderr)
|
|
90
|
-
return None
|
|
91
|
-
except Exception as e:
|
|
92
|
-
print(f" ✗ {path} → {e}", file=sys.stderr)
|
|
93
|
-
return None
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def unwrap(raw):
|
|
97
|
-
"""Unwrap unified envelope: {code, data, message} → data."""
|
|
98
|
-
if isinstance(raw, dict) and "code" in raw and "data" in raw:
|
|
99
|
-
return raw["data"]
|
|
100
|
-
return raw
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def extract_items(raw):
|
|
104
|
-
raw = unwrap(raw)
|
|
105
|
-
if isinstance(raw, list):
|
|
106
|
-
return raw
|
|
107
|
-
if isinstance(raw, dict):
|
|
108
|
-
for key in ("items", "results"):
|
|
109
|
-
v = raw.get(key)
|
|
110
|
-
if isinstance(v, list):
|
|
111
|
-
return v
|
|
112
|
-
if isinstance(raw.get("data"), list):
|
|
113
|
-
return raw["data"]
|
|
114
|
-
return []
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def safe_slug(s):
|
|
118
|
-
return str(s).strip("/").replace("/", "_") or "root"
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def write_index(folder, items, preserve_existing=False, id_key="id"):
|
|
122
|
-
if preserve_existing:
|
|
123
|
-
index_path = folder / "index.json"
|
|
124
|
-
try:
|
|
125
|
-
existing = json.loads(index_path.read_text(encoding="utf-8"))
|
|
126
|
-
if not isinstance(existing, list):
|
|
127
|
-
existing = []
|
|
128
|
-
except (OSError, json.JSONDecodeError):
|
|
129
|
-
existing = []
|
|
130
|
-
replacements = {
|
|
131
|
-
str(item.get(id_key)): item for item in items if item.get(id_key) is not None
|
|
132
|
-
}
|
|
133
|
-
retained = []
|
|
134
|
-
seen = set()
|
|
135
|
-
for item in existing:
|
|
136
|
-
key = str(item.get(id_key)) if item.get(id_key) is not None else None
|
|
137
|
-
if key in replacements:
|
|
138
|
-
retained.append(replacements[key])
|
|
139
|
-
seen.add(key)
|
|
140
|
-
else:
|
|
141
|
-
retained.append(item)
|
|
142
|
-
items = retained + [
|
|
143
|
-
item for item in items
|
|
144
|
-
if item.get(id_key) is None or str(item.get(id_key)) not in seen
|
|
145
|
-
]
|
|
146
|
-
folder.mkdir(parents=True, exist_ok=True)
|
|
147
|
-
(folder / "index.json").write_text(
|
|
148
|
-
json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
# ── 各类型的 explode 逻辑 ──
|
|
153
|
-
|
|
154
|
-
def explode_pages(out_dir, items, preserve_existing=False):
|
|
155
|
-
index = []
|
|
156
|
-
for p in items:
|
|
157
|
-
pid = p.get("id", "x")
|
|
158
|
-
fname = f"page_{pid}_{safe_slug(p.get('route', pid))}.html"
|
|
159
|
-
html = (p.get("value") or {}).get("html", "")
|
|
160
|
-
(out_dir / fname).write_text(html, encoding="utf-8")
|
|
161
|
-
meta = {k: v for k, v in p.items() if k != "value"}
|
|
162
|
-
meta["html_file"] = f".draftgo/pages/{fname}"
|
|
163
|
-
index.append(meta)
|
|
164
|
-
write_index(out_dir, index, preserve_existing)
|
|
165
|
-
return len(index)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def explode_navigations(out_dir, items, preserve_existing=False):
|
|
169
|
-
index = []
|
|
170
|
-
for item in items:
|
|
171
|
-
iid = item.get("id", "x")
|
|
172
|
-
name_slug = safe_slug(item.get("code") or item.get("route") or iid)
|
|
173
|
-
fname = f"nav_{iid}_{name_slug}.html"
|
|
174
|
-
# 列表接口只返回导航元数据。详情拉取失败时不能以空内容覆盖本地导航。
|
|
175
|
-
if isinstance(item.get("html"), str):
|
|
176
|
-
(out_dir / fname).write_text(item["html"], encoding="utf-8")
|
|
177
|
-
else:
|
|
178
|
-
print(
|
|
179
|
-
f" WARN nav_id={iid} 未取得 HTML,保留本地文件: {fname}",
|
|
180
|
-
file=sys.stderr,
|
|
181
|
-
)
|
|
182
|
-
meta = {k: v for k, v in item.items() if k != "html"}
|
|
183
|
-
meta["html_file"] = f".draftgo/navigations/{fname}"
|
|
184
|
-
index.append(meta)
|
|
185
|
-
write_index(out_dir, index, preserve_existing)
|
|
186
|
-
return len(index)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def explode_docs(out_dir, items, preserve_existing=False):
|
|
190
|
-
index = []
|
|
191
|
-
for art in items:
|
|
192
|
-
aid = art.get("id", "x")
|
|
193
|
-
slug = safe_slug(art.get("slug") or art.get("title") or aid)
|
|
194
|
-
fname = f"article_{aid}_{slug}.html"
|
|
195
|
-
content = art.get("content") or ""
|
|
196
|
-
(out_dir / fname).write_text(content, encoding="utf-8")
|
|
197
|
-
meta = {k: v for k, v in art.items() if k != "content"}
|
|
198
|
-
meta["content_file"] = f".draftgo/docs/articles/{fname}"
|
|
199
|
-
index.append(meta)
|
|
200
|
-
write_index(out_dir, index, preserve_existing)
|
|
201
|
-
return len(index)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def explode_scripts(out_dir, items, preserve_existing=False):
|
|
205
|
-
index = []
|
|
206
|
-
for sc in items:
|
|
207
|
-
sid = sc.get("id", "x")
|
|
208
|
-
slug = safe_slug(sc.get("slug") or sc.get("name") or sid)
|
|
209
|
-
fname = f"script_{sid}_{slug}.go"
|
|
210
|
-
# 列表接口不含 code;详情拉取失败时绝不能用空内容覆盖受管源码。
|
|
211
|
-
if "code" in sc:
|
|
212
|
-
(out_dir / fname).write_text(sc["code"], encoding="utf-8")
|
|
213
|
-
else:
|
|
214
|
-
print(f" WARN script_id={sid} 未取得代码,保留本地文件: {fname}", file=sys.stderr)
|
|
215
|
-
meta = {k: v for k, v in sc.items() if k not in ("code", "language")}
|
|
216
|
-
meta["code_file"] = f".draftgo/custom_scripts/{fname}"
|
|
217
|
-
index.append(meta)
|
|
218
|
-
write_index(out_dir, index, preserve_existing)
|
|
219
|
-
return len(index)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
# ── 按类型拉取 ──
|
|
223
|
-
|
|
224
|
-
def select_items(items, ids, id_key, type_name):
|
|
225
|
-
"""筛选指定目标,并拒绝用空结果覆盖现有索引。"""
|
|
226
|
-
if not ids:
|
|
227
|
-
return items
|
|
228
|
-
wanted = [str(value) for value in ids]
|
|
229
|
-
found = {str(item.get(id_key)) for item in items if item.get(id_key) is not None}
|
|
230
|
-
missing = [value for value in wanted if value not in found]
|
|
231
|
-
if missing:
|
|
232
|
-
print(
|
|
233
|
-
f" ERR {type_name} 云端未返回目标 {id_key}: {', '.join(missing)}",
|
|
234
|
-
file=sys.stderr,
|
|
235
|
-
)
|
|
236
|
-
return None
|
|
237
|
-
return [item for item in items if str(item.get(id_key)) in wanted]
|
|
238
|
-
|
|
239
|
-
def pull_pages(server, token, root, ids=None):
|
|
240
|
-
raw = fetch(server, token, ENDPOINTS["pages"])
|
|
241
|
-
if raw is None:
|
|
242
|
-
return None
|
|
243
|
-
items = extract_items(raw)
|
|
244
|
-
items = select_items(items, ids, "id", "pages")
|
|
245
|
-
if items is None:
|
|
246
|
-
return None
|
|
247
|
-
if any(not isinstance(p.get("value"), dict) or not isinstance(p["value"].get("html"), str) for p in items):
|
|
248
|
-
print(" ERR pages 响应缺少 HTML,未写入本地缓存", file=sys.stderr)
|
|
249
|
-
return None
|
|
250
|
-
out = root / ".draftgo/pages"
|
|
251
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
252
|
-
count = explode_pages(out, items, preserve_existing=bool(ids))
|
|
253
|
-
print(f" OK pages ({count} items)")
|
|
254
|
-
return count
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def pull_navigations(server, token, root, ids=None):
|
|
258
|
-
raw = fetch(server, token, ENDPOINTS["navigations"])
|
|
259
|
-
if raw is None:
|
|
260
|
-
return None
|
|
261
|
-
items = extract_items(raw)
|
|
262
|
-
items = select_items(items, ids, "id", "navigations")
|
|
263
|
-
if items is None:
|
|
264
|
-
return None
|
|
265
|
-
hydrated = []
|
|
266
|
-
for item in items:
|
|
267
|
-
code = item.get("code")
|
|
268
|
-
if not code:
|
|
269
|
-
print(
|
|
270
|
-
f" ERR nav_id={item.get('id', '?')} 缺少 code,未写入本地缓存",
|
|
271
|
-
file=sys.stderr,
|
|
272
|
-
)
|
|
273
|
-
return None
|
|
274
|
-
detail = unwrap(fetch(
|
|
275
|
-
server, token, f"/api/navigations/{urllib.parse.quote(str(code), safe='')}"
|
|
276
|
-
))
|
|
277
|
-
if not isinstance(detail, dict) or not isinstance(detail.get("html"), str):
|
|
278
|
-
print(
|
|
279
|
-
f" ERR nav_id={item.get('id', '?')} 未取得 HTML,未写入本地缓存",
|
|
280
|
-
file=sys.stderr,
|
|
281
|
-
)
|
|
282
|
-
return None
|
|
283
|
-
hydrated.append({**item, **detail})
|
|
284
|
-
out = root / ".draftgo/navigations"
|
|
285
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
286
|
-
count = explode_navigations(out, hydrated, preserve_existing=bool(ids))
|
|
287
|
-
print(f" OK navigations ({count} items)")
|
|
288
|
-
return count
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def pull_simple(server, token, root, type_name, ids=None):
|
|
292
|
-
"""拉取仅需 index.json 的简单类型(db_meta, aihub, roles, users, system_config)。"""
|
|
293
|
-
raw = fetch(server, token, ENDPOINTS[type_name])
|
|
294
|
-
if raw is None:
|
|
295
|
-
return None
|
|
296
|
-
items = extract_items(raw)
|
|
297
|
-
id_key = "config_key" if type_name == "system_config" else "id"
|
|
298
|
-
items = select_items(items, ids, id_key, type_name)
|
|
299
|
-
if items is None:
|
|
300
|
-
return None
|
|
301
|
-
out = root / f".draftgo/{type_name}"
|
|
302
|
-
write_index(out, items, preserve_existing=bool(ids), id_key=id_key)
|
|
303
|
-
print(f" OK {type_name} ({len(items)} items)")
|
|
304
|
-
return len(items)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def pull_docs(server, token, root, ids=None):
|
|
308
|
-
raw = fetch(server, token, ENDPOINTS["docs"])
|
|
309
|
-
if raw is None:
|
|
310
|
-
return None
|
|
311
|
-
items = extract_items(raw)
|
|
312
|
-
items = select_items(items, ids, "id", "docs")
|
|
313
|
-
if items is None:
|
|
314
|
-
return None
|
|
315
|
-
hydrated = []
|
|
316
|
-
for it in items:
|
|
317
|
-
aid = it.get("id")
|
|
318
|
-
detail = unwrap(fetch(server, token, f"/api/docs/articles/{aid}")) if aid else None
|
|
319
|
-
if not isinstance(detail, dict) or not isinstance(detail.get("content"), str):
|
|
320
|
-
print(
|
|
321
|
-
f" ERR article_id={aid or '?'} 未取得正文,未写入本地缓存",
|
|
322
|
-
file=sys.stderr,
|
|
323
|
-
)
|
|
324
|
-
return None
|
|
325
|
-
hydrated.append({**it, **detail})
|
|
326
|
-
out = root / ".draftgo/docs/articles"
|
|
327
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
328
|
-
count = explode_docs(out, hydrated, preserve_existing=bool(ids))
|
|
329
|
-
print(f" OK docs ({count} articles)")
|
|
330
|
-
return count
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def pull_doc_categories(server, token, root, ids=None):
|
|
334
|
-
raw = fetch(server, token, ENDPOINTS["doc_categories"])
|
|
335
|
-
if raw is None:
|
|
336
|
-
return None
|
|
337
|
-
items = extract_items(raw)
|
|
338
|
-
items = select_items(items, ids, "id", "doc_categories")
|
|
339
|
-
if items is None:
|
|
340
|
-
return None
|
|
341
|
-
out = root / ".draftgo/doc_categories"
|
|
342
|
-
write_index(out, items, preserve_existing=bool(ids))
|
|
343
|
-
print(f" OK doc_categories ({len(items)} items)")
|
|
344
|
-
return len(items)
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def pull_custom_scripts(server, token, root, ids=None):
|
|
348
|
-
raw = fetch(server, token, ENDPOINTS["custom_scripts"])
|
|
349
|
-
if raw is None:
|
|
350
|
-
return None
|
|
351
|
-
items = extract_items(raw)
|
|
352
|
-
items = select_items(items, ids, "id", "custom_scripts")
|
|
353
|
-
if items is None:
|
|
354
|
-
return None
|
|
355
|
-
hydrated = []
|
|
356
|
-
for it in items:
|
|
357
|
-
script_id = it.get("id")
|
|
358
|
-
detail = unwrap(fetch(server, token, f"/api/scripts/{script_id}")) if script_id else None
|
|
359
|
-
if not isinstance(detail, dict) or not isinstance(detail.get("code"), str):
|
|
360
|
-
print(
|
|
361
|
-
f" ERR script_id={script_id or '?'} 未取得代码,未写入本地缓存",
|
|
362
|
-
file=sys.stderr,
|
|
363
|
-
)
|
|
364
|
-
return None
|
|
365
|
-
hydrated.append({**it, **detail})
|
|
366
|
-
out = root / ".draftgo/custom_scripts"
|
|
367
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
368
|
-
count = explode_scripts(out, hydrated, preserve_existing=bool(ids))
|
|
369
|
-
print(f" OK custom_scripts ({count} scripts, code from details)")
|
|
370
|
-
return count
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
# ── Handler 映射 ──
|
|
374
|
-
|
|
375
|
-
PULL_HANDLERS = {
|
|
376
|
-
"pages": pull_pages,
|
|
377
|
-
"nav": lambda s, t, r, ids=None: pull_navigations(s, t, r, ids),
|
|
378
|
-
"navigations": pull_navigations,
|
|
379
|
-
"db_meta": lambda s, t, r, ids=None: pull_simple(s, t, r, "db_meta", ids),
|
|
380
|
-
"aihub": lambda s, t, r, ids=None: pull_simple(s, t, r, "aihub", ids),
|
|
381
|
-
"roles": lambda s, t, r, ids=None: pull_simple(s, t, r, "roles", ids),
|
|
382
|
-
"users": lambda s, t, r, ids=None: pull_simple(s, t, r, "users", ids),
|
|
383
|
-
"system_config": lambda s, t, r, ids=None: pull_simple(s, t, r, "system_config", ids),
|
|
384
|
-
"docs": pull_docs,
|
|
385
|
-
"doc_categories": pull_doc_categories,
|
|
386
|
-
"custom_scripts": pull_custom_scripts,
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def pull_all(server, token, root):
|
|
391
|
-
"""拉取全部类型,供 init 复用。"""
|
|
392
|
-
results = [pull_pages(server, token, root), pull_navigations(server, token, root)]
|
|
393
|
-
for t in ("db_meta", "aihub", "roles", "users", "system_config"):
|
|
394
|
-
results.append(pull_simple(server, token, root, t))
|
|
395
|
-
results.extend([
|
|
396
|
-
pull_doc_categories(server, token, root),
|
|
397
|
-
pull_docs(server, token, root),
|
|
398
|
-
pull_custom_scripts(server, token, root),
|
|
399
|
-
])
|
|
400
|
-
return None if any(result is None for result in results) else results
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def main():
|
|
404
|
-
if len(sys.argv) < 2:
|
|
405
|
-
print(f"usage: draftgo_pull.py {{{'|'.join(PULL_HANDLERS)}}}|--all [id ...]")
|
|
406
|
-
sys.exit(1)
|
|
407
|
-
|
|
408
|
-
mode = sys.argv[1]
|
|
409
|
-
server, token = load_config()
|
|
410
|
-
root = DEFAULT_ROOT
|
|
411
|
-
|
|
412
|
-
if mode == "--all":
|
|
413
|
-
if pull_all(server, token, root) is None:
|
|
414
|
-
sys.exit(1)
|
|
415
|
-
return
|
|
416
|
-
|
|
417
|
-
if mode not in PULL_HANDLERS:
|
|
418
|
-
print(f"unknown type: {mode}\navailable: {', '.join(PULL_HANDLERS)} or --all")
|
|
419
|
-
sys.exit(1)
|
|
420
|
-
|
|
421
|
-
ids = sys.argv[2:] or None
|
|
422
|
-
if PULL_HANDLERS[mode](server, token, root, ids) is None:
|
|
423
|
-
sys.exit(1)
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
if __name__ == "__main__":
|
|
427
|
-
main()
|