Skip to content

Commit abf494d

Browse files
committed
release: prepare 0.6.0
1 parent 53c5726 commit abf494d

16 files changed

Lines changed: 923 additions & 2 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 0.6.0
4+
5+
### Added
6+
7+
- Add read-only `Api.export_draft_to_markdown(draft_id)` with the original draft, exported Markdown, and unsupported-node details.
8+
- Add `substack drafts export DRAFT_ID`, including stable JSON output and safe `--output` and `--force` file handling.
9+
- Reverse-render every Substack node supported by Markdown import, including formatting, nested lists, images and captions, code languages, footnotes, math, pull quotes, and callouts.
10+
- Preserve unsupported nodes and unknown fields as versioned, decodable `python-substack-node:v1` markers instead of silently dropping them.
11+
312
## 0.5.0
413

514
### Added

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Substack result:
5757
- Upload local images referenced by Markdown.
5858
- Set audience, comment permissions, SEO metadata, slug, sections, and tags.
5959
- List and inspect publications and drafts.
60+
- Export drafts to loss-aware Markdown backups without server writes.
6061
- Schedule, unschedule, publish, and delete drafts with explicit safeguards.
6162
- Use stable JSON envelopes in scripts and automation.
6263
- Authenticate with browser cookies or email and password.
@@ -107,6 +108,7 @@ Inspect publications and drafts:
107108
substack publications list
108109
substack drafts list --limit 10
109110
substack drafts get 12345
111+
substack drafts export 12345 --output backup.md
110112
substack --publication-url https://example.substack.com drafts list
111113
```
112114

@@ -171,6 +173,14 @@ print(result["draft"]["id"])
171173
`create_draft_from_markdown` creates a draft by default. It publishes only when
172174
`publish=True` is passed.
173175

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+
174184
For direct ProseMirror node construction, see the
175185
[low-level Python API](docs/low-level-api.md). YAML workflows are documented in
176186
[YAML drafts](docs/yaml.md).

docs/cli.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ substack drafts create post.md
1717
The title comes from the first Markdown heading, then the filename if the file
1818
has no heading. Use `--title` to override it.
1919

20+
## Export a draft to Markdown
21+
22+
Print a read-only Markdown backup to standard output:
23+
24+
```bash
25+
substack drafts export 12345
26+
```
27+
28+
Write it to a UTF-8 file:
29+
30+
```bash
31+
substack drafts export 12345 --output backup.md
32+
```
33+
34+
The command refuses to replace an existing file unless `--force` is present.
35+
It fetches the draft but performs no server writes. Content without a supported
36+
Markdown representation is emitted as a versioned opaque HTML comment instead
37+
of being silently discarded.
38+
2039
## Select a publication
2140

2241
List every publication available to the authenticated account:
@@ -49,10 +68,13 @@ substack drafts unschedule 12345
4968

5069
```bash
5170
substack --json drafts list --limit 10
71+
substack --json drafts export 12345
5272
```
5373

5474
The command writes a JSON object containing `drafts`, `count`, `filter`,
5575
`offset`, and `limit`. Errors are also JSON when `--json` is present.
76+
Export JSON contains `action`, `draft_id`, `markdown`, and
77+
`unsupported_nodes`.
5678

5779
## Publish and delete
5880

docs/markdown.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,25 @@ document remains valid. Unknown `:::` container names remain ordinary text.
173173
and add it in the Substack editor.
174174
- Widgets authored only in the Substack editor (buttons, polls, embeds, etc.)
175175
have no Markdown equivalent.
176+
177+
## Draft export and opaque nodes
178+
179+
`Api.export_draft_to_markdown(draft_id)` and `substack drafts export DRAFT_ID`
180+
reverse supported Substack nodes into Markdown. The export is read-only and
181+
returns every unsupported node separately in `unsupported_nodes`.
182+
183+
Unsupported nodes and supported nodes with unknown fields are also kept at
184+
their document position as:
185+
186+
```text
187+
<!-- python-substack-node:v1 BASE64URL_JSON -->
188+
```
189+
190+
The payload is UTF-8 JSON encoded with URL-safe base64 and no padding. This
191+
makes unsupported content visible and recoverable instead of silently dropping
192+
it. Version 0.6 does not import these markers into a draft; safe updates that
193+
preserve them are planned for 0.8.
194+
195+
Export preserves the Markdown meaning of supported images: source, alt text,
196+
link, and plain-text caption. Substack-only image layout attributes are not a
197+
Markdown contract in 0.6.

docs/python-sdk.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,21 @@ The method creates an unpublished draft by default. It publishes only when
3535
`publish=True` is passed. It can also set audience, comment permissions, SEO
3636
metadata, slug, section, and tags.
3737

38+
## Export a draft without modifying it
39+
40+
```python
41+
from pathlib import Path
42+
43+
result = api.export_draft_to_markdown(12345)
44+
45+
Path("backup.md").write_text(result["markdown"], encoding="utf-8")
46+
print(result["unsupported_nodes"])
47+
```
48+
49+
The result contains the original `draft`, exported `markdown`, and an
50+
`unsupported_nodes` list. The method performs one draft read and no server
51+
writes. Unsupported editor nodes remain embedded in the Markdown as opaque,
52+
decodable markers.
53+
3854
For supported syntax, see the [Markdown reference](markdown.md). For direct
3955
editor-node construction, see the [low-level Python API](low-level-api.md).

docs/releases/0.6.0.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
layout: default
3+
title: 0.6.0
4+
parent: Releases
5+
nav_order: 6
6+
---
7+
8+
# 0.6.0 Release
9+
10+
Version 0.6.0 adds loss-aware, read-only backups of Substack drafts as
11+
Markdown.
12+
13+
## Export from the CLI
14+
15+
Print a draft as Markdown without changing it on Substack:
16+
17+
```bash
18+
substack drafts export 12345
19+
```
20+
21+
Write a UTF-8 backup file, refusing accidental replacement by default:
22+
23+
```bash
24+
substack drafts export 12345 --output backup.md
25+
substack drafts export 12345 --output backup.md --force
26+
```
27+
28+
Global JSON mode returns `action`, `draft_id`, `markdown`, and
29+
`unsupported_nodes`.
30+
31+
## Export from Python
32+
33+
```python
34+
result = api.export_draft_to_markdown(12345)
35+
markdown = result["markdown"]
36+
unsupported = result["unsupported_nodes"]
37+
```
38+
39+
The method performs one draft read and no server writes.
40+
41+
## Loss-aware rendering
42+
43+
Export preserves the semantic Markdown content supported by import, including
44+
headings, text marks, links, nested lists, images, captions, code languages,
45+
blockquotes, footnotes, math, pull quotes, and callouts.
46+
47+
Substack editor nodes without a supported Markdown representation remain at
48+
their document position as versioned URL-safe base64 JSON markers:
49+
50+
```text
51+
<!-- python-substack-node:v1 BASE64URL_JSON -->
52+
```
53+
54+
They are also returned in `unsupported_nodes`, making unsupported content
55+
visible and recoverable rather than silently discarding it. Version 0.6 does
56+
not import opaque markers back into a draft.

docs/safety.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Creating, scheduling, publishing, and deleting are separate operations.
99

1010
| Operation | Effect | Confirmation |
1111
|---|---|---|
12+
| `drafts export` | Reads a draft and produces Markdown | None |
1213
| `drafts create` | Creates an unpublished draft | None |
1314
| `drafts schedule` | Adds a future release time | None |
1415
| `drafts unschedule` | Removes a future release time | None |
@@ -26,3 +27,7 @@ before they are printed by the CLI.
2627
Substack's interfaces are undocumented and may change. Keep a reviewed copy of
2728
important source Markdown and verify the selected publication with
2829
`substack status` before any write.
30+
31+
Export never writes to Substack and never overwrites a local file unless
32+
`--force` is supplied. Unsupported editor content is preserved as an opaque
33+
marker and listed in JSON output so it cannot disappear unnoticed.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-substack"
3-
version = "0.5.0"
3+
version = "0.6.0"
44
description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
55
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
66
license = "MIT"

substack/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
__author__ = "Paolo Mazza"
44
__email__ = "mazzapaolo2019@gmail.com"
55
__license__ = "MIT License"
6-
__version__ = "0.5.0"
6+
__version__ = "0.6.0"
77
__url__ = "https://github.com/ma2za/python-substack"
88
__download_url__ = "https://pypi.python.org/pypi/python-substack"
99
__description__ = (

substack/api.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,28 @@ def get_draft(self, draft_id):
442442
response = self._session.get(f"{self.publication_url}/drafts/{draft_id}")
443443
return Api._handle_response(response=response)
444444

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+
445467
def delete_draft(self, draft_id):
446468
"""
447469

0 commit comments

Comments
 (0)