kaiten-cli 0.3.3__py3-none-any.whl
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.
- kaiten_cli/__init__.py +5 -0
- kaiten_cli/__main__.py +9 -0
- kaiten_cli/agent_gateway.py +577 -0
- kaiten_cli/app.py +1636 -0
- kaiten_cli/completion.py +570 -0
- kaiten_cli/discovery.py +39 -0
- kaiten_cli/errors.py +79 -0
- kaiten_cli/i18n.py +92 -0
- kaiten_cli/locales/ru.json +2967 -0
- kaiten_cli/localized_click.py +160 -0
- kaiten_cli/models.py +202 -0
- kaiten_cli/profiles.py +316 -0
- kaiten_cli/registry/__init__.py +260 -0
- kaiten_cli/registry/addons.py +392 -0
- kaiten_cli/registry/audit_and_analytics.py +451 -0
- kaiten_cli/registry/automation_schema.py +44 -0
- kaiten_cli/registry/automation_schemas.json +7417 -0
- kaiten_cli/registry/automations.py +342 -0
- kaiten_cli/registry/base.py +62 -0
- kaiten_cli/registry/blockers.py +320 -0
- kaiten_cli/registry/boards.py +520 -0
- kaiten_cli/registry/card_relations.py +376 -0
- kaiten_cli/registry/card_types.py +363 -0
- kaiten_cli/registry/cards.py +1329 -0
- kaiten_cli/registry/charts.py +539 -0
- kaiten_cli/registry/checklists.py +600 -0
- kaiten_cli/registry/columns.py +558 -0
- kaiten_cli/registry/comments.py +224 -0
- kaiten_cli/registry/custom_directories.py +980 -0
- kaiten_cli/registry/custom_properties.py +1460 -0
- kaiten_cli/registry/dashboards.py +564 -0
- kaiten_cli/registry/documents.py +761 -0
- kaiten_cli/registry/external_links.py +128 -0
- kaiten_cli/registry/files.py +738 -0
- kaiten_cli/registry/github_addon.py +613 -0
- kaiten_cli/registry/iterations.py +397 -0
- kaiten_cli/registry/lanes.py +205 -0
- kaiten_cli/registry/live_contracts.py +361 -0
- kaiten_cli/registry/members.py +336 -0
- kaiten_cli/registry/module_docs.py +108 -0
- kaiten_cli/registry/projects.py +369 -0
- kaiten_cli/registry/query.py +129 -0
- kaiten_cli/registry/restrictions.py +249 -0
- kaiten_cli/registry/roles_and_groups.py +1102 -0
- kaiten_cli/registry/scim.py +263 -0
- kaiten_cli/registry/service_desk.py +1270 -0
- kaiten_cli/registry/snapshot.py +187 -0
- kaiten_cli/registry/spaces.py +272 -0
- kaiten_cli/registry/subscribers.py +168 -0
- kaiten_cli/registry/tags.py +193 -0
- kaiten_cli/registry/time_logs.py +353 -0
- kaiten_cli/registry/tree.py +352 -0
- kaiten_cli/registry/utilities.py +287 -0
- kaiten_cli/registry/webhooks.py +265 -0
- kaiten_cli/runtime/__init__.py +1 -0
- kaiten_cli/runtime/automation_validation.py +115 -0
- kaiten_cli/runtime/behaviors.py +787 -0
- kaiten_cli/runtime/cache.py +733 -0
- kaiten_cli/runtime/client.py +387 -0
- kaiten_cli/runtime/endpoints.py +59 -0
- kaiten_cli/runtime/executor.py +269 -0
- kaiten_cli/runtime/fs_security.py +168 -0
- kaiten_cli/runtime/input.py +310 -0
- kaiten_cli/runtime/output.py +59 -0
- kaiten_cli/runtime/public_validation.py +35 -0
- kaiten_cli/runtime/snapshot_build.py +527 -0
- kaiten_cli/runtime/snapshot_common.py +363 -0
- kaiten_cli/runtime/snapshot_query.py +306 -0
- kaiten_cli/runtime/snapshot_store.py +1192 -0
- kaiten_cli/runtime/snapshots.py +62 -0
- kaiten_cli/runtime/sqlite_errors.py +23 -0
- kaiten_cli/runtime/support/__init__.py +1 -0
- kaiten_cli/runtime/support/addons.py +953 -0
- kaiten_cli/runtime/support/audit.py +70 -0
- kaiten_cli/runtime/support/batch.py +234 -0
- kaiten_cli/runtime/support/card_move_url.py +213 -0
- kaiten_cli/runtime/support/cards.py +174 -0
- kaiten_cli/runtime/support/checklists.py +21 -0
- kaiten_cli/runtime/support/company_users.py +117 -0
- kaiten_cli/runtime/support/dashboards.py +35 -0
- kaiten_cli/runtime/support/documents.py +175 -0
- kaiten_cli/runtime/support/files.py +561 -0
- kaiten_cli/runtime/support/markdown_export.py +525 -0
- kaiten_cli/runtime/support/pagination.py +167 -0
- kaiten_cli/runtime/support/projects.py +66 -0
- kaiten_cli/runtime/support/relations.py +86 -0
- kaiten_cli/runtime/support/restrictions.py +62 -0
- kaiten_cli/runtime/support/spaces.py +20 -0
- kaiten_cli/runtime/support/time_logs.py +61 -0
- kaiten_cli/runtime/support/tree.py +140 -0
- kaiten_cli/runtime/support/tree_sharing.py +447 -0
- kaiten_cli/runtime/trace.py +500 -0
- kaiten_cli/runtime/transforms.py +128 -0
- kaiten_cli/schema_docs.py +22 -0
- kaiten_cli/update_check.py +519 -0
- kaiten_cli-0.3.3.dist-info/METADATA +1110 -0
- kaiten_cli-0.3.3.dist-info/RECORD +99 -0
- kaiten_cli-0.3.3.dist-info/WHEEL +4 -0
- kaiten_cli-0.3.3.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Markdown export helpers for Kaiten cards and documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from kaiten_cli.errors import ConfigError, ValidationError
|
|
13
|
+
from kaiten_cli.i18n import tr
|
|
14
|
+
from kaiten_cli.models import DebugReporter, ToolSpec
|
|
15
|
+
|
|
16
|
+
UUID_RE = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
|
17
|
+
INTERNAL_FILE_RE = re.compile(
|
|
18
|
+
rf"/(?:files/)?companies/{UUID_RE}/(?P<kind>documents|cards)/(?P<owner>{UUID_RE})/"
|
|
19
|
+
rf"(?P<file>{UUID_RE})(?:\.[^/?#\])]+)?$"
|
|
20
|
+
)
|
|
21
|
+
API_FILE_RE = re.compile(
|
|
22
|
+
rf"^/api(?:/latest)?/(?P<kind>documents|cards)/(?P<owner>[^/]+)/files/(?P<file>{UUID_RE})(?:\.[^/?#\])]+)?$"
|
|
23
|
+
)
|
|
24
|
+
INTERNAL_FILE_TEXT_RE = re.compile(
|
|
25
|
+
rf"(?P<url>(?:https?://[^)\]\s]+)?/(?:files/)?companies/{UUID_RE}/"
|
|
26
|
+
rf"(?P<kind>documents|cards)/(?P<owner>{UUID_RE})/(?P<file>{UUID_RE})(?:\.[^)\]\s]+)?)"
|
|
27
|
+
)
|
|
28
|
+
API_FILE_TEXT_RE = re.compile(
|
|
29
|
+
rf"(?P<url>/api(?:/latest)?/(?P<kind>documents|cards)/(?P<owner>[^/)\]\s]+)/files/"
|
|
30
|
+
rf"(?P<file>{UUID_RE})(?:\.[^)\]\s]+)?)"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _payload_str(payload: dict[str, Any], key: str) -> str | None:
|
|
35
|
+
value = payload.get(key)
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
text = str(value).strip()
|
|
39
|
+
return text or None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_filename(value: str, fallback: str) -> str:
|
|
43
|
+
value = value.strip().lower()
|
|
44
|
+
value = re.sub(r"[^\w\s.-]+", "", value, flags=re.UNICODE)
|
|
45
|
+
value = re.sub(r"[\s/]+", "-", value)
|
|
46
|
+
value = re.sub(r"-+", "-", value).strip("-._")
|
|
47
|
+
return value[:90] or fallback
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _output_path(output: str | None, filename: str) -> Path:
|
|
51
|
+
if output is None:
|
|
52
|
+
return Path.cwd() / filename
|
|
53
|
+
output_path = Path(output).expanduser()
|
|
54
|
+
if output.endswith(("/", os.sep)) or output_path.is_dir():
|
|
55
|
+
return output_path / filename
|
|
56
|
+
return output_path
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _write_text_atomic(path: Path, text: str, *, overwrite: bool) -> None:
|
|
60
|
+
if path.exists() and not overwrite:
|
|
61
|
+
raise ValidationError(
|
|
62
|
+
tr(
|
|
63
|
+
"Output file already exists: {value_0}. Use --overwrite to replace it.",
|
|
64
|
+
value_0=path,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
part_path = path.with_name(f"{path.name}.part")
|
|
69
|
+
if part_path.exists() and not overwrite:
|
|
70
|
+
raise ValidationError(
|
|
71
|
+
tr(
|
|
72
|
+
"Partial file already exists: {value_0}. Use --overwrite to replace it.",
|
|
73
|
+
value_0=part_path,
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
part_path.write_text(text, encoding="utf-8")
|
|
77
|
+
part_path.replace(path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _yaml_scalar(value: Any) -> str:
|
|
81
|
+
return json.dumps(value, ensure_ascii=False)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _frontmatter(entity_type: str, fields: dict[str, Any]) -> str:
|
|
85
|
+
lines = ["---", f"type: {_yaml_scalar(entity_type)}"]
|
|
86
|
+
for key, value in fields.items():
|
|
87
|
+
if value is not None:
|
|
88
|
+
lines.append(f"{key}: {_yaml_scalar(value)}")
|
|
89
|
+
lines.extend(["---", ""])
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_document_data(data: Any) -> dict[str, Any]:
|
|
94
|
+
if isinstance(data, dict):
|
|
95
|
+
return data
|
|
96
|
+
if isinstance(data, str) and data.strip():
|
|
97
|
+
try:
|
|
98
|
+
parsed = json.loads(data)
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
return {
|
|
101
|
+
"type": "doc",
|
|
102
|
+
"content": [{"type": "paragraph", "content": [{"type": "text", "text": data}]}],
|
|
103
|
+
}
|
|
104
|
+
if isinstance(parsed, dict):
|
|
105
|
+
return parsed
|
|
106
|
+
return {"type": "doc", "content": []}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _children(node: dict[str, Any]) -> list[dict[str, Any]]:
|
|
110
|
+
content = node.get("content")
|
|
111
|
+
return (
|
|
112
|
+
[child for child in content if isinstance(child, dict)] if isinstance(content, list) else []
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _attrs(node: dict[str, Any]) -> dict[str, Any]:
|
|
117
|
+
attrs = node.get("attrs")
|
|
118
|
+
return attrs if isinstance(attrs, dict) else {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _text_content(node: dict[str, Any], *, document_uid: str | None = None) -> str:
|
|
122
|
+
if node.get("type") == "text":
|
|
123
|
+
return str(node.get("text") or "")
|
|
124
|
+
return "".join(_inline(child, document_uid=document_uid) for child in _children(node))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _escape_table_cell(value: str) -> str:
|
|
128
|
+
return value.replace("|", "\\|").replace("\n", " ").strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _escape_link_text(value: str) -> str:
|
|
132
|
+
return value.replace("[", "\\[").replace("]", "\\]")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _strip_file_extension(value: Any) -> str:
|
|
136
|
+
text = str(value)
|
|
137
|
+
if re.fullmatch(rf"{UUID_RE}\.[^./\\]+", text):
|
|
138
|
+
return text.rsplit(".", 1)[0]
|
|
139
|
+
return text
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _api_file_link(kind: str, owner: Any, file_id: Any) -> str:
|
|
143
|
+
return f"/api/{kind}/{owner}/files/{_strip_file_extension(file_id)}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _normalize_kaiten_file_url(url: str) -> str:
|
|
147
|
+
if not url:
|
|
148
|
+
return url
|
|
149
|
+
parsed = urlparse(url)
|
|
150
|
+
path = parsed.path or url
|
|
151
|
+
if not path.startswith("/"):
|
|
152
|
+
path = f"/{path}"
|
|
153
|
+
|
|
154
|
+
api_match = API_FILE_RE.match(path)
|
|
155
|
+
if api_match is not None:
|
|
156
|
+
return _api_file_link(
|
|
157
|
+
api_match.group("kind"), api_match.group("owner"), api_match.group("file")
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
internal_match = INTERNAL_FILE_RE.search(path)
|
|
161
|
+
if internal_match is not None:
|
|
162
|
+
return _api_file_link(
|
|
163
|
+
internal_match.group("kind"),
|
|
164
|
+
internal_match.group("owner"),
|
|
165
|
+
internal_match.group("file"),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return url
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_file_links_in_markdown(text: str) -> str:
|
|
172
|
+
def repl(match: re.Match[str]) -> str:
|
|
173
|
+
return _api_file_link(match.group("kind"), match.group("owner"), match.group("file"))
|
|
174
|
+
|
|
175
|
+
text = API_FILE_TEXT_RE.sub(repl, text)
|
|
176
|
+
return INTERNAL_FILE_TEXT_RE.sub(repl, text)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _link_for_file(attrs: dict[str, Any], *, document_uid: str | None) -> str:
|
|
180
|
+
file_id = attrs.get("fileId") or attrs.get("file_id")
|
|
181
|
+
if file_id and document_uid:
|
|
182
|
+
return _api_file_link("documents", document_uid, file_id)
|
|
183
|
+
return _normalize_kaiten_file_url(str(attrs.get("url") or attrs.get("src") or ""))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _apply_marks(text: str, marks: Any, *, document_uid: str | None) -> str:
|
|
187
|
+
if not isinstance(marks, list):
|
|
188
|
+
return text
|
|
189
|
+
result = text
|
|
190
|
+
link: dict[str, Any] | None = None
|
|
191
|
+
for mark in marks:
|
|
192
|
+
if not isinstance(mark, dict):
|
|
193
|
+
continue
|
|
194
|
+
mark_type = str(mark.get("type") or "")
|
|
195
|
+
attrs = mark.get("attrs") if isinstance(mark.get("attrs"), dict) else {}
|
|
196
|
+
if mark_type in {"bold", "strong"}:
|
|
197
|
+
result = f"**{result}**"
|
|
198
|
+
elif mark_type in {"italic", "em"}:
|
|
199
|
+
result = f"*{result}*"
|
|
200
|
+
elif mark_type in {"strikethrough", "strike"}:
|
|
201
|
+
result = f"~~{result}~~"
|
|
202
|
+
elif mark_type == "code":
|
|
203
|
+
result = f"`{result}`"
|
|
204
|
+
elif mark_type == "underline":
|
|
205
|
+
result = f"++{result}++"
|
|
206
|
+
elif mark_type == "sup":
|
|
207
|
+
result = f"^{result}^"
|
|
208
|
+
elif mark_type == "sub":
|
|
209
|
+
result = f"~{result}~"
|
|
210
|
+
elif mark_type == "link":
|
|
211
|
+
link = attrs
|
|
212
|
+
if link is not None:
|
|
213
|
+
href = link.get("href") or ""
|
|
214
|
+
if link.get("fileId") and link.get("fileEntityType"):
|
|
215
|
+
kind = {"document": "documents", "card": "cards"}.get(str(link["fileEntityType"]))
|
|
216
|
+
if kind == "documents" and document_uid:
|
|
217
|
+
href = _api_file_link(kind, document_uid, link["fileId"])
|
|
218
|
+
elif kind and (link.get("entityId") or link.get("entityUid")):
|
|
219
|
+
href = _api_file_link(
|
|
220
|
+
kind, link.get("entityId") or link.get("entityUid") or "", link["fileId"]
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
href = _normalize_kaiten_file_url(str(href))
|
|
224
|
+
else:
|
|
225
|
+
href = _normalize_kaiten_file_url(str(href))
|
|
226
|
+
title = str(link.get("title") or "")
|
|
227
|
+
escaped_title = title.replace('"', '\\"')
|
|
228
|
+
quoted_title = f' "{escaped_title}"' if title else ""
|
|
229
|
+
result = f"[{_escape_link_text(result)}]({href}{quoted_title})"
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _inline(node: dict[str, Any], *, document_uid: str | None) -> str:
|
|
234
|
+
node_type = node.get("type")
|
|
235
|
+
if node_type == "text":
|
|
236
|
+
return _apply_marks(
|
|
237
|
+
str(node.get("text") or ""), node.get("marks"), document_uid=document_uid
|
|
238
|
+
)
|
|
239
|
+
if node_type == "hard_break":
|
|
240
|
+
return "\n"
|
|
241
|
+
if node_type == "mention":
|
|
242
|
+
return str(_attrs(node).get("label") or "")
|
|
243
|
+
if node_type == "inline_card_link":
|
|
244
|
+
return str(_attrs(node).get("url") or "")
|
|
245
|
+
if node_type == "image":
|
|
246
|
+
attrs = _attrs(node)
|
|
247
|
+
url = _link_for_file(attrs, document_uid=document_uid)
|
|
248
|
+
alt = str(attrs.get("alt") or attrs.get("title") or attrs.get("name") or tr("image"))
|
|
249
|
+
return f"" if url else alt
|
|
250
|
+
return "".join(_inline(child, document_uid=document_uid) for child in _children(node))
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _render_list(
|
|
254
|
+
node: dict[str, Any],
|
|
255
|
+
*,
|
|
256
|
+
document_uid: str | None,
|
|
257
|
+
ordered: bool = False,
|
|
258
|
+
check_list: bool = False,
|
|
259
|
+
) -> str:
|
|
260
|
+
lines: list[str] = []
|
|
261
|
+
start = int(_attrs(node).get("order") or 1)
|
|
262
|
+
for index, item in enumerate(_children(node), start=start):
|
|
263
|
+
attrs = _attrs(item)
|
|
264
|
+
if check_list:
|
|
265
|
+
prefix = "- [x] " if attrs.get("checked") else "- [ ] "
|
|
266
|
+
elif ordered:
|
|
267
|
+
prefix = f"{index}. "
|
|
268
|
+
else:
|
|
269
|
+
prefix = "* "
|
|
270
|
+
item_text = _block(item, document_uid=document_uid).strip()
|
|
271
|
+
item_lines = item_text.splitlines() or [""]
|
|
272
|
+
lines.append(f"{prefix}{item_lines[0]}")
|
|
273
|
+
continuation = " " * len(prefix)
|
|
274
|
+
lines.extend(f"{continuation}{line}" for line in item_lines[1:])
|
|
275
|
+
return "\n".join(lines)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _render_table(node: dict[str, Any], *, document_uid: str | None) -> str:
|
|
279
|
+
rows: list[list[str]] = []
|
|
280
|
+
for row in _children(node):
|
|
281
|
+
if row.get("type") not in {"table_row", "tableRow"}:
|
|
282
|
+
continue
|
|
283
|
+
cells = [
|
|
284
|
+
_escape_table_cell(_text_content(cell, document_uid=document_uid))
|
|
285
|
+
for cell in _children(row)
|
|
286
|
+
]
|
|
287
|
+
if cells:
|
|
288
|
+
rows.append(cells)
|
|
289
|
+
if not rows:
|
|
290
|
+
return ""
|
|
291
|
+
width = max(len(row) for row in rows)
|
|
292
|
+
rows = [row + [""] * (width - len(row)) for row in rows]
|
|
293
|
+
lines = [
|
|
294
|
+
"| " + " | ".join(rows[0]) + " |",
|
|
295
|
+
"| " + " | ".join("---" for _ in range(width)) + " |",
|
|
296
|
+
]
|
|
297
|
+
lines.extend("| " + " | ".join(row) + " |" for row in rows[1:])
|
|
298
|
+
return "\n".join(lines)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _prefix_lines(text: str, prefix: str) -> str:
|
|
302
|
+
return "\n".join(f"{prefix}{line}" if line else prefix.rstrip() for line in text.splitlines())
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _block(node: dict[str, Any], *, document_uid: str | None) -> str:
|
|
306
|
+
node_type = node.get("type")
|
|
307
|
+
attrs = _attrs(node)
|
|
308
|
+
|
|
309
|
+
if node_type == "doc":
|
|
310
|
+
return _blocks(_children(node), document_uid=document_uid)
|
|
311
|
+
if node_type == "paragraph":
|
|
312
|
+
return "".join(
|
|
313
|
+
_inline(child, document_uid=document_uid) for child in _children(node)
|
|
314
|
+
).strip()
|
|
315
|
+
if node_type in {"heading", "heading1", "heading2", "heading3"}:
|
|
316
|
+
level = int(attrs.get("level") or str(node_type).removeprefix("heading") or 1)
|
|
317
|
+
return f"{'#' * min(max(level, 1), 6)} {_text_content(node, document_uid=document_uid).strip()}".rstrip()
|
|
318
|
+
if node_type == "toggle_heading":
|
|
319
|
+
return f"**{_text_content(node, document_uid=document_uid).strip()}**"
|
|
320
|
+
if node_type in {"bullet_list", "list"}:
|
|
321
|
+
return _render_list(node, document_uid=document_uid)
|
|
322
|
+
if node_type == "ordered_list":
|
|
323
|
+
return _render_list(node, document_uid=document_uid, ordered=True)
|
|
324
|
+
if node_type == "check_list":
|
|
325
|
+
return _render_list(node, document_uid=document_uid, check_list=True)
|
|
326
|
+
if node_type in {
|
|
327
|
+
"list_item",
|
|
328
|
+
"check_list_item",
|
|
329
|
+
"toggle_content",
|
|
330
|
+
"columns",
|
|
331
|
+
"column",
|
|
332
|
+
"toggle",
|
|
333
|
+
}:
|
|
334
|
+
return _blocks(_children(node), document_uid=document_uid)
|
|
335
|
+
if node_type in {"blockquote", "alert"}:
|
|
336
|
+
return _prefix_lines(_blocks(_children(node), document_uid=document_uid), "> ")
|
|
337
|
+
if node_type == "code_block":
|
|
338
|
+
language = attrs.get("language") or attrs.get("params") or ""
|
|
339
|
+
return f"```{language}\n{node.get('text') or _text_content(node, document_uid=document_uid).strip()}\n```"
|
|
340
|
+
if node_type == "horizontal_rule":
|
|
341
|
+
return "---"
|
|
342
|
+
if node_type == "table":
|
|
343
|
+
return _render_table(node, document_uid=document_uid)
|
|
344
|
+
if node_type in {"image", "imageBlock"}:
|
|
345
|
+
rendered = _inline(node, document_uid=document_uid)
|
|
346
|
+
return rendered or _blocks(_children(node), document_uid=document_uid)
|
|
347
|
+
if node_type == "file":
|
|
348
|
+
url = _link_for_file(attrs, document_uid=document_uid)
|
|
349
|
+
label = str(
|
|
350
|
+
attrs.get("name")
|
|
351
|
+
or attrs.get("title")
|
|
352
|
+
or attrs.get("fileName")
|
|
353
|
+
or attrs.get("fileId")
|
|
354
|
+
or tr("file")
|
|
355
|
+
)
|
|
356
|
+
return f"[{_escape_link_text(label)}]({url})" if url else label
|
|
357
|
+
if node_type in {"inline_card_link", "block_card_link"}:
|
|
358
|
+
return str(attrs.get("url") or "")
|
|
359
|
+
|
|
360
|
+
if _children(node):
|
|
361
|
+
return _blocks(_children(node), document_uid=document_uid)
|
|
362
|
+
return ""
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _blocks(nodes: list[dict[str, Any]], *, document_uid: str | None) -> str:
|
|
366
|
+
rendered = [_block(node, document_uid=document_uid).strip() for node in nodes]
|
|
367
|
+
return "\n\n".join(block for block in rendered if block)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def document_to_markdown(document: dict[str, Any]) -> str:
|
|
371
|
+
document_uid = str(document.get("uid") or document.get("id") or "")
|
|
372
|
+
title = str(document.get("title") or document_uid or tr("Document"))
|
|
373
|
+
frontmatter = _frontmatter(
|
|
374
|
+
"document",
|
|
375
|
+
{
|
|
376
|
+
"uid": document.get("uid"),
|
|
377
|
+
"id": document.get("id"),
|
|
378
|
+
"title": title,
|
|
379
|
+
"created": document.get("created"),
|
|
380
|
+
"updated": document.get("updated"),
|
|
381
|
+
"parent_entity_uid": document.get("parent_entity_uid"),
|
|
382
|
+
},
|
|
383
|
+
)
|
|
384
|
+
body = _block(_parse_document_data(document.get("data")), document_uid=document_uid).strip()
|
|
385
|
+
return f"{frontmatter}\n# {title}\n\n{body}\n".rstrip() + "\n"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _card_file_link(card: dict[str, Any], file_item: dict[str, Any]) -> str | None:
|
|
389
|
+
file_id = file_item.get("id") or file_item.get("uid")
|
|
390
|
+
if file_id is None:
|
|
391
|
+
return None
|
|
392
|
+
card_ref = card.get("uid") or card.get("id")
|
|
393
|
+
if card_ref is None:
|
|
394
|
+
return None
|
|
395
|
+
return _api_file_link("cards", card_ref, file_id)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def card_to_markdown(card: dict[str, Any], files: list[dict[str, Any]]) -> str:
|
|
399
|
+
card_id = card.get("id")
|
|
400
|
+
title = str(card.get("title") or card.get("name") or card_id or tr("Card"))
|
|
401
|
+
frontmatter = _frontmatter(
|
|
402
|
+
"card",
|
|
403
|
+
{
|
|
404
|
+
"id": card_id,
|
|
405
|
+
"uid": card.get("uid"),
|
|
406
|
+
"title": title,
|
|
407
|
+
"created": card.get("created"),
|
|
408
|
+
"updated": card.get("updated"),
|
|
409
|
+
"state": card.get("state"),
|
|
410
|
+
"board_id": card.get("board_id"),
|
|
411
|
+
},
|
|
412
|
+
)
|
|
413
|
+
description = _normalize_file_links_in_markdown(str(card.get("description") or "").strip())
|
|
414
|
+
body_parts = [frontmatter, f"# {title}", description]
|
|
415
|
+
attachment_lines = []
|
|
416
|
+
for item in files:
|
|
417
|
+
url = _card_file_link(card, item)
|
|
418
|
+
if url is None:
|
|
419
|
+
continue
|
|
420
|
+
label = str(item.get("name") or item.get("filename") or item.get("id") or tr("file"))
|
|
421
|
+
attachment_lines.append(f"- [{_escape_link_text(label)}]({url})")
|
|
422
|
+
if attachment_lines:
|
|
423
|
+
body_parts.extend(["## " + tr("Attachments"), "\n".join(attachment_lines)])
|
|
424
|
+
return "\n\n".join(part for part in body_parts if part).rstrip() + "\n"
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def validate_card_get_markdown_options(tool: ToolSpec, payload: dict[str, Any]) -> None:
|
|
428
|
+
del tool
|
|
429
|
+
if payload.get("markdown") and payload.get("fields"):
|
|
430
|
+
raise ValidationError(tr("--fields cannot be combined with --markdown."))
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _entity_filename(title: str, identifier: Any, fallback: str) -> str:
|
|
434
|
+
slug = _safe_filename(title, fallback)
|
|
435
|
+
suffix = _safe_filename(str(identifier), fallback)
|
|
436
|
+
return f"{slug}--{suffix}.md"
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _write_markdown_result(
|
|
440
|
+
*,
|
|
441
|
+
text: str,
|
|
442
|
+
output: str | None,
|
|
443
|
+
filename: str,
|
|
444
|
+
overwrite: bool,
|
|
445
|
+
metadata: dict[str, Any],
|
|
446
|
+
) -> dict[str, Any]:
|
|
447
|
+
path = _output_path(output, filename)
|
|
448
|
+
_write_text_atomic(path, text, overwrite=overwrite)
|
|
449
|
+
return {"path": str(path), "bytes": path.stat().st_size, **metadata}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def execute_document_get(
|
|
453
|
+
client: Any,
|
|
454
|
+
tool: ToolSpec,
|
|
455
|
+
payload: dict[str, Any],
|
|
456
|
+
path: str,
|
|
457
|
+
query: dict[str, Any] | None,
|
|
458
|
+
body: dict[str, Any] | None,
|
|
459
|
+
timeout: float,
|
|
460
|
+
reporter: DebugReporter | None,
|
|
461
|
+
) -> dict[str, Any]:
|
|
462
|
+
del tool, body, reporter
|
|
463
|
+
if client is None:
|
|
464
|
+
raise ConfigError(tr("This command requires a Kaiten profile."))
|
|
465
|
+
document = await client.get(path, params=query, timeout=timeout)
|
|
466
|
+
if not isinstance(document, dict):
|
|
467
|
+
raise ValidationError(tr("Kaiten returned an unexpected document payload."))
|
|
468
|
+
if not payload.get("markdown"):
|
|
469
|
+
return document
|
|
470
|
+
document_uid = _payload_str(payload, "document_uid")
|
|
471
|
+
if document_uid is None:
|
|
472
|
+
raise ValidationError(tr("Missing required field: document_uid."))
|
|
473
|
+
text = document_to_markdown(document)
|
|
474
|
+
filename = _entity_filename(str(document.get("title") or "document"), document_uid, "document")
|
|
475
|
+
return _write_markdown_result(
|
|
476
|
+
text=text,
|
|
477
|
+
output=_payload_str(payload, "output"),
|
|
478
|
+
filename=filename,
|
|
479
|
+
overwrite=bool(payload.get("overwrite", False)),
|
|
480
|
+
metadata={
|
|
481
|
+
"entity_type": "document",
|
|
482
|
+
"document_uid": document_uid,
|
|
483
|
+
"title": document.get("title"),
|
|
484
|
+
},
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
async def execute_card_get(
|
|
489
|
+
client: Any,
|
|
490
|
+
tool: ToolSpec,
|
|
491
|
+
payload: dict[str, Any],
|
|
492
|
+
path: str,
|
|
493
|
+
query: dict[str, Any] | None,
|
|
494
|
+
body: dict[str, Any] | None,
|
|
495
|
+
timeout: float,
|
|
496
|
+
reporter: DebugReporter | None,
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
del tool, body, reporter
|
|
499
|
+
if client is None:
|
|
500
|
+
raise ConfigError(tr("This command requires a Kaiten profile."))
|
|
501
|
+
card_id = _payload_str(payload, "card_id")
|
|
502
|
+
if card_id is None:
|
|
503
|
+
raise ValidationError(tr("Missing required field: card_id."))
|
|
504
|
+
card = await client.get(path, params=query, timeout=timeout)
|
|
505
|
+
if not isinstance(card, dict):
|
|
506
|
+
raise ValidationError(tr("Kaiten returned an unexpected card payload."))
|
|
507
|
+
if not payload.get("markdown"):
|
|
508
|
+
return card
|
|
509
|
+
card_ref = card.get("uid") or card.get("id") or card_id
|
|
510
|
+
files_payload = await client.get(f"/cards/{card_ref}/files", timeout=timeout)
|
|
511
|
+
files = files_payload if isinstance(files_payload, list) else []
|
|
512
|
+
text = card_to_markdown(card, files)
|
|
513
|
+
filename = _entity_filename(str(card.get("title") or "card"), card.get("id") or card_id, "card")
|
|
514
|
+
return _write_markdown_result(
|
|
515
|
+
text=text,
|
|
516
|
+
output=_payload_str(payload, "output"),
|
|
517
|
+
filename=filename,
|
|
518
|
+
overwrite=bool(payload.get("overwrite", False)),
|
|
519
|
+
metadata={
|
|
520
|
+
"entity_type": "card",
|
|
521
|
+
"card_id": card_id,
|
|
522
|
+
"title": card.get("title"),
|
|
523
|
+
"attachment_count": len(files),
|
|
524
|
+
},
|
|
525
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Shared bounded offset pagination for Kaiten collection endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from kaiten_cli.errors import ConfigError, ValidationError
|
|
10
|
+
from kaiten_cli.i18n import tr
|
|
11
|
+
|
|
12
|
+
API_MAX_PAGE_SIZE = 100
|
|
13
|
+
DEFAULT_COLLECTION_MAX_PAGES = 100
|
|
14
|
+
MAX_COLLECTION_MAX_PAGES = 1000
|
|
15
|
+
LEGACY_PAGINATION_MODE = "legacy_unpaginated"
|
|
16
|
+
LEGACY_OVERSIZED_FIRST_PAGE = "oversized_first_page"
|
|
17
|
+
LEGACY_REPEATED_FIRST_PAGE = "repeated_first_page"
|
|
18
|
+
REPEATED_PAGE_AFTER_PROGRESS = "repeated_page_after_progress"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class OffsetPageGuard:
|
|
23
|
+
"""Classify legacy unpaginated responses and unsafe page repetition."""
|
|
24
|
+
|
|
25
|
+
page_size: int
|
|
26
|
+
pages: list[list[Any]] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
def observe(self, page: list[Any], *, page_index: int) -> str | None:
|
|
29
|
+
if page_index == 0 and len(page) > self.page_size:
|
|
30
|
+
return LEGACY_OVERSIZED_FIRST_PAGE
|
|
31
|
+
if page_index == 1 and self.pages and page == self.pages[0]:
|
|
32
|
+
return LEGACY_REPEATED_FIRST_PAGE
|
|
33
|
+
if any(page == previous for previous in self.pages):
|
|
34
|
+
return REPEATED_PAGE_AFTER_PROGRESS
|
|
35
|
+
self.pages.append(page)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def report_legacy_pagination(
|
|
40
|
+
client,
|
|
41
|
+
*,
|
|
42
|
+
path: str,
|
|
43
|
+
reason: str,
|
|
44
|
+
rows: int,
|
|
45
|
+
reporter=None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Expose a legacy-mode decision through verbose output and command traces."""
|
|
48
|
+
message = f"pagination: path={path} mode={LEGACY_PAGINATION_MODE} reason={reason} rows={rows}"
|
|
49
|
+
if reporter is not None:
|
|
50
|
+
reporter(message)
|
|
51
|
+
|
|
52
|
+
context = getattr(client, "execution_context", None)
|
|
53
|
+
stats = getattr(context, "stats", None)
|
|
54
|
+
record = getattr(stats, "record_pagination_compatibility", None)
|
|
55
|
+
if callable(record):
|
|
56
|
+
record(path=path, mode=LEGACY_PAGINATION_MODE, reason=reason, rows=rows)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def validate_page_bounds(
|
|
60
|
+
*,
|
|
61
|
+
page_size: int,
|
|
62
|
+
max_pages: int,
|
|
63
|
+
max_page_size: int = API_MAX_PAGE_SIZE,
|
|
64
|
+
max_allowed_pages: int = MAX_COLLECTION_MAX_PAGES,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Defensively validate bounds used by internal pagination helpers."""
|
|
67
|
+
if isinstance(page_size, bool) or not isinstance(page_size, int):
|
|
68
|
+
raise ValidationError(tr("Field page_size must be an integer."))
|
|
69
|
+
if not 1 <= page_size <= max_page_size:
|
|
70
|
+
raise ValidationError(
|
|
71
|
+
tr("Field page_size must be between 1 and {value_0}.", value_0=max_page_size)
|
|
72
|
+
)
|
|
73
|
+
if isinstance(max_pages, bool) or not isinstance(max_pages, int):
|
|
74
|
+
raise ValidationError(tr("Field max_pages must be an integer."))
|
|
75
|
+
if not 1 <= max_pages <= max_allowed_pages:
|
|
76
|
+
raise ValidationError(
|
|
77
|
+
tr("Field max_pages must be between 1 and {value_0}.", value_0=max_allowed_pages)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_page_items(response: Any, *, path: str) -> list[Any]:
|
|
82
|
+
"""Require the list response shape documented for offset-paginated endpoints."""
|
|
83
|
+
if isinstance(response, list):
|
|
84
|
+
return response
|
|
85
|
+
raise ConfigError(
|
|
86
|
+
tr("{value_0} pagination expected a list response from Kaiten.", value_0=path)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def fetch_all_offset_pages(
|
|
91
|
+
client,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
timeout: float,
|
|
95
|
+
params: dict[str, Any] | None = None,
|
|
96
|
+
page_size: int = API_MAX_PAGE_SIZE,
|
|
97
|
+
max_pages: int = DEFAULT_COLLECTION_MAX_PAGES,
|
|
98
|
+
extract_items: Callable[[Any], list[Any]] | None = None,
|
|
99
|
+
reporter=None,
|
|
100
|
+
) -> list[Any]:
|
|
101
|
+
"""Fetch a complete collection or fail instead of returning a silent truncation."""
|
|
102
|
+
validate_page_bounds(page_size=page_size, max_pages=max_pages)
|
|
103
|
+
base_params = dict(params or {})
|
|
104
|
+
rows: list[Any] = []
|
|
105
|
+
guard = OffsetPageGuard(page_size=page_size)
|
|
106
|
+
|
|
107
|
+
if reporter is not None:
|
|
108
|
+
reporter(
|
|
109
|
+
tr(
|
|
110
|
+
"pagination: path={value_0} page_size={value_1} max_pages={value_2}",
|
|
111
|
+
value_0=path,
|
|
112
|
+
value_1=page_size,
|
|
113
|
+
value_2=max_pages,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
for page_index in range(max_pages):
|
|
118
|
+
page_params = {
|
|
119
|
+
**base_params,
|
|
120
|
+
"limit": page_size,
|
|
121
|
+
"offset": page_index * page_size,
|
|
122
|
+
}
|
|
123
|
+
response = await client.get(path, params=page_params, timeout=timeout)
|
|
124
|
+
page = (
|
|
125
|
+
extract_items(response)
|
|
126
|
+
if extract_items is not None
|
|
127
|
+
else list_page_items(response, path=path)
|
|
128
|
+
)
|
|
129
|
+
page_state = guard.observe(page, page_index=page_index)
|
|
130
|
+
if page_state == LEGACY_OVERSIZED_FIRST_PAGE:
|
|
131
|
+
report_legacy_pagination(
|
|
132
|
+
client,
|
|
133
|
+
path=path,
|
|
134
|
+
reason=page_state,
|
|
135
|
+
rows=len(page),
|
|
136
|
+
reporter=reporter,
|
|
137
|
+
)
|
|
138
|
+
return page
|
|
139
|
+
if page_state == LEGACY_REPEATED_FIRST_PAGE:
|
|
140
|
+
report_legacy_pagination(
|
|
141
|
+
client,
|
|
142
|
+
path=path,
|
|
143
|
+
reason=page_state,
|
|
144
|
+
rows=len(rows),
|
|
145
|
+
reporter=reporter,
|
|
146
|
+
)
|
|
147
|
+
return rows
|
|
148
|
+
if page_state == REPEATED_PAGE_AFTER_PROGRESS:
|
|
149
|
+
raise ConfigError(
|
|
150
|
+
tr(
|
|
151
|
+
"{value_0} pagination repeated a previously received page after progress; refusing to return a possibly duplicated or truncated result.",
|
|
152
|
+
value_0=path,
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
rows.extend(page)
|
|
156
|
+
if len(page) < page_size:
|
|
157
|
+
return rows
|
|
158
|
+
|
|
159
|
+
raise ConfigError(
|
|
160
|
+
tr(
|
|
161
|
+
"{value_0} pagination reached {value_1} full pages of {value_2} rows; refusing to return a possibly truncated result ({value_3} rows read). Increase max_pages after reviewing the expected collection size.",
|
|
162
|
+
value_0=path,
|
|
163
|
+
value_1=max_pages,
|
|
164
|
+
value_2=page_size,
|
|
165
|
+
value_3=len(rows),
|
|
166
|
+
)
|
|
167
|
+
)
|