python-substack 0.5.0__tar.gz → 0.6.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.
- {python_substack-0.5.0 → python_substack-0.6.0}/PKG-INFO +11 -1
- {python_substack-0.5.0 → python_substack-0.6.0}/README.md +10 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/pyproject.toml +1 -1
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/__init__.py +1 -1
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/api.py +22 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/cli.py +34 -0
- python_substack-0.6.0/substack/mdexport.py +381 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/LICENSE +0 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/exceptions.py +0 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/mdrender.py +0 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/nodes.py +0 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack/post.py +0 -0
- {python_substack-0.5.0 → python_substack-0.6.0}/substack_mcp/mcp_server.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -93,6 +93,7 @@ Substack result:
|
|
|
93
93
|
- Upload local images referenced by Markdown.
|
|
94
94
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
95
95
|
- List and inspect publications and drafts.
|
|
96
|
+
- Export drafts to loss-aware Markdown backups without server writes.
|
|
96
97
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
97
98
|
- Use stable JSON envelopes in scripts and automation.
|
|
98
99
|
- Authenticate with browser cookies or email and password.
|
|
@@ -143,6 +144,7 @@ Inspect publications and drafts:
|
|
|
143
144
|
substack publications list
|
|
144
145
|
substack drafts list --limit 10
|
|
145
146
|
substack drafts get 12345
|
|
147
|
+
substack drafts export 12345 --output backup.md
|
|
146
148
|
substack --publication-url https://example.substack.com drafts list
|
|
147
149
|
```
|
|
148
150
|
|
|
@@ -207,6 +209,14 @@ print(result["draft"]["id"])
|
|
|
207
209
|
`create_draft_from_markdown` creates a draft by default. It publishes only when
|
|
208
210
|
`publish=True` is passed.
|
|
209
211
|
|
|
212
|
+
Back up an existing draft without modifying it:
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
backup = api.export_draft_to_markdown(12345)
|
|
216
|
+
print(backup["markdown"])
|
|
217
|
+
print(backup["unsupported_nodes"])
|
|
218
|
+
```
|
|
219
|
+
|
|
210
220
|
For direct ProseMirror node construction, see the
|
|
211
221
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
212
222
|
[YAML drafts](docs/yaml.md).
|
|
@@ -57,6 +57,7 @@ Substack result:
|
|
|
57
57
|
- Upload local images referenced by Markdown.
|
|
58
58
|
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
|
|
59
59
|
- List and inspect publications and drafts.
|
|
60
|
+
- Export drafts to loss-aware Markdown backups without server writes.
|
|
60
61
|
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
|
|
61
62
|
- Use stable JSON envelopes in scripts and automation.
|
|
62
63
|
- Authenticate with browser cookies or email and password.
|
|
@@ -107,6 +108,7 @@ Inspect publications and drafts:
|
|
|
107
108
|
substack publications list
|
|
108
109
|
substack drafts list --limit 10
|
|
109
110
|
substack drafts get 12345
|
|
111
|
+
substack drafts export 12345 --output backup.md
|
|
110
112
|
substack --publication-url https://example.substack.com drafts list
|
|
111
113
|
```
|
|
112
114
|
|
|
@@ -171,6 +173,14 @@ print(result["draft"]["id"])
|
|
|
171
173
|
`create_draft_from_markdown` creates a draft by default. It publishes only when
|
|
172
174
|
`publish=True` is passed.
|
|
173
175
|
|
|
176
|
+
Back up an existing draft without modifying it:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
backup = api.export_draft_to_markdown(12345)
|
|
180
|
+
print(backup["markdown"])
|
|
181
|
+
print(backup["unsupported_nodes"])
|
|
182
|
+
```
|
|
183
|
+
|
|
174
184
|
For direct ProseMirror node construction, see the
|
|
175
185
|
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
|
|
176
186
|
[YAML drafts](docs/yaml.md).
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.
|
|
6
|
+
__version__ = "0.6.0"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = (
|
|
@@ -442,6 +442,28 @@ class Api:
|
|
|
442
442
|
response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
|
|
443
443
|
return Api._handle_response(response=response)
|
|
444
444
|
|
|
445
|
+
def export_draft_to_markdown(self, draft_id):
|
|
446
|
+
from substack.mdexport import document_to_markdown
|
|
447
|
+
|
|
448
|
+
draft = self.get_draft(draft_id)
|
|
449
|
+
draft_body = draft.get("draft_body")
|
|
450
|
+
if isinstance(draft_body, str):
|
|
451
|
+
try:
|
|
452
|
+
draft_body = json.loads(draft_body)
|
|
453
|
+
except json.JSONDecodeError as exc:
|
|
454
|
+
raise ValueError(
|
|
455
|
+
"Malformed draft body: draft_body is not valid JSON"
|
|
456
|
+
) from exc
|
|
457
|
+
if not isinstance(draft_body, dict):
|
|
458
|
+
raise ValueError("Malformed draft body: draft_body must be a JSON object")
|
|
459
|
+
|
|
460
|
+
markdown, unsupported_nodes = document_to_markdown(draft_body)
|
|
461
|
+
return {
|
|
462
|
+
"draft": draft,
|
|
463
|
+
"markdown": markdown,
|
|
464
|
+
"unsupported_nodes": unsupported_nodes,
|
|
465
|
+
}
|
|
466
|
+
|
|
445
467
|
def delete_draft(self, draft_id):
|
|
446
468
|
"""
|
|
447
469
|
|
|
@@ -309,6 +309,32 @@ def _drafts_create(api, args):
|
|
|
309
309
|
print(f"Created draft {draft.get('id')}: {title}")
|
|
310
310
|
|
|
311
311
|
|
|
312
|
+
def _drafts_export(api, args):
|
|
313
|
+
output_path = Path(args.output) if args.output else None
|
|
314
|
+
if output_path is not None and output_path.exists() and not args.force:
|
|
315
|
+
raise CLIUsageError(
|
|
316
|
+
f"Output file already exists: {output_path}; use --force to overwrite"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
result = api.export_draft_to_markdown(args.draft_id)
|
|
320
|
+
markdown = result["markdown"]
|
|
321
|
+
if output_path is not None:
|
|
322
|
+
output_path.write_text(markdown, encoding="utf-8")
|
|
323
|
+
|
|
324
|
+
payload = {
|
|
325
|
+
"action": "export",
|
|
326
|
+
"draft_id": args.draft_id,
|
|
327
|
+
"markdown": markdown,
|
|
328
|
+
"unsupported_nodes": result["unsupported_nodes"],
|
|
329
|
+
}
|
|
330
|
+
if args.json_output:
|
|
331
|
+
_print_json(payload)
|
|
332
|
+
elif output_path is not None:
|
|
333
|
+
print(f"Exported draft {args.draft_id} to {output_path}")
|
|
334
|
+
else:
|
|
335
|
+
print(markdown, end="")
|
|
336
|
+
|
|
337
|
+
|
|
312
338
|
def _drafts_schedule(api, args):
|
|
313
339
|
scheduled_at = _parse_schedule(args.at)
|
|
314
340
|
result = api.schedule_draft(args.draft_id, scheduled_at)
|
|
@@ -421,6 +447,14 @@ def _build_parser():
|
|
|
421
447
|
drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
|
|
422
448
|
drafts_create.set_defaults(handler=_drafts_create)
|
|
423
449
|
|
|
450
|
+
drafts_export = draft_commands.add_parser(
|
|
451
|
+
"export", help="Export a draft to Markdown without modifying it."
|
|
452
|
+
)
|
|
453
|
+
drafts_export.add_argument("draft_id", type=int)
|
|
454
|
+
drafts_export.add_argument("--output", metavar="PATH")
|
|
455
|
+
drafts_export.add_argument("--force", action="store_true")
|
|
456
|
+
drafts_export.set_defaults(handler=_drafts_export)
|
|
457
|
+
|
|
424
458
|
drafts_schedule = draft_commands.add_parser("schedule", help="Schedule a draft.")
|
|
425
459
|
drafts_schedule.add_argument("draft_id", type=int)
|
|
426
460
|
drafts_schedule.add_argument("--at", required=True)
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import copy
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
_BLOCK_TYPES = {
|
|
7
|
+
"paragraph",
|
|
8
|
+
"heading",
|
|
9
|
+
"blockquote",
|
|
10
|
+
"codeBlock",
|
|
11
|
+
"horizontal_rule",
|
|
12
|
+
"bullet_list",
|
|
13
|
+
"ordered_list",
|
|
14
|
+
"captionedImage",
|
|
15
|
+
"footnote",
|
|
16
|
+
"latex_block",
|
|
17
|
+
"pullquote",
|
|
18
|
+
"calloutBlock",
|
|
19
|
+
}
|
|
20
|
+
_MARK_TYPES = {
|
|
21
|
+
"strong",
|
|
22
|
+
"em",
|
|
23
|
+
"code",
|
|
24
|
+
"strikethrough",
|
|
25
|
+
"superscript",
|
|
26
|
+
"subscript",
|
|
27
|
+
"link",
|
|
28
|
+
}
|
|
29
|
+
_IMAGE_ATTRS = {
|
|
30
|
+
"src",
|
|
31
|
+
"srcNoWatermark",
|
|
32
|
+
"fullscreen",
|
|
33
|
+
"imageSize",
|
|
34
|
+
"height",
|
|
35
|
+
"width",
|
|
36
|
+
"resizeWidth",
|
|
37
|
+
"bytes",
|
|
38
|
+
"alt",
|
|
39
|
+
"title",
|
|
40
|
+
"type",
|
|
41
|
+
"href",
|
|
42
|
+
"belowTheFold",
|
|
43
|
+
"topImage",
|
|
44
|
+
"internalRedirect",
|
|
45
|
+
"isProcessing",
|
|
46
|
+
"align",
|
|
47
|
+
"offset",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _marker(node, unsupported):
|
|
52
|
+
unsupported.append(copy.deepcopy(node))
|
|
53
|
+
payload = json.dumps(
|
|
54
|
+
node, ensure_ascii=False, separators=(",", ":"), sort_keys=True
|
|
55
|
+
).encode("utf-8")
|
|
56
|
+
encoded = base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=")
|
|
57
|
+
return f"<!-- python-substack-node:v1 {encoded} -->"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _require_dict(node, context):
|
|
61
|
+
if not isinstance(node, dict):
|
|
62
|
+
raise ValueError(f"Malformed draft body: {context} must be an object")
|
|
63
|
+
if not isinstance(node.get("type"), str) or not node["type"]:
|
|
64
|
+
raise ValueError(f"Malformed draft body: {context} has no node type")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _content(node, context):
|
|
68
|
+
content = node.get("content", [])
|
|
69
|
+
if not isinstance(content, list):
|
|
70
|
+
raise ValueError(f"Malformed draft body: {context} content must be a list")
|
|
71
|
+
return content
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _attrs(node, context):
|
|
75
|
+
attrs = node.get("attrs", {})
|
|
76
|
+
if not isinstance(attrs, dict):
|
|
77
|
+
raise ValueError(f"Malformed draft body: {context} attrs must be an object")
|
|
78
|
+
return attrs
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _has_unknown_keys(value, allowed):
|
|
82
|
+
return bool(set(value) - set(allowed))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _escape_text(value):
|
|
86
|
+
return re.sub(r"([\\`*_{}\[\]()<>#+\-.!|])", r"\\\1", value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _escape_destination(value):
|
|
90
|
+
return str(value).replace("\\", "\\\\").replace(")", "\\)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _escape_title(value):
|
|
94
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _code_span(value):
|
|
98
|
+
runs = [len(match.group(0)) for match in re.finditer(r"`+", value)]
|
|
99
|
+
delimiter = "`" * max(1, (max(runs) + 1) if runs else 1)
|
|
100
|
+
padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else ""
|
|
101
|
+
return f"{delimiter}{padding}{value}{padding}{delimiter}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _render_marked_text(value, marks, node, unsupported):
|
|
105
|
+
if not isinstance(value, str) or not isinstance(marks, list):
|
|
106
|
+
raise ValueError("Malformed draft body: text and marks have invalid types")
|
|
107
|
+
for mark in marks:
|
|
108
|
+
_require_dict(mark, "mark")
|
|
109
|
+
if mark["type"] not in _MARK_TYPES:
|
|
110
|
+
return _marker(node, unsupported)
|
|
111
|
+
allowed = {"type", "attrs"} if mark["type"] == "link" else {"type"}
|
|
112
|
+
if _has_unknown_keys(mark, allowed):
|
|
113
|
+
return _marker(node, unsupported)
|
|
114
|
+
|
|
115
|
+
rendered = (
|
|
116
|
+
_code_span(value)
|
|
117
|
+
if any(mark["type"] == "code" for mark in marks)
|
|
118
|
+
else _escape_text(value)
|
|
119
|
+
)
|
|
120
|
+
for mark in marks:
|
|
121
|
+
mark_type = mark["type"]
|
|
122
|
+
if mark_type == "code":
|
|
123
|
+
continue
|
|
124
|
+
if mark_type == "strong":
|
|
125
|
+
rendered = f"**{rendered}**"
|
|
126
|
+
elif mark_type == "em":
|
|
127
|
+
rendered = f"*{rendered}*"
|
|
128
|
+
elif mark_type == "strikethrough":
|
|
129
|
+
rendered = f"~~{rendered}~~"
|
|
130
|
+
elif mark_type == "superscript":
|
|
131
|
+
rendered = f"^{rendered}^"
|
|
132
|
+
elif mark_type == "subscript":
|
|
133
|
+
rendered = f"~{rendered}~"
|
|
134
|
+
elif mark_type == "link":
|
|
135
|
+
attrs = _attrs(mark, "link mark")
|
|
136
|
+
if _has_unknown_keys(attrs, {"href"}) or not isinstance(
|
|
137
|
+
attrs.get("href"), str
|
|
138
|
+
):
|
|
139
|
+
return _marker(node, unsupported)
|
|
140
|
+
rendered = f"[{rendered}]({_escape_destination(attrs['href'])})"
|
|
141
|
+
return rendered
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _render_inline(nodes, unsupported):
|
|
145
|
+
if not isinstance(nodes, list):
|
|
146
|
+
raise ValueError("Malformed draft body: inline content must be a list")
|
|
147
|
+
rendered = []
|
|
148
|
+
for node in nodes:
|
|
149
|
+
_require_dict(node, "inline node")
|
|
150
|
+
node_type = node["type"]
|
|
151
|
+
if node_type == "text":
|
|
152
|
+
if _has_unknown_keys(node, {"type", "text", "marks"}):
|
|
153
|
+
rendered.append(_marker(node, unsupported))
|
|
154
|
+
continue
|
|
155
|
+
rendered.append(
|
|
156
|
+
_render_marked_text(
|
|
157
|
+
node.get("text"), node.get("marks", []), node, unsupported
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
elif node_type == "footnoteAnchor":
|
|
161
|
+
attrs = _attrs(node, "footnote anchor")
|
|
162
|
+
number = attrs.get("number")
|
|
163
|
+
if (
|
|
164
|
+
_has_unknown_keys(node, {"type", "attrs"})
|
|
165
|
+
or _has_unknown_keys(attrs, {"number"})
|
|
166
|
+
or not isinstance(number, int)
|
|
167
|
+
):
|
|
168
|
+
rendered.append(_marker(node, unsupported))
|
|
169
|
+
else:
|
|
170
|
+
rendered.append(f"[^{number}]")
|
|
171
|
+
elif node_type == "latex":
|
|
172
|
+
attrs = _attrs(node, "inline math")
|
|
173
|
+
expression = attrs.get("expression", attrs.get("persistentExpression"))
|
|
174
|
+
if (
|
|
175
|
+
_has_unknown_keys(node, {"type", "attrs"})
|
|
176
|
+
or _has_unknown_keys(attrs, {"expression", "persistentExpression"})
|
|
177
|
+
or not isinstance(expression, str)
|
|
178
|
+
):
|
|
179
|
+
rendered.append(_marker(node, unsupported))
|
|
180
|
+
else:
|
|
181
|
+
rendered.append(f"${expression}$")
|
|
182
|
+
else:
|
|
183
|
+
rendered.append(_marker(node, unsupported))
|
|
184
|
+
return "".join(rendered)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _render_list(node, unsupported, ordered=False):
|
|
188
|
+
lines = []
|
|
189
|
+
for index, item in enumerate(_content(node, "list"), start=1):
|
|
190
|
+
_require_dict(item, "list item")
|
|
191
|
+
if item["type"] != "list_item" or _has_unknown_keys(item, {"type", "content"}):
|
|
192
|
+
item_text = _marker(item, unsupported)
|
|
193
|
+
else:
|
|
194
|
+
item_blocks = [
|
|
195
|
+
_render_block(child, unsupported)
|
|
196
|
+
for child in _content(item, "list item")
|
|
197
|
+
]
|
|
198
|
+
item_text = "\n\n".join(item_blocks)
|
|
199
|
+
prefix = f"{index}. " if ordered else "- "
|
|
200
|
+
item_lines = item_text.splitlines() or [""]
|
|
201
|
+
lines.append(prefix + item_lines[0])
|
|
202
|
+
lines.extend(" " + line if line else "" for line in item_lines[1:])
|
|
203
|
+
return "\n".join(lines)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _render_image(node, unsupported):
|
|
207
|
+
content = _content(node, "captioned image")
|
|
208
|
+
if not content:
|
|
209
|
+
raise ValueError("Malformed draft body: captioned image has no image")
|
|
210
|
+
image = content[0]
|
|
211
|
+
_require_dict(image, "image")
|
|
212
|
+
attrs = _attrs(image, "image")
|
|
213
|
+
if (
|
|
214
|
+
image["type"] != "image2"
|
|
215
|
+
or _has_unknown_keys(node, {"type", "content"})
|
|
216
|
+
or _has_unknown_keys(image, {"type", "attrs"})
|
|
217
|
+
or _has_unknown_keys(attrs, _IMAGE_ATTRS)
|
|
218
|
+
or not isinstance(attrs.get("src"), str)
|
|
219
|
+
):
|
|
220
|
+
return _marker(node, unsupported)
|
|
221
|
+
|
|
222
|
+
alt = str(attrs.get("alt") or "").replace("\\", "\\\\").replace("]", "\\]")
|
|
223
|
+
image_markdown = f"}"
|
|
224
|
+
if len(content) > 2:
|
|
225
|
+
return _marker(node, unsupported)
|
|
226
|
+
if len(content) == 2:
|
|
227
|
+
caption = content[1]
|
|
228
|
+
_require_dict(caption, "image caption")
|
|
229
|
+
caption_nodes = _content(caption, "image caption")
|
|
230
|
+
if (
|
|
231
|
+
caption["type"] != "caption"
|
|
232
|
+
or _has_unknown_keys(caption, {"type", "content"})
|
|
233
|
+
or any(
|
|
234
|
+
not isinstance(child, dict)
|
|
235
|
+
or child.get("type") != "text"
|
|
236
|
+
or child.get("marks")
|
|
237
|
+
or not isinstance(child.get("text"), str)
|
|
238
|
+
for child in caption_nodes
|
|
239
|
+
)
|
|
240
|
+
):
|
|
241
|
+
return _marker(node, unsupported)
|
|
242
|
+
caption_text = "".join(child.get("text", "") for child in caption_nodes)
|
|
243
|
+
image_markdown += f' "{_escape_title(caption_text)}"'
|
|
244
|
+
image_markdown += ")"
|
|
245
|
+
href = attrs.get("href")
|
|
246
|
+
if href:
|
|
247
|
+
if not isinstance(href, str):
|
|
248
|
+
return _marker(node, unsupported)
|
|
249
|
+
image_markdown = f"[{image_markdown}]({_escape_destination(href)})"
|
|
250
|
+
return image_markdown
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _render_footnote(node, unsupported):
|
|
254
|
+
attrs = _attrs(node, "footnote")
|
|
255
|
+
number = attrs.get("number")
|
|
256
|
+
if (
|
|
257
|
+
_has_unknown_keys(node, {"type", "attrs", "content"})
|
|
258
|
+
or _has_unknown_keys(attrs, {"number"})
|
|
259
|
+
or not isinstance(number, int)
|
|
260
|
+
):
|
|
261
|
+
return _marker(node, unsupported)
|
|
262
|
+
body = "\n\n".join(
|
|
263
|
+
_render_block(child, unsupported) for child in _content(node, "footnote")
|
|
264
|
+
)
|
|
265
|
+
lines = body.splitlines() or [""]
|
|
266
|
+
return f"[^{number}]: {lines[0]}" + "".join(
|
|
267
|
+
f"\n {line}" if line else "\n" for line in lines[1:]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _render_block(node, unsupported):
|
|
272
|
+
_require_dict(node, "block node")
|
|
273
|
+
node_type = node["type"]
|
|
274
|
+
if node_type not in _BLOCK_TYPES:
|
|
275
|
+
return _marker(node, unsupported)
|
|
276
|
+
|
|
277
|
+
if node_type == "paragraph":
|
|
278
|
+
if _has_unknown_keys(node, {"type", "content"}):
|
|
279
|
+
return _marker(node, unsupported)
|
|
280
|
+
return _render_inline(_content(node, "paragraph"), unsupported)
|
|
281
|
+
if node_type == "heading":
|
|
282
|
+
attrs = _attrs(node, "heading")
|
|
283
|
+
level = attrs.get("level")
|
|
284
|
+
if (
|
|
285
|
+
_has_unknown_keys(node, {"type", "content", "attrs"})
|
|
286
|
+
or _has_unknown_keys(attrs, {"level"})
|
|
287
|
+
or not isinstance(level, int)
|
|
288
|
+
or not 1 <= level <= 6
|
|
289
|
+
):
|
|
290
|
+
return _marker(node, unsupported)
|
|
291
|
+
return f"{'#' * level} {_render_inline(_content(node, 'heading'), unsupported)}"
|
|
292
|
+
if node_type == "horizontal_rule":
|
|
293
|
+
return (
|
|
294
|
+
"---"
|
|
295
|
+
if not _has_unknown_keys(node, {"type"})
|
|
296
|
+
else _marker(node, unsupported)
|
|
297
|
+
)
|
|
298
|
+
if node_type == "codeBlock":
|
|
299
|
+
attrs = _attrs(node, "code block")
|
|
300
|
+
if _has_unknown_keys(node, {"type", "content", "attrs"}) or _has_unknown_keys(
|
|
301
|
+
attrs, {"language"}
|
|
302
|
+
):
|
|
303
|
+
return _marker(node, unsupported)
|
|
304
|
+
code_nodes = _content(node, "code block")
|
|
305
|
+
if any(
|
|
306
|
+
not isinstance(child, dict)
|
|
307
|
+
or child.get("type") != "text"
|
|
308
|
+
or _has_unknown_keys(child, {"type", "text"})
|
|
309
|
+
or not isinstance(child.get("text"), str)
|
|
310
|
+
for child in code_nodes
|
|
311
|
+
):
|
|
312
|
+
raise ValueError("Malformed draft body: invalid code block content")
|
|
313
|
+
code = "".join(child["text"] for child in code_nodes)
|
|
314
|
+
runs = [len(match.group(0)) for match in re.finditer(r"`+", code)]
|
|
315
|
+
fence = "`" * max(3, (max(runs) + 1) if runs else 3)
|
|
316
|
+
language = attrs.get("language") or ""
|
|
317
|
+
if not isinstance(language, str) or "\n" in language:
|
|
318
|
+
return _marker(node, unsupported)
|
|
319
|
+
return f"{fence}{language}\n{code}\n{fence}"
|
|
320
|
+
if node_type == "blockquote":
|
|
321
|
+
if _has_unknown_keys(node, {"type", "content"}):
|
|
322
|
+
return _marker(node, unsupported)
|
|
323
|
+
body = "\n\n".join(
|
|
324
|
+
_render_block(child, unsupported) for child in _content(node, "blockquote")
|
|
325
|
+
)
|
|
326
|
+
return "\n".join(">" if not line else f"> {line}" for line in body.splitlines())
|
|
327
|
+
if node_type in {"bullet_list", "ordered_list"}:
|
|
328
|
+
if _has_unknown_keys(node, {"type", "content"}):
|
|
329
|
+
return _marker(node, unsupported)
|
|
330
|
+
return _render_list(node, unsupported, ordered=node_type == "ordered_list")
|
|
331
|
+
if node_type == "captionedImage":
|
|
332
|
+
return _render_image(node, unsupported)
|
|
333
|
+
if node_type == "footnote":
|
|
334
|
+
return _render_footnote(node, unsupported)
|
|
335
|
+
if node_type == "latex_block":
|
|
336
|
+
attrs = _attrs(node, "math block")
|
|
337
|
+
expression = attrs.get("persistentExpression", attrs.get("expression"))
|
|
338
|
+
if (
|
|
339
|
+
_has_unknown_keys(node, {"type", "attrs"})
|
|
340
|
+
or _has_unknown_keys(attrs, {"persistentExpression", "expression", "dirty"})
|
|
341
|
+
or not isinstance(expression, str)
|
|
342
|
+
):
|
|
343
|
+
return _marker(node, unsupported)
|
|
344
|
+
return f"$$\n{expression}\n$$"
|
|
345
|
+
if node_type in {"pullquote", "calloutBlock"}:
|
|
346
|
+
allowed = (
|
|
347
|
+
{"type", "content", "attrs"}
|
|
348
|
+
if node_type == "pullquote"
|
|
349
|
+
else {"type", "content"}
|
|
350
|
+
)
|
|
351
|
+
if _has_unknown_keys(node, allowed):
|
|
352
|
+
return _marker(node, unsupported)
|
|
353
|
+
if node_type == "pullquote":
|
|
354
|
+
attrs = _attrs(node, "pull quote")
|
|
355
|
+
if _has_unknown_keys(attrs, {"align", "color"}) or any(
|
|
356
|
+
attrs.get(key) is not None for key in ("align", "color")
|
|
357
|
+
):
|
|
358
|
+
return _marker(node, unsupported)
|
|
359
|
+
body = "\n\n".join(
|
|
360
|
+
_render_block(child, unsupported) for child in _content(node, node_type)
|
|
361
|
+
)
|
|
362
|
+
name = "pullquote" if node_type == "pullquote" else "callout"
|
|
363
|
+
return f"::: {name}\n{body}\n:::"
|
|
364
|
+
raise AssertionError(node_type)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def document_to_markdown(document):
|
|
368
|
+
_require_dict(document, "document")
|
|
369
|
+
if document["type"] != "doc":
|
|
370
|
+
raise ValueError("Malformed draft body: root node must have type 'doc'")
|
|
371
|
+
if _has_unknown_keys(document, {"type", "content"}):
|
|
372
|
+
raise ValueError("Malformed draft body: document has unsupported root fields")
|
|
373
|
+
|
|
374
|
+
unsupported = []
|
|
375
|
+
blocks = [
|
|
376
|
+
_render_block(node, unsupported) for node in _content(document, "document")
|
|
377
|
+
]
|
|
378
|
+
markdown = "\n\n".join(blocks)
|
|
379
|
+
if markdown:
|
|
380
|
+
markdown += "\n"
|
|
381
|
+
return markdown, unsupported
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|