Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 8.0.1
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 26.5.1
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 9.0.1
hooks:
- id: isort
name: isort (python)
args: ["--profile", "black"]
6 changes: 3 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ Do not add telemetry to the package, CLI, SDK, or MCP server.
| 0.4.0 | released | 2026-08-24 | v0.4.0 | - | - | - | - | - | [GitHub](https://github.com/ma2za/python-substack/releases/tag/v0.4.0) |
| 0.5.0 | released | 2026-08-30 | v0.5.0 | 166 | 28 | 14,448 | 47 | 88 | [GitHub](https://github.com/ma2za/python-substack/releases/tag/v0.5.0) |
| 0.6.0 | released | 2026-08-30 | v0.6.0 | 166 | 28 | 14,448 | 47 | 88 | [GitHub](https://github.com/ma2za/python-substack/releases/tag/v0.6.0); [Substack](https://mazzapaolo.substack.com/p/back-up-substack-draft-markdown-python-substack-060) |
| 0.7.0 | in progress | - | - | - | - | - | - | - | - |
| 0.8.0 | planned | - | - | - | - | - | - | - | - |
| 0.7.0 | released | 2026-09-22 | v0.7.0 | 166 | 28 | 14,448 | 47 | 88 | [GitHub](https://github.com/ma2za/python-substack/releases/tag/v0.7.0) |
| 0.8.0 | in progress | - | - | - | - | - | - | - | - |
| 0.9.0 | planned | - | - | - | - | - | - | - | - |
| 0.10.0 | planned | - | - | - | - | - | - | - | - |
| 1.0.0 | planned | - | - | - | 300 target | - | 3x baseline target | - | - | - |
Expand Down Expand Up @@ -449,7 +449,7 @@ without risking unsupported content.

## 0.8.0: Lossless unsupported widget preservation

**Status:** planned
**Status:** in progress

**Objective:** Allow exported drafts containing Substack-only widgets to be
updated without data loss.
Expand Down
27 changes: 21 additions & 6 deletions substack/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ def update_draft_from_markdown(
draft_section_id: int = None,
tags=None,
dry_run: bool = False,
allow_unsupported_change: bool = False,
) -> dict:
"""
Update an existing draft body from Markdown, with optional metadata changes.
Expand All @@ -497,12 +498,7 @@ def update_draft_from_markdown(
if not isinstance(draft_body, dict):
raise ValueError("Malformed draft body: draft_body must be a JSON object")

_, unsupported_nodes = document_to_markdown(draft_body)
if unsupported_nodes:
raise ValueError(
"Refusing to update: remote draft contains unsupported Substack nodes. "
"Export it first or remove the nodes manually to avoid data loss."
)
_, remote_unsupported = document_to_markdown(draft_body)

post = Post(
title=draft.get("title", ""),
Expand All @@ -515,6 +511,25 @@ def update_draft_from_markdown(
)
post.from_markdown(markdown, api=self)

_, submitted_unsupported = document_to_markdown(post.draft_body)

if not allow_unsupported_change:
remote_serialized = [
json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
for n in remote_unsupported
]
submitted_serialized = [
json.dumps(n, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
for n in submitted_unsupported
]
if sorted(remote_serialized) != sorted(submitted_serialized):
raise ValueError(
"Refusing to update: remote unsupported nodes are missing, duplicated, stale, or altered. "
"Export the draft first or set allow_unsupported_change=True to proceed."
)

unsupported_nodes = remote_unsupported

update_payload = {"draft_body": json.dumps(post.draft_body)}

if subtitle is not None:
Expand Down
5 changes: 5 additions & 0 deletions substack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,9 @@ def _drafts_create(api, args):


def _drafts_update(api, args):
if args.allow_unsupported_change and not args.yes:
raise CLIUsageError("--yes is required when using --allow-unsupported-change")

if not args.yes and (args.json_output or not sys.stdin.isatty()):
raise CLIUsageError("--yes is required in non-interactive or JSON mode")

Expand Down Expand Up @@ -347,6 +350,7 @@ def _drafts_update(api, args):
draft_section_id=args.draft_section_id,
tags=args.tags,
dry_run=args.dry_run,
allow_unsupported_change=args.allow_unsupported_change,
)

if args.json_output:
Expand Down Expand Up @@ -508,6 +512,7 @@ def _build_parser():
drafts_update.add_argument("--draft-section-id", type=int)
drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
drafts_update.add_argument("--dry-run", action="store_true")
drafts_update.add_argument("--allow-unsupported-change", action="store_true")
drafts_update.add_argument("--yes", action="store_true")
drafts_update.set_defaults(handler=_drafts_update)

Expand Down
Loading
Loading