Skip to content

Commit d4a63a5

Browse files
committed
feat: implement update_draft_from_markdown for 0.7.0
1 parent abf494d commit d4a63a5

4 files changed

Lines changed: 237 additions & 0 deletions

File tree

substack/api.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,95 @@ def export_draft_to_markdown(self, draft_id):
464464
"unsupported_nodes": unsupported_nodes,
465465
}
466466

467+
def update_draft_from_markdown(
468+
self,
469+
draft_id: int,
470+
markdown: str,
471+
*,
472+
subtitle: str = None,
473+
audience: str = None,
474+
write_comment_permissions: str = None,
475+
search_engine_title: str = None,
476+
search_engine_description: str = None,
477+
slug: str = None,
478+
draft_section_id: int = None,
479+
tags=None,
480+
dry_run: bool = False,
481+
) -> dict:
482+
"""
483+
Update an existing draft body from Markdown, with optional metadata changes.
484+
"""
485+
from substack.mdexport import document_to_markdown
486+
from substack.post import Post
487+
488+
draft = self.get_draft(draft_id)
489+
draft_body = draft.get("draft_body")
490+
if isinstance(draft_body, str):
491+
try:
492+
draft_body = json.loads(draft_body)
493+
except json.JSONDecodeError as exc:
494+
raise ValueError(
495+
"Malformed draft body: draft_body is not valid JSON"
496+
) from exc
497+
if not isinstance(draft_body, dict):
498+
raise ValueError("Malformed draft body: draft_body must be a JSON object")
499+
500+
_, unsupported_nodes = document_to_markdown(draft_body)
501+
if unsupported_nodes:
502+
raise ValueError(
503+
"Refusing to update: remote draft contains unsupported Substack nodes. "
504+
"Export it first or remove the nodes manually to avoid data loss."
505+
)
506+
507+
post = Post(
508+
title=draft.get("title", ""),
509+
subtitle=(
510+
subtitle if subtitle is not None else (draft.get("subtitle") or "")
511+
),
512+
user_id=self.get_user_id(),
513+
audience=audience,
514+
write_comment_permissions=write_comment_permissions,
515+
)
516+
post.from_markdown(markdown, api=self)
517+
518+
update_payload = {"draft_body": json.dumps(post.draft_body)}
519+
520+
if subtitle is not None:
521+
update_payload["subtitle"] = subtitle
522+
if audience is not None:
523+
update_payload["audience"] = audience
524+
if write_comment_permissions is not None:
525+
update_payload["write_comment_permissions"] = write_comment_permissions
526+
if search_engine_title is not None:
527+
update_payload["search_engine_title"] = search_engine_title
528+
if search_engine_description is not None:
529+
update_payload["search_engine_description"] = search_engine_description
530+
if slug is not None:
531+
update_payload["slug"] = slug
532+
if draft_section_id is not None:
533+
update_payload["draft_section_id"] = draft_section_id
534+
535+
tags_result = None
536+
updated_draft = draft
537+
538+
if not dry_run:
539+
updated_draft = self.put_draft(draft_id, **update_payload)
540+
541+
tags_list = Api._normalize_tags(tags)
542+
if tags_list:
543+
tags_result = self.add_tags_to_post(draft_id, tags_list)
544+
545+
return {
546+
"action": "update",
547+
"draft_id": draft_id,
548+
"dry_run": dry_run,
549+
"changed": not dry_run,
550+
"payload": update_payload,
551+
"draft": updated_draft,
552+
"tags": tags_result,
553+
"unsupported_nodes": unsupported_nodes,
554+
}
555+
467556
def delete_draft(self, draft_id):
468557
"""
469558

substack/cli.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,53 @@ def _drafts_create(api, args):
309309
print(f"Created draft {draft.get('id')}: {title}")
310310

311311

312+
def _drafts_update(api, args):
313+
if not args.yes and (args.json_output or not sys.stdin.isatty()):
314+
raise CLIUsageError("--yes is required in non-interactive or JSON mode")
315+
316+
markdown_file = Path(args.markdown_file)
317+
if not markdown_file.exists():
318+
raise CLIUsageError(f"File not found: {markdown_file}")
319+
320+
if not args.yes:
321+
action = "update" if not args.dry_run else "dry-run update"
322+
print(f"Ready to {action} draft {args.draft_id} from {markdown_file}")
323+
print("Substack nodes that are not supported by Markdown export/import")
324+
print("will cause the update to be refused to prevent data loss.")
325+
print()
326+
try:
327+
response = input(f"Confirm {action}? [y/N]: ")
328+
if response.lower() not in ["y", "yes"]:
329+
print("Aborted.")
330+
return
331+
except EOFError as exc:
332+
raise CLIUsageError(
333+
f"Confirm {action} requires confirmation or --yes"
334+
) from exc
335+
336+
markdown = markdown_file.read_text(encoding="utf-8")
337+
338+
result = api.update_draft_from_markdown(
339+
args.draft_id,
340+
markdown,
341+
subtitle=args.subtitle,
342+
audience=args.audience,
343+
write_comment_permissions=args.write_comment_permissions,
344+
search_engine_title=args.search_engine_title,
345+
search_engine_description=args.search_engine_description,
346+
slug=args.slug,
347+
draft_section_id=args.draft_section_id,
348+
tags=args.tags,
349+
dry_run=args.dry_run,
350+
)
351+
352+
if args.json_output:
353+
_print_json(result)
354+
else:
355+
status = "Dry-run updated" if args.dry_run else "Updated"
356+
print(f"{status} draft {args.draft_id}")
357+
358+
312359
def _drafts_export(api, args):
313360
output_path = Path(args.output) if args.output else None
314361
if output_path is not None and output_path.exists() and not args.force:
@@ -447,6 +494,23 @@ def _build_parser():
447494
drafts_create.add_argument("--tag", action="append", dest="tags", metavar="TAG")
448495
drafts_create.set_defaults(handler=_drafts_create)
449496

497+
drafts_update = draft_commands.add_parser(
498+
"update", help="Update a draft from a Markdown file."
499+
)
500+
drafts_update.add_argument("draft_id", type=int)
501+
drafts_update.add_argument("markdown_file", metavar="MARKDOWN_FILE")
502+
drafts_update.add_argument("--subtitle")
503+
drafts_update.add_argument("--audience")
504+
drafts_update.add_argument("--write-comment-permissions")
505+
drafts_update.add_argument("--search-engine-title")
506+
drafts_update.add_argument("--search-engine-description")
507+
drafts_update.add_argument("--slug")
508+
drafts_update.add_argument("--draft-section-id", type=int)
509+
drafts_update.add_argument("--tag", action="append", dest="tags", metavar="TAG")
510+
drafts_update.add_argument("--dry-run", action="store_true")
511+
drafts_update.add_argument("--yes", action="store_true")
512+
drafts_update.set_defaults(handler=_drafts_update)
513+
450514
drafts_export = draft_commands.add_parser(
451515
"export", help="Export a draft to Markdown without modifying it."
452516
)

tests/substack/test_api_update.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from unittest.mock import Mock, patch
2+
3+
import pytest
4+
5+
from substack import Api
6+
7+
8+
def test_update_draft_from_markdown_basic(monkeypatch):
9+
api = Api.__new__(Api)
10+
api.publication_url = "https://test.substack.com"
11+
mock_get_user_id = Mock(return_value=1)
12+
monkeypatch.setattr(api, "get_user_id", mock_get_user_id)
13+
mock_get_draft = Mock(
14+
return_value={"id": 42, "draft_body": '{"type":"doc","content":[]}'}
15+
)
16+
monkeypatch.setattr(api, "get_draft", mock_get_draft)
17+
mock_put_draft = Mock(return_value={"id": 42})
18+
monkeypatch.setattr(api, "put_draft", mock_put_draft)
19+
res = api.update_draft_from_markdown(42, "Updated content")
20+
assert res["action"] == "update"
21+
assert res["draft_id"] == 42
22+
assert res["dry_run"] is False
23+
assert mock_put_draft.called

tests/substack/test_cli_update.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import json
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
from substack import cli
7+
8+
9+
class UpdateOperationsApi:
10+
def __init__(self):
11+
self.calls = []
12+
13+
def update_draft_from_markdown(self, draft_id, markdown, **kwargs):
14+
self.calls.append(("update", draft_id, markdown, kwargs))
15+
return {
16+
"action": "update",
17+
"draft_id": draft_id,
18+
"dry_run": kwargs.get("dry_run", False),
19+
"changed": not kwargs.get("dry_run", False),
20+
"payload": {},
21+
"draft": {"id": draft_id},
22+
"tags": None,
23+
"unsupported_nodes": [],
24+
}
25+
26+
27+
def use_api(monkeypatch, mock_api):
28+
monkeypatch.setattr(cli, "_api_from_env", lambda **kw: mock_api)
29+
30+
31+
def test_drafts_update_requires_yes_in_json_mode(tmp_path, monkeypatch, capsys):
32+
api = UpdateOperationsApi()
33+
use_api(monkeypatch, api)
34+
md_file = tmp_path / "test.md"
35+
md_file.write_text("# Test", encoding="utf-8")
36+
assert cli.main(["--json", "drafts", "update", "42", str(md_file)]) == 2
37+
err = capsys.readouterr().err
38+
assert "requires" in err or "required" in err
39+
assert "--yes" in err
40+
assert api.calls == []
41+
42+
43+
def test_drafts_update_json_dry_run(tmp_path, monkeypatch, capsys):
44+
api = UpdateOperationsApi()
45+
use_api(monkeypatch, api)
46+
md_file = tmp_path / "test.md"
47+
md_file.write_text("# Test", encoding="utf-8")
48+
assert (
49+
cli.main(
50+
["--json", "drafts", "update", "42", str(md_file), "--dry-run", "--yes"]
51+
)
52+
== 0
53+
)
54+
out = json.loads(capsys.readouterr().out)
55+
assert out["action"] == "update"
56+
assert out["draft_id"] == 42
57+
assert out["dry_run"] is True
58+
assert api.calls[0][0] == "update"
59+
assert api.calls[0][1] == 42
60+
assert api.calls[0][2] == "# Test"
61+
assert api.calls[0][3]["dry_run"] is True

0 commit comments

Comments
 (0)