yuque-cli 0.1.1__tar.gz → 1.0.0__tar.gz
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.
- yuque_cli-1.0.0/.claude/settings.json +15 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/PKG-INFO +3 -3
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/README.md +2 -2
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/pyproject.toml +1 -1
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/cli.py +169 -41
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/client.py +89 -4
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/output.py +42 -2
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/urls.py +21 -5
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_cli.py +85 -35
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_client.py +68 -1
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_urls.py +33 -9
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/uv.lock +1 -1
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.claude/settings.local.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.gitignore +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.python-version +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.vscode/extensions.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/.vscode/settings.json +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/AGENTS.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/CLAUDE.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/CONTEXT.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0001-use-internal-web-api-with-cookie-auth.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0003-doc-update-fetch-then-merge.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/internal-api.md +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/main.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/mise.toml +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/__init__.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/__main__.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/appdata.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/auth.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/config.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/errors.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/inputs.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/src/yuque_cli/session.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_appdata.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_auth.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_config.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_inputs.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_output.py +0 -0
- {yuque_cli-0.1.1 → yuque_cli-1.0.0}/test/test_session.py +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"Stop": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "EDIT|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "if git diff --name-only HEAD 2>/dev/null | grep -q '^src/\\|^pyproject.toml'; then current=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = \"\\(.*\\)\"/\\1/'); IFS='.' read -r major minor patch <<< \"$current\"; new=\"$major.$minor.$((patch + 1))\"; sed -i '' \"s/^version = \\\".*\\\"/version = \\\"$new\\\"/\" pyproject.toml && echo \"[版本升级] $current → $new (patch)\"; fi"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: yuque-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
4
4
|
Summary: 针对语雀的命令行客户端:cookie 认证,驱动内部 web 接口,提供文档与评论的 CRUD
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: httpx>=0.27
|
|
@@ -101,12 +101,12 @@ yuque -y doc delete team/handbook/old-draft
|
|
|
101
101
|
| 命令 | 说明 |
|
|
102
102
|
| --- | --- |
|
|
103
103
|
| `comment list <space>/<repo>/<slug>` | 列出评论 |
|
|
104
|
-
| `comment
|
|
104
|
+
| `comment create <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
|
|
105
105
|
| `comment delete <comment_id>` | 删除评论 |
|
|
106
106
|
|
|
107
107
|
## 正文输入
|
|
108
108
|
|
|
109
|
-
`doc create` / `comment
|
|
109
|
+
`doc create` / `comment create` 正文来源(`--body` 与 `--file` 互斥):
|
|
110
110
|
|
|
111
111
|
1. `-b/--body` 内联字符串
|
|
112
112
|
2. `-F/--file` 从文件读(`-F -` 读 stdin)
|
|
@@ -91,12 +91,12 @@ yuque -y doc delete team/handbook/old-draft
|
|
|
91
91
|
| 命令 | 说明 |
|
|
92
92
|
| --- | --- |
|
|
93
93
|
| `comment list <space>/<repo>/<slug>` | 列出评论 |
|
|
94
|
-
| `comment
|
|
94
|
+
| `comment create <space>/<repo>/<slug> [-b/--body \| -F/--file]` | 添加评论 |
|
|
95
95
|
| `comment delete <comment_id>` | 删除评论 |
|
|
96
96
|
|
|
97
97
|
## 正文输入
|
|
98
98
|
|
|
99
|
-
`doc create` / `comment
|
|
99
|
+
`doc create` / `comment create` 正文来源(`--body` 与 `--file` 互斥):
|
|
100
100
|
|
|
101
101
|
1. `-b/--body` 内联字符串
|
|
102
102
|
2. `-F/--file` 从文件读(`-F -` 读 stdin)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
3
5
|
import sys
|
|
6
|
+
import time
|
|
4
7
|
from contextlib import contextmanager
|
|
5
8
|
from dataclasses import dataclass
|
|
6
9
|
from typing import Annotated, Any, Callable, Optional
|
|
@@ -12,7 +15,7 @@ from . import __version__, auth, config, output, session
|
|
|
12
15
|
from .appdata import AppDataError
|
|
13
16
|
from .appdata import repo_id as appdata_repo_id
|
|
14
17
|
from .appdata import doc_id as appdata_doc_id
|
|
15
|
-
from .client import Client
|
|
18
|
+
from .client import Client, _extract_paragraphs
|
|
16
19
|
from .errors import AuthExpired, YuqueError
|
|
17
20
|
from .inputs import BodyInputError, resolve_body
|
|
18
21
|
from .session import SessionError
|
|
@@ -83,7 +86,7 @@ def cli_errors():
|
|
|
83
86
|
raise typer.Exit(1)
|
|
84
87
|
|
|
85
88
|
|
|
86
|
-
def build_client(state: AppState) -> Client:
|
|
89
|
+
def build_client(state: AppState, host_hint: str | None = None) -> Client:
|
|
87
90
|
sess = session.load(config.session_file())
|
|
88
91
|
hooks: dict[str, list[Callable[..., Any]]] = {}
|
|
89
92
|
if state.verbose:
|
|
@@ -96,11 +99,20 @@ def build_client(state: AppState) -> Client:
|
|
|
96
99
|
# trust_env=False:默认直连,绕开 *_proxy 环境变量(如本地 Clash 的 socks5 代理)——
|
|
97
100
|
# 避免请求被本地代理拦截,也免去 SOCKS 需 socksio 的崩溃。如需经代理访问,移除此参数并装 httpx[socks]。
|
|
98
101
|
http = httpx.Client(timeout=30.0, event_hooks=hooks, trust_env=False)
|
|
99
|
-
|
|
102
|
+
# --host 显式指定优先;否则从 URL 提取的 host 作为 fallback
|
|
103
|
+
return Client(http, sess, base_url=config.base_url(state.host or host_hint))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _pager(text: str) -> None:
|
|
107
|
+
"""当 stdout 是终端时通过 less 分页显示,否则直接输出。"""
|
|
108
|
+
if sys.stdout.isatty():
|
|
109
|
+
subprocess.run(["less", "-FRX"], input=text, text=True)
|
|
110
|
+
else:
|
|
111
|
+
typer.echo(text)
|
|
100
112
|
|
|
101
113
|
|
|
102
114
|
def _emit(state: AppState, data, human: str) -> None:
|
|
103
|
-
|
|
115
|
+
_pager(output.dumps(data) if state.json else human)
|
|
104
116
|
|
|
105
117
|
|
|
106
118
|
def _confirm(state: AppState, message: str) -> None:
|
|
@@ -125,8 +137,10 @@ def _resolve_repo_ref(
|
|
|
125
137
|
if not has_url and not has_opts:
|
|
126
138
|
raise YuqueError("请提供 URL 或指定 --space 和 --repo")
|
|
127
139
|
if has_url:
|
|
140
|
+
assert url is not None
|
|
128
141
|
return parse_repo_ref(url)
|
|
129
|
-
|
|
142
|
+
assert space is not None and repo is not None
|
|
143
|
+
return RepoRef(space=space, repo=repo)
|
|
130
144
|
|
|
131
145
|
|
|
132
146
|
def _resolve_doc_ref(
|
|
@@ -164,40 +178,100 @@ def _prompt_cookie() -> str:
|
|
|
164
178
|
return typer.prompt("cookie")
|
|
165
179
|
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
181
|
+
_CDP_URL = "chrome://inspect/#remote-debugging"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _open_cdp_hint() -> None:
|
|
185
|
+
"""在 Chrome 中打开远程调试说明页面。"""
|
|
186
|
+
import platform
|
|
187
|
+
if platform.system() == "Darwin":
|
|
188
|
+
subprocess.run(["open", "-a", "Google Chrome", _CDP_URL], check=False)
|
|
189
|
+
elif platform.system() == "Windows":
|
|
190
|
+
subprocess.run(["start", "", _CDP_URL], shell=True, check=False)
|
|
191
|
+
else:
|
|
192
|
+
subprocess.run(["google-chrome", _CDP_URL], check=False)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
_CDP_DONE_MSG = "建议及时关闭 Chrome 远程调试端口,避免安全风险"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _try_cdp_login(base: str, state: AppState) -> dict:
|
|
199
|
+
"""尝试 CDP 获取 cookie;失败后打开浏览器并持续轮询直至成功。"""
|
|
200
|
+
# 首次尝试:可能已经开启
|
|
201
|
+
ws_url = auth.cdp_websocket_url(verbose=state.verbose)
|
|
202
|
+
if ws_url:
|
|
203
|
+
try:
|
|
204
|
+
cookies = auth.cookies_via_cdp(base, ws_url)
|
|
205
|
+
except auth.CdpUnavailable:
|
|
206
|
+
pass
|
|
207
|
+
else:
|
|
208
|
+
host = state.host or config.host()
|
|
209
|
+
typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
|
|
210
|
+
typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
|
|
211
|
+
return cookies
|
|
212
|
+
|
|
213
|
+
# 打开浏览器引导开启远程调试
|
|
214
|
+
_open_cdp_hint()
|
|
215
|
+
typer.secho(
|
|
216
|
+
f"等待 Chrome 远程调试开启({_CDP_URL}),Ctrl+C 可中断...",
|
|
217
|
+
fg="yellow", err=True,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# 轮询直至成功
|
|
221
|
+
spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
222
|
+
tick = 0
|
|
223
|
+
try:
|
|
224
|
+
while True:
|
|
225
|
+
# 用 spinner 替代 sleep,视觉上不卡死
|
|
226
|
+
for _ in range(10):
|
|
227
|
+
char = spinner[tick % len(spinner)]
|
|
228
|
+
sys.stderr.write(f"\r {char} 等待 Chrome 远程调试...")
|
|
229
|
+
sys.stderr.flush()
|
|
230
|
+
time.sleep(0.2)
|
|
231
|
+
tick += 1
|
|
232
|
+
ws_url = auth.cdp_websocket_url(verbose=state.verbose)
|
|
233
|
+
if ws_url is None:
|
|
234
|
+
continue
|
|
235
|
+
try:
|
|
236
|
+
cookies = auth.cookies_via_cdp(base, ws_url)
|
|
237
|
+
sys.stderr.write("\r" + " " * 40 + "\r")
|
|
238
|
+
sys.stderr.flush()
|
|
239
|
+
host = state.host or config.host()
|
|
240
|
+
typer.secho(f"已通过 CDP 从浏览器获取 {host} 登录态", fg="green", err=True)
|
|
241
|
+
typer.secho(_CDP_DONE_MSG, fg="yellow", err=True)
|
|
242
|
+
return cookies
|
|
243
|
+
except auth.CdpUnavailable:
|
|
244
|
+
continue
|
|
245
|
+
except KeyboardInterrupt:
|
|
246
|
+
sys.stderr.write("\r" + " " * 40 + "\r")
|
|
247
|
+
sys.stderr.flush()
|
|
248
|
+
raise
|
|
174
249
|
|
|
175
250
|
|
|
176
251
|
@auth_app.command("login")
|
|
177
252
|
def auth_login(
|
|
178
253
|
ctx: typer.Context,
|
|
179
254
|
manual: Annotated[
|
|
180
|
-
bool, typer.Option("--manual", help="跳过 CDP
|
|
255
|
+
bool, typer.Option("--manual", help="跳过 CDP,交互式粘贴 cookie")
|
|
181
256
|
] = False,
|
|
257
|
+
cookie: Annotated[
|
|
258
|
+
Optional[str], typer.Option("--cookie", help="直接提供 cookie 串完成登录")
|
|
259
|
+
] = None,
|
|
182
260
|
) -> None:
|
|
183
|
-
"""登录:默认探测本地 CDP
|
|
261
|
+
"""登录:默认探测本地 CDP 自动获取登录态;也可用 --cookie 或 --manual 手动登录。"""
|
|
184
262
|
with cli_errors():
|
|
185
263
|
state: AppState = ctx.obj
|
|
186
264
|
base = config.base_url(state.host)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
typer.secho(_CDP_HINT, fg="yellow", err=True)
|
|
265
|
+
if cookie is not None:
|
|
266
|
+
cookies = auth.cookies_via_manual(lambda: cookie)
|
|
267
|
+
elif manual:
|
|
191
268
|
cookies = auth.cookies_via_manual(_prompt_cookie)
|
|
192
269
|
else:
|
|
193
270
|
try:
|
|
194
|
-
cookies =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
)
|
|
198
|
-
except auth.CdpUnavailable as exc:
|
|
199
|
-
typer.secho(f"CDP 已连接但获取失败:{exc}", fg="yellow", err=True)
|
|
200
|
-
cookies = auth.cookies_via_manual(_prompt_cookie)
|
|
271
|
+
cookies = _try_cdp_login(base, state)
|
|
272
|
+
except KeyboardInterrupt:
|
|
273
|
+
typer.echo("", err=True)
|
|
274
|
+
raise typer.Exit(1)
|
|
201
275
|
sess = session.Session(cookies)
|
|
202
276
|
sess.require_session()
|
|
203
277
|
session.save(sess, config.session_file())
|
|
@@ -236,6 +310,26 @@ def repo_list(ctx: typer.Context) -> None:
|
|
|
236
310
|
_emit(state, repos, output.render_repos(repos))
|
|
237
311
|
|
|
238
312
|
|
|
313
|
+
@repo_app.command("get")
|
|
314
|
+
def repo_get(
|
|
315
|
+
ctx: typer.Context,
|
|
316
|
+
url: UrlArg = None,
|
|
317
|
+
space: SpaceOpt = None,
|
|
318
|
+
repo: RepoOpt = None,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""获取指定知识库的信息(GET /api/books/{id}/overview)。"""
|
|
321
|
+
state: AppState = ctx.obj
|
|
322
|
+
with cli_errors():
|
|
323
|
+
ref = _resolve_repo_ref(url, space, repo)
|
|
324
|
+
client = build_client(state, host_hint=ref.host)
|
|
325
|
+
repo_id = client.resolve_repo_id(ref.space, ref.repo)
|
|
326
|
+
data = client.repo_overview(repo_id)
|
|
327
|
+
docs = client.repo_docs(repo_id)
|
|
328
|
+
if state.json:
|
|
329
|
+
data["docs"] = docs
|
|
330
|
+
_emit(state, data, output.render_repo_overview(data, docs))
|
|
331
|
+
|
|
332
|
+
|
|
239
333
|
# -- doc --------------------------------------------------------------------
|
|
240
334
|
|
|
241
335
|
UrlArg = Annotated[Optional[str], typer.Argument(help="目标 URL 或去 host 的路径形态")]
|
|
@@ -256,8 +350,9 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
|
|
|
256
350
|
"""列出知识库下的文档。"""
|
|
257
351
|
state: AppState = ctx.obj
|
|
258
352
|
with cli_errors():
|
|
353
|
+
assert url is not None
|
|
259
354
|
ref = parse_repo_ref(url)
|
|
260
|
-
client = build_client(state)
|
|
355
|
+
client = build_client(state, host_hint=ref.host)
|
|
261
356
|
repo_id = client.resolve_repo_id(ref.space, ref.repo)
|
|
262
357
|
docs = client.repo_docs(repo_id)
|
|
263
358
|
_emit(state, docs, output.render_docs(docs))
|
|
@@ -265,15 +360,28 @@ def doc_list(ctx: typer.Context, url: UrlArg) -> None:
|
|
|
265
360
|
|
|
266
361
|
@doc_app.command("get")
|
|
267
362
|
def doc_get(ctx: typer.Context, url: UrlArg) -> None:
|
|
268
|
-
"""取文档正文:默认干净 Markdown;--json
|
|
363
|
+
"""取文档正文:默认干净 Markdown;--json 出详情(含段落结构)。"""
|
|
269
364
|
state: AppState = ctx.obj
|
|
270
365
|
with cli_errors():
|
|
366
|
+
assert url is not None
|
|
271
367
|
ref = parse_doc_ref(url)
|
|
272
|
-
client = build_client(state)
|
|
368
|
+
client = build_client(state, host_hint=ref.host)
|
|
273
369
|
if state.json:
|
|
274
370
|
app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
|
|
275
371
|
detail = client.doc_detail(ref.slug, appdata_repo_id(app_data))
|
|
276
|
-
|
|
372
|
+
doc_meta = app_data.get("doc", {})
|
|
373
|
+
book_meta = app_data.get("book", {})
|
|
374
|
+
lake_body = detail.get("content") or detail.get("body_asl") or detail.get("body", "")
|
|
375
|
+
combined = {
|
|
376
|
+
"doc": detail,
|
|
377
|
+
"doc_version_id": doc_meta.get("doc_version_id"),
|
|
378
|
+
"book": {
|
|
379
|
+
"id": book_meta.get("id") or appdata_repo_id(app_data),
|
|
380
|
+
"slug": ref.repo,
|
|
381
|
+
},
|
|
382
|
+
"paragraphs": _extract_paragraphs(lake_body) if lake_body else [],
|
|
383
|
+
}
|
|
384
|
+
typer.echo(output.dumps(combined))
|
|
277
385
|
else:
|
|
278
386
|
typer.echo(client.doc_markdown(ref.space, ref.repo, ref.slug))
|
|
279
387
|
|
|
@@ -297,13 +405,13 @@ def doc_create(
|
|
|
297
405
|
raise YuqueError("--title 为必填项")
|
|
298
406
|
ref = _resolve_repo_ref(url, space, repo)
|
|
299
407
|
text = _read_body(body, file)
|
|
300
|
-
client = build_client(state)
|
|
408
|
+
client = build_client(state, host_hint=ref.host)
|
|
301
409
|
repo_id = client.resolve_repo_id(ref.space, ref.repo)
|
|
302
410
|
pub = None if public is None else (1 if public else 0)
|
|
303
411
|
created = client.create_doc(
|
|
304
412
|
repo_id=repo_id, title=title, body=text, slug=slug, public=pub
|
|
305
413
|
)
|
|
306
|
-
url_out = f"{config.base_url(state.host)}/{ref.space}/{ref.repo}/{created.get('slug', '')}"
|
|
414
|
+
url_out = f"{config.base_url(state.host or ref.host)}/{ref.space}/{ref.repo}/{created.get('slug', '')}"
|
|
307
415
|
_emit(state, created, f"已创建:{url_out}")
|
|
308
416
|
|
|
309
417
|
|
|
@@ -326,7 +434,7 @@ def doc_update(
|
|
|
326
434
|
"没有要更新的字段(至少给 --title / 正文 / --public|--private 之一)"
|
|
327
435
|
)
|
|
328
436
|
ref = _resolve_doc_ref(url, space, repo)
|
|
329
|
-
client = build_client(state)
|
|
437
|
+
client = build_client(state, host_hint=ref.host)
|
|
330
438
|
app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
|
|
331
439
|
doc_id = appdata_doc_id(app_data)
|
|
332
440
|
meta = app_data.get("doc", {})
|
|
@@ -339,7 +447,7 @@ def doc_update(
|
|
|
339
447
|
updated = client.update_doc(
|
|
340
448
|
doc_id=doc_id, title=new_title, body=new_body, public=new_public
|
|
341
449
|
)
|
|
342
|
-
url_out = f"{config.base_url(state.host)}/{ref.space}/{ref.repo}/{ref.slug}"
|
|
450
|
+
url_out = f"{config.base_url(state.host or ref.host)}/{ref.space}/{ref.repo}/{ref.slug}"
|
|
343
451
|
_emit(state, updated, f"已更新:{url_out}")
|
|
344
452
|
|
|
345
453
|
|
|
@@ -348,8 +456,9 @@ def doc_delete(ctx: typer.Context, url: UrlArg) -> None:
|
|
|
348
456
|
"""删除文档(软删,可在回收站恢复)。"""
|
|
349
457
|
state: AppState = ctx.obj
|
|
350
458
|
with cli_errors():
|
|
459
|
+
assert url is not None
|
|
351
460
|
ref = parse_doc_ref(url)
|
|
352
|
-
client = build_client(state)
|
|
461
|
+
client = build_client(state, host_hint=ref.host)
|
|
353
462
|
app_data = client.doc_page_app_data(ref.space, ref.repo, ref.slug)
|
|
354
463
|
doc_id = appdata_doc_id(app_data)
|
|
355
464
|
title = app_data.get("doc", {}).get("title", ref.slug)
|
|
@@ -366,27 +475,46 @@ def comment_list(ctx: typer.Context, url: UrlArg) -> None:
|
|
|
366
475
|
"""列出文档的评论。"""
|
|
367
476
|
state: AppState = ctx.obj
|
|
368
477
|
with cli_errors():
|
|
478
|
+
assert url is not None
|
|
369
479
|
ref = parse_doc_ref(url)
|
|
370
|
-
client = build_client(state)
|
|
480
|
+
client = build_client(state, host_hint=ref.host)
|
|
371
481
|
_, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
|
|
372
482
|
items = client.comments(doc_id)
|
|
373
483
|
_emit(state, items, output.render_comments(items))
|
|
374
484
|
|
|
375
485
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
486
|
+
SelectionOpt = Annotated[
|
|
487
|
+
Optional[str],
|
|
488
|
+
typer.Option("--selection", "-s", help="划词评论的 selection JSON(如提供则为划词评论)"),
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@comment_app.command("create")
|
|
493
|
+
def comment_create(
|
|
494
|
+
ctx: typer.Context,
|
|
495
|
+
url: UrlArg,
|
|
496
|
+
body: BodyOpt = None,
|
|
497
|
+
file: FileOpt = None,
|
|
498
|
+
selection: SelectionOpt = None,
|
|
379
499
|
) -> None:
|
|
380
|
-
"""
|
|
500
|
+
"""给文档添加评论。带 --selection 时为划词评论。"""
|
|
381
501
|
state: AppState = ctx.obj
|
|
382
502
|
with cli_errors():
|
|
503
|
+
assert url is not None
|
|
383
504
|
ref = parse_doc_ref(url)
|
|
384
505
|
text = _read_body(body, file)
|
|
385
506
|
if not text:
|
|
386
507
|
raise YuqueError("评论正文不能为空(用 --body 或 --file/-)")
|
|
387
|
-
client = build_client(state)
|
|
508
|
+
client = build_client(state, host_hint=ref.host)
|
|
388
509
|
_, doc_id = client.resolve_doc_ids(ref.space, ref.repo, ref.slug)
|
|
389
|
-
|
|
510
|
+
if selection is not None:
|
|
511
|
+
try:
|
|
512
|
+
sel = json.loads(selection)
|
|
513
|
+
except json.JSONDecodeError as exc:
|
|
514
|
+
raise YuqueError(f"--selection JSON 解析失败:{exc}") from exc
|
|
515
|
+
created = client.create_comment(doc_id=doc_id, body=text, selection=sel)
|
|
516
|
+
else:
|
|
517
|
+
created = client.create_comment(doc_id=doc_id, body=text)
|
|
390
518
|
_emit(state, created, f"已评论(id={created.get('id', '')})")
|
|
391
519
|
|
|
392
520
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import secrets
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
import httpx
|
|
@@ -14,6 +15,63 @@ USER_AGENT = (
|
|
|
14
15
|
)
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def _random_id() -> str:
|
|
19
|
+
return "u" + secrets.token_hex(4)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _random_hash() -> str:
|
|
23
|
+
return secrets.token_hex(32)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_body_asl(text: str) -> tuple[str, str, str]:
|
|
27
|
+
pid = _random_id()
|
|
28
|
+
sid = _random_id()
|
|
29
|
+
h = _random_hash()
|
|
30
|
+
body_asl = (
|
|
31
|
+
'<!doctype lake>'
|
|
32
|
+
'<meta name="doc-version" content="1" />'
|
|
33
|
+
'<meta name="viewport" content="adapt" />'
|
|
34
|
+
f'<p data-lake-id="{pid}" id="{pid}">'
|
|
35
|
+
f'<span data-lake-id="{sid}" id="{sid}">{text}</span>'
|
|
36
|
+
f'</p>'
|
|
37
|
+
f'<!{h}>'
|
|
38
|
+
)
|
|
39
|
+
return body_asl, pid, sid
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _build_body(text: str, pid: str, sid: str) -> str:
|
|
43
|
+
return (
|
|
44
|
+
'<div class="lake-content" typography="traditional">'
|
|
45
|
+
f'<p id="{pid}" class="ne-p">'
|
|
46
|
+
f'<span class="ne-text">{text}</span>'
|
|
47
|
+
f'</p>'
|
|
48
|
+
f'</div>'
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _html_to_text(html: str) -> str:
|
|
53
|
+
import re
|
|
54
|
+
|
|
55
|
+
text = re.sub(r"<[^>]+>", "", html or "")
|
|
56
|
+
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
|
57
|
+
return text
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _extract_paragraphs(html: str) -> list[dict]:
|
|
61
|
+
import re
|
|
62
|
+
|
|
63
|
+
paras: list[dict] = []
|
|
64
|
+
for pm in re.finditer(r'<p[^>]*\s+id="([^"]*)"[^>]*>(.*?)</p>', html, re.DOTALL):
|
|
65
|
+
pid = pm.group(1)
|
|
66
|
+
raw = pm.group(2)
|
|
67
|
+
text = _html_to_text(raw)
|
|
68
|
+
spans: list[dict] = []
|
|
69
|
+
for sm in re.finditer(r'<span[^>]*\s+id="([^"]*)"[^>]*>(.*?)</span>', raw, re.DOTALL):
|
|
70
|
+
spans.append({"id": sm.group(1), "text": _html_to_text(sm.group(2))})
|
|
71
|
+
paras.append({"id": pid, "text": text, "spans": spans})
|
|
72
|
+
return paras
|
|
73
|
+
|
|
74
|
+
|
|
17
75
|
class Client:
|
|
18
76
|
"""语雀内部 web API 客户端。注入 httpx.Client 与 Session 以便测试。"""
|
|
19
77
|
|
|
@@ -92,11 +150,27 @@ class Client:
|
|
|
92
150
|
"""用户所属的组织/团队列表(GET /api/mine/group_quick_links)。"""
|
|
93
151
|
return self._data(self._request("GET", f"{self.base_url}/api/mine/group_quick_links"))
|
|
94
152
|
|
|
95
|
-
def
|
|
153
|
+
def repo_overview(self, repo_id: int) -> dict:
|
|
96
154
|
return self._data(
|
|
97
|
-
self._request("GET", f"{self.base_url}/api/books/{repo_id}/
|
|
155
|
+
self._request("GET", f"{self.base_url}/api/books/{repo_id}/overview")
|
|
98
156
|
)
|
|
99
157
|
|
|
158
|
+
def repo_docs(self, repo_id: int) -> list[dict]:
|
|
159
|
+
all_docs: list[dict] = []
|
|
160
|
+
offset = 0
|
|
161
|
+
while True:
|
|
162
|
+
page = self._data(
|
|
163
|
+
self._request(
|
|
164
|
+
"GET", f"{self.base_url}/api/docs",
|
|
165
|
+
params={"book_id": repo_id, "offset": offset}
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if not page:
|
|
169
|
+
break
|
|
170
|
+
all_docs.extend(page)
|
|
171
|
+
offset += len(page)
|
|
172
|
+
return all_docs
|
|
173
|
+
|
|
100
174
|
# -- 文档 ---------------------------------------------------------------
|
|
101
175
|
|
|
102
176
|
def doc_markdown(self, space: str, repo: str, slug: str) -> str:
|
|
@@ -173,8 +247,19 @@ class Client:
|
|
|
173
247
|
)
|
|
174
248
|
)
|
|
175
249
|
|
|
176
|
-
def create_comment(self, *, doc_id: int, body: str) -> dict:
|
|
177
|
-
payload
|
|
250
|
+
def create_comment(self, *, doc_id: int, body: str, selection: dict | None = None) -> dict:
|
|
251
|
+
payload: dict[str, Any] = {
|
|
252
|
+
"commentable_id": doc_id,
|
|
253
|
+
"commentable_type": "Doc",
|
|
254
|
+
"body": body,
|
|
255
|
+
}
|
|
256
|
+
if selection is not None:
|
|
257
|
+
body_asl, pid, sid = _build_body_asl(body)
|
|
258
|
+
payload["type"] = "WORDING_COMMENT"
|
|
259
|
+
payload["format"] = "lake"
|
|
260
|
+
payload["body_asl"] = body_asl
|
|
261
|
+
payload["body"] = _build_body(body, pid, sid)
|
|
262
|
+
payload["selection"] = selection
|
|
178
263
|
return self._data(
|
|
179
264
|
self._request(
|
|
180
265
|
"POST", f"{self.base_url}/api/comments", json_body=payload, write=True
|
|
@@ -63,16 +63,56 @@ def render_docs(docs: list[dict]) -> str:
|
|
|
63
63
|
return table(["TITLE", "SLUG", "ID"], rows)
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def _strip_html(html: str) -> str:
|
|
67
|
+
"""提取 HTML 中的纯文本。"""
|
|
68
|
+
import re
|
|
69
|
+
|
|
70
|
+
text = re.sub(r"<[^>]+>", "", html or "")
|
|
71
|
+
text = text.replace("<", "<").replace(">", ">").replace("&", "&")
|
|
72
|
+
return text.strip()
|
|
73
|
+
|
|
74
|
+
|
|
66
75
|
def render_comments(comments: list[dict]) -> str:
|
|
67
76
|
rows = [
|
|
68
77
|
[
|
|
69
78
|
str(c.get("id", "")),
|
|
70
79
|
str((c.get("user") or {}).get("name", "")),
|
|
71
|
-
|
|
80
|
+
"划词" if c.get("type") == "WORDING_COMMENT" else "正文",
|
|
81
|
+
_first_line(
|
|
82
|
+
_strip_html(c.get("body", ""))
|
|
83
|
+
if c.get("type") == "WORDING_COMMENT"
|
|
84
|
+
else (c.get("body", "") or "")
|
|
85
|
+
),
|
|
72
86
|
]
|
|
73
87
|
for c in comments
|
|
74
88
|
]
|
|
75
|
-
return table(["ID", "AUTHOR", "BODY"], rows)
|
|
89
|
+
return table(["ID", "AUTHOR", "TYPE", "BODY"], rows)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_repo_overview(data: dict, docs: list[dict] | None = None) -> str:
|
|
93
|
+
"""通用渲染:按 key 逐行展示,遇到 dict 取其 name/login/id,其它值直接展示。"""
|
|
94
|
+
_SKIP_KEYS = {"enableCustomBody", "enableCatalog", "order", "layout", "customIndex", "enableUserFeed"}
|
|
95
|
+
lines: list[str] = []
|
|
96
|
+
for key, val in data.items():
|
|
97
|
+
if key in _SKIP_KEYS:
|
|
98
|
+
continue
|
|
99
|
+
if isinstance(val, dict):
|
|
100
|
+
val = val.get("name") or val.get("login") or val.get("id") or json.dumps(val, ensure_ascii=False)
|
|
101
|
+
elif isinstance(val, list):
|
|
102
|
+
if val and isinstance(val[0], dict):
|
|
103
|
+
val = ", ".join(v.get("name") or str(v) for v in val)
|
|
104
|
+
else:
|
|
105
|
+
val = ", ".join(str(v) for v in val)
|
|
106
|
+
elif isinstance(val, bool):
|
|
107
|
+
val = "yes" if val else "no"
|
|
108
|
+
elif val is None:
|
|
109
|
+
val = ""
|
|
110
|
+
lines.append(f"{key}: {val}")
|
|
111
|
+
if docs:
|
|
112
|
+
lines.append("")
|
|
113
|
+
lines.append(f"文档数量: {len(docs)}")
|
|
114
|
+
lines.append(render_docs(docs))
|
|
115
|
+
return "\n".join(lines)
|
|
76
116
|
|
|
77
117
|
|
|
78
118
|
def render_spaces(spaces: list[dict]) -> str:
|
|
@@ -12,6 +12,7 @@ class UrlParseError(ValueError):
|
|
|
12
12
|
class RepoRef:
|
|
13
13
|
space: str
|
|
14
14
|
repo: str
|
|
15
|
+
host: str | None = None
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
@dataclass(frozen=True)
|
|
@@ -19,10 +20,11 @@ class DocRef:
|
|
|
19
20
|
space: str
|
|
20
21
|
repo: str
|
|
21
22
|
slug: str
|
|
23
|
+
host: str | None = None
|
|
22
24
|
|
|
23
25
|
@property
|
|
24
26
|
def repo_ref(self) -> RepoRef:
|
|
25
|
-
return RepoRef(self.space, self.repo)
|
|
27
|
+
return RepoRef(self.space, self.repo, host=self.host)
|
|
26
28
|
|
|
27
29
|
|
|
28
30
|
def _segments(raw: str) -> list[str]:
|
|
@@ -42,14 +44,28 @@ def _segments(raw: str) -> list[str]:
|
|
|
42
44
|
return [seg for seg in path.split("/") if seg]
|
|
43
45
|
|
|
44
46
|
|
|
47
|
+
def extract_host(raw: str) -> str | None:
|
|
48
|
+
"""从完整 URL 或带 host 的路径中提取 host;无法识别时返回 None。"""
|
|
49
|
+
s = raw.strip()
|
|
50
|
+
if not s:
|
|
51
|
+
return None
|
|
52
|
+
if "://" in s:
|
|
53
|
+
return urlsplit(s).hostname
|
|
54
|
+
# 无 scheme:首段含 "." 即视为 host
|
|
55
|
+
parts = s.split("/")
|
|
56
|
+
if parts and "." in parts[0]:
|
|
57
|
+
return parts[0]
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
45
61
|
def parse_doc_ref(raw: str) -> DocRef:
|
|
46
62
|
segs = _segments(raw)
|
|
47
|
-
if len(segs)
|
|
63
|
+
if len(segs) < 3:
|
|
48
64
|
raise UrlParseError(
|
|
49
65
|
f"文档地址应形如 {{space}}/{{repo}}/{{slug}}(三段),得到:{raw!r}"
|
|
50
66
|
)
|
|
51
|
-
space, repo, slug = segs
|
|
52
|
-
return DocRef(space=space, repo=repo, slug=slug)
|
|
67
|
+
space, repo, slug = segs[:3]
|
|
68
|
+
return DocRef(space=space, repo=repo, slug=slug, host=extract_host(raw))
|
|
53
69
|
|
|
54
70
|
|
|
55
71
|
def parse_repo_ref(raw: str) -> RepoRef:
|
|
@@ -60,4 +76,4 @@ def parse_repo_ref(raw: str) -> RepoRef:
|
|
|
60
76
|
)
|
|
61
77
|
if len(segs) > 3:
|
|
62
78
|
raise UrlParseError(f"地址段数过多,无法识别为知识库:{raw!r}")
|
|
63
|
-
return RepoRef(space=segs[0], repo=segs[1])
|
|
79
|
+
return RepoRef(space=segs[0], repo=segs[1], host=extract_host(raw))
|
|
@@ -32,14 +32,14 @@ class FakeClient:
|
|
|
32
32
|
self._rec("groups")
|
|
33
33
|
return [
|
|
34
34
|
{
|
|
35
|
-
"id":
|
|
36
|
-
"title": "
|
|
37
|
-
"target": {"id":
|
|
35
|
+
"id": 100001,
|
|
36
|
+
"title": "Team A",
|
|
37
|
+
"target": {"id": 200001, "login": "user_a", "name": "Team A"},
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
|
-
"id":
|
|
41
|
-
"title": "
|
|
42
|
-
"target": {"id":
|
|
40
|
+
"id": 100002,
|
|
41
|
+
"title": "Project B",
|
|
42
|
+
"target": {"id": 200002, "login": "user_b", "name": "Project B"},
|
|
43
43
|
},
|
|
44
44
|
]
|
|
45
45
|
|
|
@@ -47,6 +47,10 @@ class FakeClient:
|
|
|
47
47
|
self._rec("resolve_repo_id", space, repo)
|
|
48
48
|
return 77
|
|
49
49
|
|
|
50
|
+
def repo_overview(self, repo_id):
|
|
51
|
+
self._rec("repo_overview", repo_id)
|
|
52
|
+
return {"name": "B", "slug": "bk", "id": 77, "description": "desc", "public": 1, "items_count": 2}
|
|
53
|
+
|
|
50
54
|
def repo_docs(self, repo_id):
|
|
51
55
|
self._rec("repo_docs", repo_id)
|
|
52
56
|
return [{"title": "D", "slug": "d", "id": 88}]
|
|
@@ -95,7 +99,7 @@ class FakeClient:
|
|
|
95
99
|
@pytest.fixture
|
|
96
100
|
def fake(monkeypatch):
|
|
97
101
|
fc = FakeClient()
|
|
98
|
-
monkeypatch.setattr(cli, "build_client", lambda state: fc)
|
|
102
|
+
monkeypatch.setattr(cli, "build_client", lambda state, host_hint=None: fc)
|
|
99
103
|
return fc
|
|
100
104
|
|
|
101
105
|
|
|
@@ -158,29 +162,18 @@ class TestAuth:
|
|
|
158
162
|
assert "CDP" in result.output
|
|
159
163
|
assert (tmp_path / "session.json").exists()
|
|
160
164
|
|
|
161
|
-
def
|
|
165
|
+
def test_login_with_cookie_option(self, monkeypatch, tmp_path):
|
|
162
166
|
monkeypatch.setenv("YUQUE_CONFIG_DIR", str(tmp_path))
|
|
163
|
-
monkeypatch.setattr(cli.auth, "cdp_websocket_url", lambda **kw: None)
|
|
164
|
-
monkeypatch.setattr(cli, "_prompt_cookie", lambda: f"{SESSION_COOKIE}=x")
|
|
165
|
-
result = runner.invoke(cli.app, ["auth", "login"])
|
|
166
|
-
assert result.exit_code == 0
|
|
167
|
-
assert "--remote-debugging-port" in result.output
|
|
168
|
-
assert (tmp_path / "session.json").exists()
|
|
169
167
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
):
|
|
173
|
-
monkeypatch.setenv("YUQUE_CONFIG_DIR", str(tmp_path))
|
|
174
|
-
monkeypatch.setattr(cli.auth, "cdp_websocket_url", lambda **kw: "ws://b")
|
|
175
|
-
|
|
176
|
-
def boom(base, ws):
|
|
177
|
-
raise cli.auth.CdpUnavailable("无目标站点登录态")
|
|
168
|
+
def no_prompt(): # pragma: no cover - must not be called
|
|
169
|
+
raise AssertionError("should not prompt when --cookie is used")
|
|
178
170
|
|
|
179
|
-
monkeypatch.setattr(cli
|
|
180
|
-
|
|
181
|
-
|
|
171
|
+
monkeypatch.setattr(cli, "_prompt_cookie", no_prompt)
|
|
172
|
+
result = runner.invoke(
|
|
173
|
+
cli.app, ["auth", "login", "--cookie", f"{SESSION_COOKIE}=x; ct=t"]
|
|
174
|
+
)
|
|
182
175
|
assert result.exit_code == 0
|
|
183
|
-
assert "
|
|
176
|
+
assert "已登录" in result.output
|
|
184
177
|
assert (tmp_path / "session.json").exists()
|
|
185
178
|
|
|
186
179
|
def test_logout_removes_session(self, monkeypatch, tmp_path):
|
|
@@ -206,6 +199,33 @@ class TestRepo:
|
|
|
206
199
|
result = runner.invoke(cli.app, ["--json", "repo", "list"])
|
|
207
200
|
assert '"slug": "bk"' in result.output
|
|
208
201
|
|
|
202
|
+
def test_get_human(self, fake):
|
|
203
|
+
result = runner.invoke(cli.app, ["repo", "get", "lg/bk"])
|
|
204
|
+
assert result.exit_code == 0
|
|
205
|
+
assert "name: B" in result.output
|
|
206
|
+
assert "slug: bk" in result.output
|
|
207
|
+
assert "D" in result.output
|
|
208
|
+
assert calls(fake, "resolve_repo_id") == [("resolve_repo_id", "lg", "bk")]
|
|
209
|
+
assert calls(fake, "repo_overview") == [("repo_overview", 77)]
|
|
210
|
+
assert calls(fake, "repo_docs") == [("repo_docs", 77)]
|
|
211
|
+
|
|
212
|
+
def test_get_json(self, fake):
|
|
213
|
+
result = runner.invoke(cli.app, ["--json", "repo", "get", "lg/bk"])
|
|
214
|
+
assert result.exit_code == 0
|
|
215
|
+
assert '"name": "B"' in result.output
|
|
216
|
+
assert '"slug": "bk"' in result.output
|
|
217
|
+
assert '"docs"' in result.output
|
|
218
|
+
assert '"title": "D"' in result.output
|
|
219
|
+
|
|
220
|
+
def test_get_with_space_book_opts(self, fake):
|
|
221
|
+
result = runner.invoke(cli.app, ["repo", "get", "--space", "lg", "--repo", "bk"])
|
|
222
|
+
assert result.exit_code == 0
|
|
223
|
+
assert "name: B" in result.output
|
|
224
|
+
assert "D" in result.output
|
|
225
|
+
assert calls(fake, "resolve_repo_id") == [("resolve_repo_id", "lg", "bk")]
|
|
226
|
+
assert calls(fake, "repo_overview") == [("repo_overview", 77)]
|
|
227
|
+
assert calls(fake, "repo_docs") == [("repo_docs", 77)]
|
|
228
|
+
|
|
209
229
|
|
|
210
230
|
class TestDoc:
|
|
211
231
|
def test_list(self, fake):
|
|
@@ -222,6 +242,9 @@ class TestDoc:
|
|
|
222
242
|
def test_get_json_detail(self, fake):
|
|
223
243
|
result = runner.invoke(cli.app, ["--json", "doc", "get", "lg/bk/sl"])
|
|
224
244
|
assert '"body": "<lake>"' in result.output
|
|
245
|
+
assert '"doc_version_id"' in result.output
|
|
246
|
+
assert '"book"' in result.output
|
|
247
|
+
assert '"paragraphs"' in result.output
|
|
225
248
|
assert calls(fake, "doc_detail") == [("doc_detail", "sl", 77)]
|
|
226
249
|
|
|
227
250
|
def test_create_inline_body(self, fake):
|
|
@@ -351,16 +374,43 @@ class TestComment:
|
|
|
351
374
|
|
|
352
375
|
def test_add(self, fake):
|
|
353
376
|
result = runner.invoke(
|
|
354
|
-
cli.app, ["comment", "
|
|
377
|
+
cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice"]
|
|
355
378
|
)
|
|
356
379
|
assert "已评论" in result.output
|
|
357
380
|
assert calls(fake, "create_comment")[0][1] == {"doc_id": 88, "body": "nice"}
|
|
358
381
|
|
|
359
382
|
def test_add_empty_body_errors(self, fake):
|
|
360
|
-
result = runner.invoke(cli.app, ["comment", "
|
|
383
|
+
result = runner.invoke(cli.app, ["comment", "create", "lg/bk/sl"])
|
|
361
384
|
assert result.exit_code == 1
|
|
362
385
|
assert "评论正文不能为空" in result.output
|
|
363
386
|
|
|
387
|
+
def test_add_with_selection(self, fake):
|
|
388
|
+
sel = '{"paragraph_id":"u-pid","text":"hello","text_offset":0,"selection_range":{"start":{"id":"u-eid","text":"hello","offset":0,"paragraphId":"u-pid","paragraphOffset":0},"end":{"id":"u-eid","text":"hello","offset":3,"paragraphId":"u-pid","paragraphOffset":3}},"doc_version_id":123}'
|
|
389
|
+
result = runner.invoke(
|
|
390
|
+
cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice", "--selection", sel]
|
|
391
|
+
)
|
|
392
|
+
assert "已评论" in result.output
|
|
393
|
+
kw = calls(fake, "create_comment")[0][1]
|
|
394
|
+
assert kw["doc_id"] == 88
|
|
395
|
+
assert kw["body"] == "nice"
|
|
396
|
+
assert kw["selection"] == {
|
|
397
|
+
"paragraph_id": "u-pid",
|
|
398
|
+
"text": "hello",
|
|
399
|
+
"text_offset": 0,
|
|
400
|
+
"selection_range": {
|
|
401
|
+
"start": {"id": "u-eid", "text": "hello", "offset": 0, "paragraphId": "u-pid", "paragraphOffset": 0},
|
|
402
|
+
"end": {"id": "u-eid", "text": "hello", "offset": 3, "paragraphId": "u-pid", "paragraphOffset": 3},
|
|
403
|
+
},
|
|
404
|
+
"doc_version_id": 123,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
def test_add_with_selection_invalid_json_errors(self, fake):
|
|
408
|
+
result = runner.invoke(
|
|
409
|
+
cli.app, ["comment", "create", "lg/bk/sl", "--body", "nice", "--selection", "not json"]
|
|
410
|
+
)
|
|
411
|
+
assert result.exit_code == 1
|
|
412
|
+
assert "--selection JSON 解析失败" in result.output
|
|
413
|
+
|
|
364
414
|
def test_delete_with_yes(self, fake):
|
|
365
415
|
result = runner.invoke(cli.app, ["--yes", "comment", "delete", "5"])
|
|
366
416
|
assert "已删除评论" in result.output
|
|
@@ -373,16 +423,16 @@ class TestSpace:
|
|
|
373
423
|
assert result.exit_code == 0
|
|
374
424
|
assert "Me" in result.output
|
|
375
425
|
assert "me" in result.output
|
|
376
|
-
assert "
|
|
377
|
-
assert "
|
|
378
|
-
assert "
|
|
379
|
-
assert "
|
|
426
|
+
assert "Team A" in result.output
|
|
427
|
+
assert "Project B" in result.output
|
|
428
|
+
assert "user_a" in result.output
|
|
429
|
+
assert "user_b" in result.output
|
|
380
430
|
|
|
381
431
|
def test_list_json(self, fake):
|
|
382
432
|
result = runner.invoke(cli.app, ["--json", "space", "list"])
|
|
383
433
|
assert result.exit_code == 0
|
|
384
434
|
assert '"login": "me"' in result.output
|
|
385
|
-
assert '"login": "
|
|
386
|
-
assert '"login": "
|
|
435
|
+
assert '"login": "user_a"' in result.output
|
|
436
|
+
assert '"login": "user_b"' in result.output
|
|
387
437
|
assert '"id": 1' in result.output
|
|
388
|
-
assert '"id":
|
|
438
|
+
assert '"id": 200001' in result.output
|
|
@@ -6,7 +6,7 @@ from urllib.parse import quote
|
|
|
6
6
|
import httpx
|
|
7
7
|
import pytest
|
|
8
8
|
|
|
9
|
-
from yuque_cli.client import USER_AGENT, Client
|
|
9
|
+
from yuque_cli.client import USER_AGENT, Client, _extract_paragraphs
|
|
10
10
|
from yuque_cli.errors import ApiError, AuthExpired
|
|
11
11
|
from yuque_cli.session import CSRF_COOKIE, SESSION_COOKIE, Session
|
|
12
12
|
|
|
@@ -150,6 +150,40 @@ class TestWriteEndpoints:
|
|
|
150
150
|
make_client(handler).create_comment(doc_id=7, body="nice")
|
|
151
151
|
assert seen["body"] == {"commentable_id": 7, "commentable_type": "Doc", "body": "nice"}
|
|
152
152
|
|
|
153
|
+
def test_create_comment_with_selection(self):
|
|
154
|
+
seen = {}
|
|
155
|
+
|
|
156
|
+
def handler(req: httpx.Request) -> httpx.Response:
|
|
157
|
+
seen["body"] = json.loads(req.content)
|
|
158
|
+
return httpx.Response(200, json={"data": {"id": 11}})
|
|
159
|
+
|
|
160
|
+
sel = {
|
|
161
|
+
"paragraph_id": "u-pid",
|
|
162
|
+
"text_offset": 0,
|
|
163
|
+
"text": "hello",
|
|
164
|
+
"selection_range": {
|
|
165
|
+
"start": {"id": "u-eid", "text": "hello", "offset": 0, "paragraphId": "u-pid", "paragraphOffset": 0},
|
|
166
|
+
"end": {"id": "u-eid", "text": "hello", "offset": 3, "paragraphId": "u-pid", "paragraphOffset": 3},
|
|
167
|
+
},
|
|
168
|
+
"doc_version_id": 123,
|
|
169
|
+
}
|
|
170
|
+
make_client(handler).create_comment(doc_id=7, body="nice", selection=sel)
|
|
171
|
+
|
|
172
|
+
payload = seen["body"]
|
|
173
|
+
assert payload["commentable_id"] == 7
|
|
174
|
+
assert payload["commentable_type"] == "Doc"
|
|
175
|
+
assert payload["type"] == "WORDING_COMMENT"
|
|
176
|
+
assert payload["format"] == "lake"
|
|
177
|
+
assert payload["selection"] == sel
|
|
178
|
+
assert "body_asl" in payload
|
|
179
|
+
assert "<!doctype lake>" in payload["body_asl"]
|
|
180
|
+
assert "nice" in payload["body_asl"]
|
|
181
|
+
assert '<p data-lake-id="' in payload["body_asl"]
|
|
182
|
+
assert '<span data-lake-id="' in payload["body_asl"]
|
|
183
|
+
assert payload["body"] != "nice" # body is HTML not plain text
|
|
184
|
+
assert '<div class="lake-content"' in payload["body"]
|
|
185
|
+
assert "nice" in payload["body"]
|
|
186
|
+
|
|
153
187
|
def test_delete_comment(self):
|
|
154
188
|
def handler(req: httpx.Request) -> httpx.Response:
|
|
155
189
|
assert req.method == "DELETE"
|
|
@@ -177,3 +211,36 @@ class TestIdResolution:
|
|
|
177
211
|
return httpx.Response(200, text=html)
|
|
178
212
|
|
|
179
213
|
assert make_client(handler).resolve_doc_ids("lg", "bk", "sl") == (77, 88)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TestExtractParagraphs:
|
|
217
|
+
def test_extracts_ids_and_text(self):
|
|
218
|
+
html = '<div class="lake-content"><p id="u73ba16c1" class="ne-p"><span class="ne-text">发布时发布后</span></p></div>'
|
|
219
|
+
paras = _extract_paragraphs(html)
|
|
220
|
+
assert paras == [{"id": "u73ba16c1", "text": "发布时发布后", "spans": []}]
|
|
221
|
+
|
|
222
|
+
def test_extracts_spans_with_ids(self):
|
|
223
|
+
html = '<p id="p1"><span data-lake-id="s1" id="s1">hello</span></p>'
|
|
224
|
+
paras = _extract_paragraphs(html)
|
|
225
|
+
assert paras == [{"id": "p1", "text": "hello", "spans": [{"id": "s1", "text": "hello"}]}]
|
|
226
|
+
|
|
227
|
+
def test_skips_spans_without_ids(self):
|
|
228
|
+
html = '<p id="p1"><span style="color:red">no id span</span></p>'
|
|
229
|
+
paras = _extract_paragraphs(html)
|
|
230
|
+
assert paras[0]["spans"] == []
|
|
231
|
+
|
|
232
|
+
def test_multiple_paragraphs(self):
|
|
233
|
+
html = '<p id="a1">第一段<crap/></p>中间文本<p id="b2">二段</p>'
|
|
234
|
+
paras = _extract_paragraphs(html)
|
|
235
|
+
assert paras == [
|
|
236
|
+
{"id": "a1", "text": "第一段", "spans": []},
|
|
237
|
+
{"id": "b2", "text": "二段", "spans": []},
|
|
238
|
+
]
|
|
239
|
+
|
|
240
|
+
def test_empty_no_paragraphs(self):
|
|
241
|
+
assert _extract_paragraphs("<div>no p tags</div>") == []
|
|
242
|
+
|
|
243
|
+
def test_strips_tags_and_entities(self):
|
|
244
|
+
html = '<p id="x">hello <world></p>'
|
|
245
|
+
paras = _extract_paragraphs(html)
|
|
246
|
+
assert paras == [{"id": "x", "text": "hello <world>", "spans": []}]
|
|
@@ -4,6 +4,7 @@ from yuque_cli.urls import (
|
|
|
4
4
|
RepoRef,
|
|
5
5
|
DocRef,
|
|
6
6
|
UrlParseError,
|
|
7
|
+
extract_host,
|
|
7
8
|
parse_repo_ref,
|
|
8
9
|
parse_doc_ref,
|
|
9
10
|
)
|
|
@@ -12,15 +13,15 @@ from yuque_cli.urls import (
|
|
|
12
13
|
class TestParseDocRef:
|
|
13
14
|
def test_full_https_url(self):
|
|
14
15
|
ref = parse_doc_ref("https://www.yuque.com/abc/team-book/my-slug")
|
|
15
|
-
assert ref == DocRef(space="abc", repo="team-book", slug="my-slug")
|
|
16
|
+
assert ref == DocRef(space="abc", repo="team-book", slug="my-slug", host="www.yuque.com")
|
|
16
17
|
|
|
17
18
|
def test_http_scheme(self):
|
|
18
19
|
ref = parse_doc_ref("http://www.yuque.com/abc/book/slug")
|
|
19
|
-
assert ref == DocRef("abc", "book", "slug")
|
|
20
|
+
assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
|
|
20
21
|
|
|
21
22
|
def test_host_without_scheme(self):
|
|
22
23
|
ref = parse_doc_ref("www.yuque.com/abc/book/slug")
|
|
23
|
-
assert ref == DocRef("abc", "book", "slug")
|
|
24
|
+
assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
|
|
24
25
|
|
|
25
26
|
def test_path_only(self):
|
|
26
27
|
ref = parse_doc_ref("abc/book/slug")
|
|
@@ -34,7 +35,7 @@ class TestParseDocRef:
|
|
|
34
35
|
|
|
35
36
|
def test_strips_query_and_fragment(self):
|
|
36
37
|
ref = parse_doc_ref("https://www.yuque.com/abc/book/slug?from=x#h1")
|
|
37
|
-
assert ref == DocRef("abc", "book", "slug")
|
|
38
|
+
assert ref == DocRef("abc", "book", "slug", host="www.yuque.com")
|
|
38
39
|
|
|
39
40
|
def test_strips_surrounding_whitespace(self):
|
|
40
41
|
assert parse_doc_ref(" abc/book/slug ") == DocRef("abc", "book", "slug")
|
|
@@ -51,9 +52,12 @@ class TestParseDocRef:
|
|
|
51
52
|
with pytest.raises(UrlParseError):
|
|
52
53
|
parse_doc_ref("")
|
|
53
54
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
def test_extra_segments_use_first_three(self):
|
|
56
|
+
"""多余的路径段(如 /edit)会被忽略,只取前三段。"""
|
|
57
|
+
assert parse_doc_ref("abc/book/slug/edit") == DocRef("abc", "book", "slug")
|
|
58
|
+
assert parse_doc_ref(
|
|
59
|
+
"https://www.yuque.com/abc/book/slug/edit"
|
|
60
|
+
) == DocRef("abc", "book", "slug", host="www.yuque.com")
|
|
57
61
|
|
|
58
62
|
def test_repo_ref_property(self):
|
|
59
63
|
ref = parse_doc_ref("abc/book/slug")
|
|
@@ -63,7 +67,7 @@ class TestParseDocRef:
|
|
|
63
67
|
class TestParseRepoRef:
|
|
64
68
|
def test_full_https_url(self):
|
|
65
69
|
assert parse_repo_ref("https://www.yuque.com/abc/team-book") == RepoRef(
|
|
66
|
-
"abc", "team-book"
|
|
70
|
+
"abc", "team-book", host="www.yuque.com"
|
|
67
71
|
)
|
|
68
72
|
|
|
69
73
|
def test_path_only(self):
|
|
@@ -73,7 +77,7 @@ class TestParseRepoRef:
|
|
|
73
77
|
assert parse_repo_ref("abc/team-book/some-slug") == RepoRef("abc", "team-book")
|
|
74
78
|
|
|
75
79
|
def test_host_stripped_and_trailing_slash(self):
|
|
76
|
-
assert parse_repo_ref("www.yuque.com/abc/book/") == RepoRef("abc", "book")
|
|
80
|
+
assert parse_repo_ref("www.yuque.com/abc/book/") == RepoRef("abc", "book", host="www.yuque.com")
|
|
77
81
|
|
|
78
82
|
def test_space_only_is_rejected(self):
|
|
79
83
|
with pytest.raises(UrlParseError):
|
|
@@ -82,3 +86,23 @@ class TestParseRepoRef:
|
|
|
82
86
|
def test_empty_is_rejected(self):
|
|
83
87
|
with pytest.raises(UrlParseError):
|
|
84
88
|
parse_repo_ref(" ")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestExtractHost:
|
|
92
|
+
def test_full_url(self):
|
|
93
|
+
assert extract_host("https://www.yuque.com/abc/book/slug") == "www.yuque.com"
|
|
94
|
+
|
|
95
|
+
def test_custom_domain(self):
|
|
96
|
+
assert extract_host("https://jlpay.yuque.com/pmw77l/iybc48/doc") == "jlpay.yuque.com"
|
|
97
|
+
|
|
98
|
+
def test_no_scheme_with_host(self):
|
|
99
|
+
assert extract_host("jlpay.yuque.com/abc/book/slug") == "jlpay.yuque.com"
|
|
100
|
+
|
|
101
|
+
def test_path_only(self):
|
|
102
|
+
assert extract_host("abc/book/slug") is None
|
|
103
|
+
|
|
104
|
+
def test_empty(self):
|
|
105
|
+
assert extract_host("") is None
|
|
106
|
+
|
|
107
|
+
def test_whitespace(self):
|
|
108
|
+
assert extract_host(" https://foo.com/a/b ") == "foo.com"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{yuque_cli-0.1.1 → yuque_cli-1.0.0}/docs/adr/0002-cookie-acquisition-cdp-with-manual-fallback.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|