python-substack 0.1.21__tar.gz → 0.1.23__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.1.21 → python_substack-0.1.23}/PKG-INFO +16 -1
- {python_substack-0.1.21 → python_substack-0.1.23}/README.md +15 -0
- {python_substack-0.1.21 → python_substack-0.1.23}/pyproject.toml +1 -1
- {python_substack-0.1.21 → python_substack-0.1.23}/substack/__init__.py +1 -1
- {python_substack-0.1.21 → python_substack-0.1.23}/substack/post.py +329 -25
- {python_substack-0.1.21 → python_substack-0.1.23}/LICENSE +0 -0
- {python_substack-0.1.21 → python_substack-0.1.23}/substack/api.py +0 -0
- {python_substack-0.1.21 → python_substack-0.1.23}/substack/exceptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-substack
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.23
|
|
4
4
|
Summary: A Python wrapper around the Substack API.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -180,6 +180,21 @@ This is a paragraph with **bold** and *italic* text.
|
|
|
180
180
|
"""
|
|
181
181
|
post.from_markdown(markdown_content, api=api)
|
|
182
182
|
|
|
183
|
+
# Markdown footnotes are supported too. References become inline anchors and
|
|
184
|
+
# definitions become footnote blocks, numbered by order of first appearance.
|
|
185
|
+
# Labels can be numbers or names (e.g. [^1] or [^source]).
|
|
186
|
+
footnote_markdown = """
|
|
187
|
+
A claim that needs support.[^1] Another, with a named label.[^source]
|
|
188
|
+
|
|
189
|
+
[^1]: The supporting detail, with a [link](https://example.com).
|
|
190
|
+
[^source]: Author, *Title* (2025).
|
|
191
|
+
"""
|
|
192
|
+
post.from_markdown(footnote_markdown, api=api)
|
|
193
|
+
|
|
194
|
+
# Or build footnotes manually:
|
|
195
|
+
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
196
|
+
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
197
|
+
|
|
183
198
|
draft = api.post_draft(post.get_draft())
|
|
184
199
|
|
|
185
200
|
# set section (can only be done after first posting the draft)
|
|
@@ -156,6 +156,21 @@ This is a paragraph with **bold** and *italic* text.
|
|
|
156
156
|
"""
|
|
157
157
|
post.from_markdown(markdown_content, api=api)
|
|
158
158
|
|
|
159
|
+
# Markdown footnotes are supported too. References become inline anchors and
|
|
160
|
+
# definitions become footnote blocks, numbered by order of first appearance.
|
|
161
|
+
# Labels can be numbers or names (e.g. [^1] or [^source]).
|
|
162
|
+
footnote_markdown = """
|
|
163
|
+
A claim that needs support.[^1] Another, with a named label.[^source]
|
|
164
|
+
|
|
165
|
+
[^1]: The supporting detail, with a [link](https://example.com).
|
|
166
|
+
[^source]: Author, *Title* (2025).
|
|
167
|
+
"""
|
|
168
|
+
post.from_markdown(footnote_markdown, api=api)
|
|
169
|
+
|
|
170
|
+
# Or build footnotes manually:
|
|
171
|
+
post.paragraph(content=[{"content": "Some claim."}]).footnote_anchor(1)
|
|
172
|
+
post.footnote(1, "The note text, with **formatting** allowed.")
|
|
173
|
+
|
|
159
174
|
draft = api.post_draft(post.get_draft())
|
|
160
175
|
|
|
161
176
|
# set section (can only be done after first posting the draft)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
__author__ = "Paolo Mazza"
|
|
4
4
|
__email__ = "mazzapaolo2019@gmail.com"
|
|
5
5
|
__license__ = "MIT License"
|
|
6
|
-
__version__ = "0.1.
|
|
6
|
+
__version__ = "0.1.23"
|
|
7
7
|
__url__ = "https://github.com/ma2za/python-substack"
|
|
8
8
|
__download_url__ = "https://pypi.python.org/pypi/python-substack"
|
|
9
9
|
__description__ = "A Python wrapper around the Substack API"
|
|
@@ -8,10 +8,32 @@ import json
|
|
|
8
8
|
import re
|
|
9
9
|
from typing import Dict, List
|
|
10
10
|
|
|
11
|
-
__all__ = ["Post", "parse_inline"]
|
|
11
|
+
__all__ = ["Post", "parse_inline", "tokens_to_text_nodes"]
|
|
12
12
|
|
|
13
13
|
from substack.exceptions import SectionNotExistsException
|
|
14
14
|
|
|
15
|
+
# Markdown footnotes: ``text.[^label]`` references and ``[^label]: definition`` lines.
|
|
16
|
+
FOOTNOTE_REFERENCE_PATTERN = re.compile(r"\[\^([^\]]+)\]")
|
|
17
|
+
FOOTNOTE_DEFINITION_PATTERN = re.compile(r"^\[\^([^\]]+)\]:\s?(.*)$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tokens_to_text_nodes(tokens: List[Dict]) -> List[Dict]:
|
|
21
|
+
"""Convert parse_inline() tokens to ProseMirror text nodes.
|
|
22
|
+
|
|
23
|
+
parse_inline() returns {"content": "text", "marks": [...]}.
|
|
24
|
+
ProseMirror expects {"type": "text", "text": "text", "marks": [...]}.
|
|
25
|
+
"""
|
|
26
|
+
nodes = []
|
|
27
|
+
for token in tokens:
|
|
28
|
+
if not token or not token.get("content"):
|
|
29
|
+
continue
|
|
30
|
+
node = {"type": "text", "text": token["content"]}
|
|
31
|
+
marks = token.get("marks")
|
|
32
|
+
if marks:
|
|
33
|
+
node["marks"] = marks
|
|
34
|
+
nodes.append(node)
|
|
35
|
+
return nodes
|
|
36
|
+
|
|
15
37
|
|
|
16
38
|
def parse_inline(text: str) -> List[Dict]:
|
|
17
39
|
"""
|
|
@@ -19,8 +41,11 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
19
41
|
for use in the post content.
|
|
20
42
|
|
|
21
43
|
Supported formatting:
|
|
44
|
+
- `code`: Text wrapped in backticks.
|
|
22
45
|
- **Bold**: Text wrapped in double asterisks.
|
|
23
46
|
- *Italic*: Text wrapped in single asterisks.
|
|
47
|
+
- ***Bold+Italic***: Text wrapped in triple asterisks.
|
|
48
|
+
- ~~Strikethrough~~: Text wrapped in double tildes.
|
|
24
49
|
- [Links]: Text wrapped in square brackets followed by URL in parentheses.
|
|
25
50
|
|
|
26
51
|
Args:
|
|
@@ -37,33 +62,50 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
37
62
|
return []
|
|
38
63
|
|
|
39
64
|
tokens = []
|
|
40
|
-
# Process text character by character to handle nested formatting
|
|
41
|
-
# We'll use regex to find all markdown patterns, then process them in order
|
|
42
65
|
|
|
43
|
-
#
|
|
44
|
-
|
|
66
|
+
# Pattern order matters: code > links > bold+italic > bold > italic > strikethrough
|
|
67
|
+
code_pattern = r'`([^`]+)`'
|
|
45
68
|
link_pattern = r'\[([^\]]+)\]\(([^)]+)\)'
|
|
69
|
+
bold_italic_pattern = r'\*\*\*([^*]+)\*\*\*'
|
|
46
70
|
bold_pattern = r'\*\*([^*]+)\*\*'
|
|
47
71
|
italic_pattern = r'(?<!\*)\*([^*]+)\*(?!\*)' # Not preceded or followed by *
|
|
72
|
+
strikethrough_pattern = r'~~([^~]+)~~'
|
|
48
73
|
|
|
49
74
|
# Find all matches with their positions
|
|
50
75
|
matches = []
|
|
76
|
+
|
|
77
|
+
# Inline code FIRST -- content inside backticks must not be parsed for other formatting
|
|
78
|
+
for match in re.finditer(code_pattern, text):
|
|
79
|
+
matches.append((match.start(), match.end(), "code", match.group(1), None))
|
|
80
|
+
|
|
81
|
+
# Links
|
|
51
82
|
for match in re.finditer(link_pattern, text):
|
|
52
83
|
# Skip if it's an image link (starts with ![)
|
|
53
84
|
# But do NOT skip normal links at position 0.
|
|
54
85
|
if match.start() == 0 or text[match.start()-1:match.start()+1] != "![":
|
|
55
|
-
|
|
86
|
+
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
87
|
+
matches.append((match.start(), match.end(), "link", match.group(1), match.group(2)))
|
|
88
|
+
|
|
89
|
+
# Bold+italic combo
|
|
90
|
+
for match in re.finditer(bold_italic_pattern, text):
|
|
91
|
+
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
92
|
+
matches.append((match.start(), match.end(), "bold_italic", match.group(1), None))
|
|
56
93
|
|
|
94
|
+
# Bold
|
|
57
95
|
for match in re.finditer(bold_pattern, text):
|
|
58
|
-
# Check if this range is already covered by a link
|
|
59
96
|
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
60
97
|
matches.append((match.start(), match.end(), "bold", match.group(1), None))
|
|
61
98
|
|
|
99
|
+
# Italic
|
|
62
100
|
for match in re.finditer(italic_pattern, text):
|
|
63
|
-
# Check if this range is already covered by a link or bold
|
|
64
101
|
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
65
102
|
matches.append((match.start(), match.end(), "italic", match.group(1), None))
|
|
66
103
|
|
|
104
|
+
# Strikethrough
|
|
105
|
+
for match in re.finditer(strikethrough_pattern, text):
|
|
106
|
+
if not any(start <= match.start() < end for start, end, _, _, _ in matches):
|
|
107
|
+
matches.append((match.start(), match.end(), "strikethrough", match.group(1), None))
|
|
108
|
+
|
|
67
109
|
# Sort matches by position
|
|
68
110
|
matches.sort(key=lambda x: x[0])
|
|
69
111
|
|
|
@@ -75,11 +117,21 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
75
117
|
tokens.append({"content": text[last_pos:start]})
|
|
76
118
|
|
|
77
119
|
# Add the formatted content
|
|
78
|
-
if match_type == "
|
|
120
|
+
if match_type == "code":
|
|
121
|
+
tokens.append({
|
|
122
|
+
"content": content,
|
|
123
|
+
"marks": [{"type": "code"}]
|
|
124
|
+
})
|
|
125
|
+
elif match_type == "link":
|
|
79
126
|
tokens.append({
|
|
80
127
|
"content": content,
|
|
81
128
|
"marks": [{"type": "link", "attrs": {"href": url}}]
|
|
82
129
|
})
|
|
130
|
+
elif match_type == "bold_italic":
|
|
131
|
+
tokens.append({
|
|
132
|
+
"content": content,
|
|
133
|
+
"marks": [{"type": "strong"}, {"type": "em"}]
|
|
134
|
+
})
|
|
83
135
|
elif match_type == "bold":
|
|
84
136
|
tokens.append({
|
|
85
137
|
"content": content,
|
|
@@ -90,6 +142,11 @@ def parse_inline(text: str) -> List[Dict]:
|
|
|
90
142
|
"content": content,
|
|
91
143
|
"marks": [{"type": "em"}]
|
|
92
144
|
})
|
|
145
|
+
elif match_type == "strikethrough":
|
|
146
|
+
tokens.append({
|
|
147
|
+
"content": content,
|
|
148
|
+
"marks": [{"type": "strikethrough"}]
|
|
149
|
+
})
|
|
93
150
|
|
|
94
151
|
last_pos = end
|
|
95
152
|
|
|
@@ -490,6 +547,186 @@ class Post:
|
|
|
490
547
|
|
|
491
548
|
return self
|
|
492
549
|
|
|
550
|
+
def footnote_anchor(self, number: int):
|
|
551
|
+
"""
|
|
552
|
+
|
|
553
|
+
Add an inline footnote reference (the superscript marker) to the last block.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
number: The footnote number this anchor points to.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Self for method chaining.
|
|
560
|
+
|
|
561
|
+
"""
|
|
562
|
+
content = self.draft_body["content"][-1].get("content", [])
|
|
563
|
+
content += [{"type": "footnoteAnchor", "attrs": {"number": number}}]
|
|
564
|
+
self.draft_body["content"][-1]["content"] = content
|
|
565
|
+
return self
|
|
566
|
+
|
|
567
|
+
def footnote(self, number: int, content=None):
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
Append a footnote block (the note shown at the foot of the post).
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
number: The footnote number, matching a footnote_anchor.
|
|
574
|
+
content: Text string or list of inline token dicts. A plain string is
|
|
575
|
+
parsed for inline Markdown and may contain blank-line-separated
|
|
576
|
+
paragraphs; a parse_inline() token list or a list of ready text
|
|
577
|
+
nodes is also accepted (single paragraph).
|
|
578
|
+
|
|
579
|
+
Returns:
|
|
580
|
+
Self for method chaining.
|
|
581
|
+
|
|
582
|
+
"""
|
|
583
|
+
paragraphs: List[Dict] = []
|
|
584
|
+
if isinstance(content, str):
|
|
585
|
+
# Blank lines separate paragraphs within the footnote.
|
|
586
|
+
for chunk in re.split(r"\n\s*\n", content):
|
|
587
|
+
chunk = chunk.strip()
|
|
588
|
+
if chunk:
|
|
589
|
+
paragraphs.append(
|
|
590
|
+
{"type": "paragraph", "content": tokens_to_text_nodes(parse_inline(chunk))}
|
|
591
|
+
)
|
|
592
|
+
elif isinstance(content, list):
|
|
593
|
+
# Accept either parse_inline tokens ({"content": ...}) or text nodes.
|
|
594
|
+
if content and content[0].get("type") == "text":
|
|
595
|
+
text_nodes = content
|
|
596
|
+
else:
|
|
597
|
+
text_nodes = tokens_to_text_nodes(content)
|
|
598
|
+
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
599
|
+
|
|
600
|
+
if not paragraphs:
|
|
601
|
+
paragraphs = [{"type": "paragraph", "content": []}]
|
|
602
|
+
|
|
603
|
+
node: Dict = {
|
|
604
|
+
"type": "footnote",
|
|
605
|
+
"attrs": {"number": number},
|
|
606
|
+
"content": paragraphs,
|
|
607
|
+
}
|
|
608
|
+
self.draft_body["content"] = self.draft_body.get("content", []) + [node]
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _extract_footnote_definitions(markdown_content: str):
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
Pull ``[^label]: definition`` lines out of the Markdown.
|
|
616
|
+
|
|
617
|
+
Definitions may wrap onto indented continuation lines and may contain
|
|
618
|
+
multiple paragraphs (blank line followed by an indented block). Returns
|
|
619
|
+
the body with definitions removed plus a {label: definition_text} mapping,
|
|
620
|
+
where paragraphs are separated by a blank line.
|
|
621
|
+
|
|
622
|
+
"""
|
|
623
|
+
lines = markdown_content.split("\n")
|
|
624
|
+
body_lines: List[str] = []
|
|
625
|
+
definitions: Dict[str, str] = {}
|
|
626
|
+
in_code_fence = False
|
|
627
|
+
i = 0
|
|
628
|
+
while i < len(lines):
|
|
629
|
+
# Track fenced code blocks so footnote-like lines inside them are
|
|
630
|
+
# left untouched.
|
|
631
|
+
if lines[i].lstrip().startswith("```"):
|
|
632
|
+
in_code_fence = not in_code_fence
|
|
633
|
+
body_lines.append(lines[i])
|
|
634
|
+
i += 1
|
|
635
|
+
continue
|
|
636
|
+
match = None if in_code_fence else FOOTNOTE_DEFINITION_PATTERN.match(lines[i])
|
|
637
|
+
if match:
|
|
638
|
+
label, first = match.group(1), match.group(2)
|
|
639
|
+
paragraphs: List[str] = []
|
|
640
|
+
current = [first.strip()] if first.strip() else []
|
|
641
|
+
i += 1
|
|
642
|
+
while i < len(lines):
|
|
643
|
+
line = lines[i]
|
|
644
|
+
if line.strip() == "":
|
|
645
|
+
# A blank line stays in the footnote only if the next
|
|
646
|
+
# non-empty line is indented (a further paragraph).
|
|
647
|
+
nxt = i + 1
|
|
648
|
+
if (
|
|
649
|
+
nxt < len(lines)
|
|
650
|
+
and lines[nxt].strip()
|
|
651
|
+
and lines[nxt][:1] in (" ", "\t")
|
|
652
|
+
):
|
|
653
|
+
if current:
|
|
654
|
+
paragraphs.append(" ".join(current))
|
|
655
|
+
current = []
|
|
656
|
+
i += 1
|
|
657
|
+
continue
|
|
658
|
+
break
|
|
659
|
+
if line[:1] in (" ", "\t"):
|
|
660
|
+
current.append(line.strip())
|
|
661
|
+
i += 1
|
|
662
|
+
else:
|
|
663
|
+
break
|
|
664
|
+
if current:
|
|
665
|
+
paragraphs.append(" ".join(current))
|
|
666
|
+
definitions[label] = "\n\n".join(paragraphs)
|
|
667
|
+
else:
|
|
668
|
+
body_lines.append(lines[i])
|
|
669
|
+
i += 1
|
|
670
|
+
return "\n".join(body_lines), definitions
|
|
671
|
+
|
|
672
|
+
@staticmethod
|
|
673
|
+
def _number_footnotes(markdown_content: str, definitions: Dict[str, str]):
|
|
674
|
+
"""Number footnotes by order of first inline reference in the body."""
|
|
675
|
+
order: List[str] = []
|
|
676
|
+
for match in FOOTNOTE_REFERENCE_PATTERN.finditer(markdown_content):
|
|
677
|
+
label = match.group(1)
|
|
678
|
+
if label in definitions and label not in order:
|
|
679
|
+
order.append(label)
|
|
680
|
+
# Defined-but-unreferenced footnotes go last, in definition order.
|
|
681
|
+
for label in definitions:
|
|
682
|
+
if label not in order:
|
|
683
|
+
order.append(label)
|
|
684
|
+
return {label: index + 1 for index, label in enumerate(order)}
|
|
685
|
+
|
|
686
|
+
def _inject_footnote_anchors(self, node: Dict, numbers_by_label: Dict[str, int]):
|
|
687
|
+
"""Recursively replace ``[^label]`` in text nodes with footnoteAnchor nodes."""
|
|
688
|
+
# Never rewrite the contents of a code block.
|
|
689
|
+
if node.get("type") == "codeBlock":
|
|
690
|
+
return
|
|
691
|
+
content = node.get("content")
|
|
692
|
+
if not isinstance(content, list):
|
|
693
|
+
return
|
|
694
|
+
new_content: List[Dict] = []
|
|
695
|
+
for child in content:
|
|
696
|
+
text = child.get("text", "")
|
|
697
|
+
has_code_mark = any(
|
|
698
|
+
mark.get("type") == "code" for mark in (child.get("marks") or [])
|
|
699
|
+
)
|
|
700
|
+
if (
|
|
701
|
+
child.get("type") == "text"
|
|
702
|
+
and not has_code_mark
|
|
703
|
+
and FOOTNOTE_REFERENCE_PATTERN.search(text)
|
|
704
|
+
):
|
|
705
|
+
marks = child.get("marks")
|
|
706
|
+
last = 0
|
|
707
|
+
for match in FOOTNOTE_REFERENCE_PATTERN.finditer(text):
|
|
708
|
+
label = match.group(1)
|
|
709
|
+
if label not in numbers_by_label:
|
|
710
|
+
continue # Unknown label: leave the literal text in place.
|
|
711
|
+
if match.start() > last:
|
|
712
|
+
segment = {"type": "text", "text": text[last:match.start()]}
|
|
713
|
+
if marks:
|
|
714
|
+
segment["marks"] = marks
|
|
715
|
+
new_content.append(segment)
|
|
716
|
+
new_content.append(
|
|
717
|
+
{"type": "footnoteAnchor", "attrs": {"number": numbers_by_label[label]}}
|
|
718
|
+
)
|
|
719
|
+
last = match.end()
|
|
720
|
+
if last < len(text):
|
|
721
|
+
segment = {"type": "text", "text": text[last:]}
|
|
722
|
+
if marks:
|
|
723
|
+
segment["marks"] = marks
|
|
724
|
+
new_content.append(segment)
|
|
725
|
+
else:
|
|
726
|
+
self._inject_footnote_anchors(child, numbers_by_label)
|
|
727
|
+
new_content.append(child)
|
|
728
|
+
node["content"] = new_content
|
|
729
|
+
|
|
493
730
|
def from_markdown(self, markdown_content: str, api=None):
|
|
494
731
|
"""
|
|
495
732
|
Parse Markdown content and add it to the post.
|
|
@@ -503,7 +740,13 @@ class Post:
|
|
|
503
740
|
- Blockquotes: Lines starting with '>' (consecutive lines grouped)
|
|
504
741
|
- Paragraphs: Regular text blocks
|
|
505
742
|
- Bullet lists: Lines starting with '*' or '-'
|
|
506
|
-
-
|
|
743
|
+
- Ordered lists: Lines starting with '1.', '2.', etc.
|
|
744
|
+
- Horizontal rules: Lines with ---, ***, or ___
|
|
745
|
+
- Inline formatting: **bold**, *italic*, ***bold+italic***, `code`, ~~strikethrough~~
|
|
746
|
+
- Footnotes: ``text.[^label]`` references plus ``[^label]: definition``
|
|
747
|
+
lines. References become inline anchors and definitions become
|
|
748
|
+
footnote blocks, numbered by order of first appearance. Labels may be
|
|
749
|
+
numbers or names (e.g. ``[^1]`` or ``[^agi-book]``).
|
|
507
750
|
|
|
508
751
|
Args:
|
|
509
752
|
markdown_content: Markdown string to parse and add to the post.
|
|
@@ -517,6 +760,13 @@ class Post:
|
|
|
517
760
|
>>> post = Post("Title", "Subtitle", user_id)
|
|
518
761
|
>>> post.from_markdown("# Heading\\n\\nThis is **bold** text with [a link](https://example.com).")
|
|
519
762
|
"""
|
|
763
|
+
# Footnotes: extract ``[^label]: ...`` definitions and number them by
|
|
764
|
+
# order of first reference before parsing the rest of the body.
|
|
765
|
+
markdown_content, footnote_definitions = self._extract_footnote_definitions(
|
|
766
|
+
markdown_content
|
|
767
|
+
)
|
|
768
|
+
footnote_numbers = self._number_footnotes(markdown_content, footnote_definitions)
|
|
769
|
+
|
|
520
770
|
lines = markdown_content.split("\n")
|
|
521
771
|
blocks = []
|
|
522
772
|
current_block: List[str] = []
|
|
@@ -593,6 +843,11 @@ class Post:
|
|
|
593
843
|
if not text_content:
|
|
594
844
|
continue
|
|
595
845
|
|
|
846
|
+
# Check for horizontal rule: ---, ***, ___
|
|
847
|
+
if re.match(r'^(\*{3,}|-{3,}|_{3,})\s*$', text_content):
|
|
848
|
+
self.horizontal_rule()
|
|
849
|
+
continue
|
|
850
|
+
|
|
596
851
|
# Process headings (lines starting with '#' characters)
|
|
597
852
|
if text_content.startswith("#"):
|
|
598
853
|
level = len(text_content) - len(text_content.lstrip("#"))
|
|
@@ -648,14 +903,15 @@ class Post:
|
|
|
648
903
|
|
|
649
904
|
self.add({"type": "captionedImage", "src": image_url})
|
|
650
905
|
|
|
651
|
-
# Process paragraphs, bullet lists, or blockquotes
|
|
906
|
+
# Process paragraphs, bullet lists, ordered lists, or blockquotes
|
|
652
907
|
else:
|
|
653
908
|
if "\n" in text_content:
|
|
654
|
-
# Process each line, grouping consecutive bullets
|
|
655
|
-
# into
|
|
656
|
-
#
|
|
909
|
+
# Process each line, grouping consecutive bullets/ordered items
|
|
910
|
+
# into list nodes and consecutive blockquote lines into a
|
|
911
|
+
# single blockquote node.
|
|
657
912
|
pending_bullets: List[List[Dict]] = []
|
|
658
913
|
pending_quotes: List[str] = []
|
|
914
|
+
pending_ordered: List[List[Dict]] = []
|
|
659
915
|
|
|
660
916
|
def flush_bullets():
|
|
661
917
|
if not pending_bullets:
|
|
@@ -677,10 +933,7 @@ class Post:
|
|
|
677
933
|
paragraphs: List[Dict] = []
|
|
678
934
|
for quote_line in pending_quotes:
|
|
679
935
|
tokens = parse_inline(quote_line)
|
|
680
|
-
text_nodes =
|
|
681
|
-
{"type": "text", "text": t["content"]}
|
|
682
|
-
for t in tokens if t
|
|
683
|
-
]
|
|
936
|
+
text_nodes = tokens_to_text_nodes(tokens)
|
|
684
937
|
if text_nodes:
|
|
685
938
|
paragraphs.append({"type": "paragraph", "content": text_nodes})
|
|
686
939
|
node: Dict = {"type": "blockquote"}
|
|
@@ -689,20 +942,48 @@ class Post:
|
|
|
689
942
|
self.draft_body["content"].append(node)
|
|
690
943
|
pending_quotes.clear()
|
|
691
944
|
|
|
945
|
+
def flush_ordered():
|
|
946
|
+
if not pending_ordered:
|
|
947
|
+
return
|
|
948
|
+
list_items = []
|
|
949
|
+
for item_nodes in pending_ordered:
|
|
950
|
+
list_items.append({
|
|
951
|
+
"type": "list_item",
|
|
952
|
+
"content": [{"type": "paragraph", "content": item_nodes}],
|
|
953
|
+
})
|
|
954
|
+
self.draft_body["content"].append(
|
|
955
|
+
{"type": "ordered_list", "content": list_items}
|
|
956
|
+
)
|
|
957
|
+
pending_ordered.clear()
|
|
958
|
+
|
|
692
959
|
for line in text_content.split("\n"):
|
|
693
960
|
line = line.strip()
|
|
694
961
|
if not line:
|
|
695
962
|
flush_bullets()
|
|
963
|
+
flush_ordered()
|
|
696
964
|
flush_quotes()
|
|
697
965
|
continue
|
|
698
966
|
|
|
699
967
|
# Check for blockquote marker
|
|
700
968
|
if line.startswith("> ") or line == ">":
|
|
701
969
|
flush_bullets()
|
|
970
|
+
flush_ordered()
|
|
702
971
|
quote_text = line[2:] if line.startswith("> ") else ""
|
|
703
972
|
pending_quotes.append(quote_text)
|
|
704
973
|
continue
|
|
705
974
|
|
|
975
|
+
# Check for ordered list marker
|
|
976
|
+
ordered_match = re.match(r'^(\d+)\.\s+(.*)', line)
|
|
977
|
+
if ordered_match:
|
|
978
|
+
flush_bullets()
|
|
979
|
+
flush_quotes()
|
|
980
|
+
item_text = ordered_match.group(2).strip()
|
|
981
|
+
tokens = parse_inline(item_text)
|
|
982
|
+
text_nodes = tokens_to_text_nodes(tokens)
|
|
983
|
+
if text_nodes:
|
|
984
|
+
pending_ordered.append(text_nodes)
|
|
985
|
+
continue
|
|
986
|
+
|
|
706
987
|
# Check for bullet marker
|
|
707
988
|
bullet_text = None
|
|
708
989
|
if line.startswith("* "):
|
|
@@ -713,33 +994,56 @@ class Post:
|
|
|
713
994
|
bullet_text = line[1:].strip()
|
|
714
995
|
|
|
715
996
|
if bullet_text is not None:
|
|
997
|
+
flush_ordered()
|
|
716
998
|
flush_quotes()
|
|
717
999
|
tokens = parse_inline(bullet_text)
|
|
718
|
-
|
|
719
|
-
|
|
1000
|
+
text_nodes = tokens_to_text_nodes(tokens)
|
|
1001
|
+
if text_nodes:
|
|
1002
|
+
pending_bullets.append(text_nodes)
|
|
720
1003
|
else:
|
|
721
1004
|
flush_bullets()
|
|
1005
|
+
flush_ordered()
|
|
722
1006
|
flush_quotes()
|
|
723
1007
|
tokens = parse_inline(line)
|
|
724
1008
|
self.add({"type": "paragraph", "content": tokens})
|
|
725
1009
|
|
|
726
1010
|
flush_bullets()
|
|
1011
|
+
flush_ordered()
|
|
727
1012
|
flush_quotes()
|
|
728
1013
|
else:
|
|
729
|
-
# Single line —
|
|
1014
|
+
# Single line — blockquote, ordered list, or paragraph
|
|
730
1015
|
if text_content.startswith("> ") or text_content == ">":
|
|
731
1016
|
quote_text = text_content[2:] if text_content.startswith("> ") else ""
|
|
732
1017
|
tokens = parse_inline(quote_text)
|
|
733
|
-
text_nodes =
|
|
734
|
-
{"type": "text", "text": t["content"]}
|
|
735
|
-
for t in tokens if t
|
|
736
|
-
]
|
|
1018
|
+
text_nodes = tokens_to_text_nodes(tokens)
|
|
737
1019
|
para = {"type": "paragraph", "content": text_nodes} if text_nodes else {"type": "paragraph"}
|
|
738
1020
|
self.draft_body["content"] = self.draft_body.get("content", []) + [
|
|
739
1021
|
{"type": "blockquote", "content": [para]}
|
|
740
1022
|
]
|
|
1023
|
+
|
|
1024
|
+
elif re.match(r'^(\d+)\.\s+(.*)', text_content):
|
|
1025
|
+
ordered_match = re.match(r'^(\d+)\.\s+(.*)', text_content)
|
|
1026
|
+
item_text = ordered_match.group(2).strip()
|
|
1027
|
+
tokens = parse_inline(item_text)
|
|
1028
|
+
text_nodes = tokens_to_text_nodes(tokens)
|
|
1029
|
+
if text_nodes:
|
|
1030
|
+
list_item = {
|
|
1031
|
+
"type": "list_item",
|
|
1032
|
+
"content": [{"type": "paragraph", "content": text_nodes}],
|
|
1033
|
+
}
|
|
1034
|
+
self.draft_body["content"].append(
|
|
1035
|
+
{"type": "ordered_list", "content": [list_item]}
|
|
1036
|
+
)
|
|
1037
|
+
|
|
741
1038
|
else:
|
|
742
1039
|
tokens = parse_inline(text_content)
|
|
743
1040
|
self.add({"type": "paragraph", "content": tokens})
|
|
744
1041
|
|
|
1042
|
+
# Footnotes: turn ``[^label]`` references into inline anchors, then append
|
|
1043
|
+
# the footnote blocks in numbered order.
|
|
1044
|
+
if footnote_numbers:
|
|
1045
|
+
self._inject_footnote_anchors(self.draft_body, footnote_numbers)
|
|
1046
|
+
for label, number in sorted(footnote_numbers.items(), key=lambda item: item[1]):
|
|
1047
|
+
self.footnote(number, footnote_definitions[label])
|
|
1048
|
+
|
|
745
1049
|
return self
|
|
File without changes
|
|
File without changes
|
|
File without changes
|