docspan 0.1.0__py3-none-any.whl
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.
- docspan/__init__.py +3 -0
- docspan/__main__.py +0 -0
- docspan/backends/__init__.py +19 -0
- docspan/backends/base.py +85 -0
- docspan/backends/confluence/__init__.py +0 -0
- docspan/backends/confluence/adf/__init__.py +14 -0
- docspan/backends/confluence/adf/comparator.py +427 -0
- docspan/backends/confluence/adf/converter.py +119 -0
- docspan/backends/confluence/adf/converters.py +1449 -0
- docspan/backends/confluence/adf/interfaces.py +191 -0
- docspan/backends/confluence/adf/nodes.py +2085 -0
- docspan/backends/confluence/adf/parser.py +400 -0
- docspan/backends/confluence/adf/validators.py +161 -0
- docspan/backends/confluence/adf/visitors.py +495 -0
- docspan/backends/confluence/backend.py +227 -0
- docspan/backends/confluence/client.py +44 -0
- docspan/backends/confluence/config/__init__.py +21 -0
- docspan/backends/confluence/config/loader.py +107 -0
- docspan/backends/confluence/config/models.py +167 -0
- docspan/backends/confluence/config/validation.py +297 -0
- docspan/backends/confluence/markdown/__init__.py +22 -0
- docspan/backends/confluence/markdown/ast.py +819 -0
- docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
- docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
- docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
- docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
- docspan/backends/confluence/markdown/inline_parser.py +495 -0
- docspan/backends/confluence/markdown/parser.py +1006 -0
- docspan/backends/confluence/models/__init__.py +18 -0
- docspan/backends/confluence/models/markdown_file.py +402 -0
- docspan/backends/confluence/models/page.py +212 -0
- docspan/backends/confluence/models/path_utils.py +34 -0
- docspan/backends/confluence/models/results.py +28 -0
- docspan/backends/confluence/models/sync_status.py +382 -0
- docspan/backends/confluence/services/__init__.py +0 -0
- docspan/backends/confluence/services/confluence/__init__.py +40 -0
- docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
- docspan/backends/confluence/services/confluence/base_client.py +420 -0
- docspan/backends/confluence/services/confluence/client.py +376 -0
- docspan/backends/confluence/services/confluence/comment_client.py +682 -0
- docspan/backends/confluence/services/confluence/crawler.py +587 -0
- docspan/backends/confluence/services/confluence/label_client.py +130 -0
- docspan/backends/confluence/services/confluence/page_client.py +1288 -0
- docspan/backends/confluence/services/confluence/space_client.py +179 -0
- docspan/backends/confluence/services/confluence/url_parser.py +106 -0
- docspan/backends/google_docs/__init__.py +0 -0
- docspan/backends/google_docs/auth.py +143 -0
- docspan/backends/google_docs/backend.py +140 -0
- docspan/backends/google_docs/client.py +665 -0
- docspan/backends/google_docs/converter.py +471 -0
- docspan/backends/google_docs/docs_request_builder.py +232 -0
- docspan/backends/google_docs/docs_structure_parser.py +120 -0
- docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
- docspan/cli/__init__.py +0 -0
- docspan/cli/main.py +408 -0
- docspan/config.py +62 -0
- docspan/core/__init__.py +49 -0
- docspan/core/merge.py +30 -0
- docspan/core/orchestrator.py +332 -0
- docspan/core/paths.py +8 -0
- docspan/core/state.py +53 -0
- docspan-0.1.0.dist-info/METADATA +273 -0
- docspan-0.1.0.dist-info/RECORD +65 -0
- docspan-0.1.0.dist-info/WHEEL +4 -0
- docspan-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Build Google Docs batchUpdate request lists from structural AST diffs."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import difflib
|
|
5
|
+
from typing import List, Tuple
|
|
6
|
+
|
|
7
|
+
from docspan.backends.google_docs.docs_structure_parser import DocsParagraphNode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _utf16_len(text: str) -> int:
|
|
11
|
+
"""Return the number of UTF-16 code units in text (surrogate pairs count as 2)."""
|
|
12
|
+
return len(text.encode("utf-16-le")) // 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocsRequestBuilder:
|
|
16
|
+
"""Diff two paragraph ASTs and produce minimal Google Docs batchUpdate requests."""
|
|
17
|
+
|
|
18
|
+
def _text_key(self, node: DocsParagraphNode) -> Tuple:
|
|
19
|
+
"""Key used by SequenceMatcher for comparing nodes."""
|
|
20
|
+
return (node.style, node.text, node.is_list_item)
|
|
21
|
+
|
|
22
|
+
def build(
|
|
23
|
+
self,
|
|
24
|
+
current: List[DocsParagraphNode],
|
|
25
|
+
target: List[DocsParagraphNode],
|
|
26
|
+
doc_end_index: int,
|
|
27
|
+
) -> List[dict]:
|
|
28
|
+
"""
|
|
29
|
+
Build a minimal list of batchUpdate request dicts.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
current: Paragraph nodes parsed from the live Google Doc.
|
|
33
|
+
target: Paragraph nodes parsed from the local markdown file.
|
|
34
|
+
doc_end_index: endIndex of the last body element (used to protect
|
|
35
|
+
the terminal newline that Docs API requires).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
List of request dicts sorted by descending startIndex (write-backwards).
|
|
39
|
+
"""
|
|
40
|
+
current_keys = [self._text_key(n) for n in current]
|
|
41
|
+
target_keys = [self._text_key(n) for n in target]
|
|
42
|
+
|
|
43
|
+
matcher = difflib.SequenceMatcher(None, current_keys, target_keys, autojunk=False)
|
|
44
|
+
opcodes = matcher.get_opcodes()
|
|
45
|
+
|
|
46
|
+
all_requests: List[dict] = []
|
|
47
|
+
|
|
48
|
+
for tag, i1, i2, j1, j2 in opcodes:
|
|
49
|
+
if tag == "equal":
|
|
50
|
+
# Nodes match — only emit style update if style differs
|
|
51
|
+
for ci, ti in zip(range(i1, i2), range(j1, j2)):
|
|
52
|
+
all_requests.extend(
|
|
53
|
+
self._make_style_update_requests(current[ci], target[ti])
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
elif tag == "delete":
|
|
57
|
+
# Remove current nodes i1..i2 with no replacement
|
|
58
|
+
all_requests.extend(
|
|
59
|
+
self._make_delete_requests(current[i1:i2], doc_end_index)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
elif tag == "insert":
|
|
63
|
+
# Insert target nodes j1..j2 — determine insertion point
|
|
64
|
+
if i1 > 0:
|
|
65
|
+
insert_at = current[i1 - 1].end_index - 1
|
|
66
|
+
else:
|
|
67
|
+
insert_at = 1 # start of document body
|
|
68
|
+
all_requests.extend(
|
|
69
|
+
self._make_insert_requests(target[j1:j2], insert_at)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
elif tag == "replace":
|
|
73
|
+
# Delete current nodes, then insert target nodes at same position
|
|
74
|
+
delete_start = current[i1].start_index
|
|
75
|
+
all_requests.extend(
|
|
76
|
+
self._make_delete_requests(current[i1:i2], doc_end_index)
|
|
77
|
+
)
|
|
78
|
+
all_requests.extend(
|
|
79
|
+
self._make_insert_requests(target[j1:j2], delete_start)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Sort descending by startIndex (write-backwards strategy)
|
|
83
|
+
all_requests.sort(
|
|
84
|
+
key=lambda r: self._extract_start_index(r),
|
|
85
|
+
reverse=True,
|
|
86
|
+
)
|
|
87
|
+
return all_requests
|
|
88
|
+
|
|
89
|
+
# ──────────────────────────────────────────────
|
|
90
|
+
# Request factories
|
|
91
|
+
# ──────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def _make_delete_requests(
|
|
94
|
+
self, nodes: List[DocsParagraphNode], doc_end_index: int
|
|
95
|
+
) -> List[dict]:
|
|
96
|
+
requests = []
|
|
97
|
+
for node in nodes:
|
|
98
|
+
start = node.start_index
|
|
99
|
+
end = node.end_index
|
|
100
|
+
# Never delete the terminal newline
|
|
101
|
+
if end >= doc_end_index:
|
|
102
|
+
end = doc_end_index - 1
|
|
103
|
+
if start >= end:
|
|
104
|
+
continue
|
|
105
|
+
requests.append({
|
|
106
|
+
"deleteContentRange": {
|
|
107
|
+
"range": {
|
|
108
|
+
"startIndex": start,
|
|
109
|
+
"endIndex": end,
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return requests
|
|
114
|
+
|
|
115
|
+
def _make_insert_requests(
|
|
116
|
+
self, nodes: List[DocsParagraphNode], insert_at_index: int
|
|
117
|
+
) -> List[dict]:
|
|
118
|
+
"""
|
|
119
|
+
Emit insertText + updateParagraphStyle (+ createParagraphBullets) per node.
|
|
120
|
+
|
|
121
|
+
All inserts go at the same index; because we sort descending later the
|
|
122
|
+
ordering inside a single insert group is preserved.
|
|
123
|
+
"""
|
|
124
|
+
requests = []
|
|
125
|
+
# Insert in reverse order so that each insert lands at the right place
|
|
126
|
+
# when the final descending-sort is applied.
|
|
127
|
+
for node in reversed(nodes):
|
|
128
|
+
text = node.text + "\n"
|
|
129
|
+
requests.append({
|
|
130
|
+
"insertText": {
|
|
131
|
+
"location": {"index": insert_at_index},
|
|
132
|
+
"text": text,
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
text_len = _utf16_len(text)
|
|
136
|
+
end_index = insert_at_index + text_len
|
|
137
|
+
paragraph_range = {
|
|
138
|
+
"startIndex": insert_at_index,
|
|
139
|
+
"endIndex": end_index,
|
|
140
|
+
}
|
|
141
|
+
requests.append({
|
|
142
|
+
"updateParagraphStyle": {
|
|
143
|
+
"range": paragraph_range,
|
|
144
|
+
"paragraphStyle": {"namedStyleType": node.style},
|
|
145
|
+
"fields": "namedStyleType",
|
|
146
|
+
}
|
|
147
|
+
})
|
|
148
|
+
if node.is_list_item:
|
|
149
|
+
requests.append({
|
|
150
|
+
"createParagraphBullets": {
|
|
151
|
+
"range": paragraph_range,
|
|
152
|
+
"bulletPreset": "BULLET_DISC_CIRCLE_SQUARE",
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
return requests
|
|
156
|
+
|
|
157
|
+
def _make_style_update_requests(
|
|
158
|
+
self, current_node: DocsParagraphNode, target_node: DocsParagraphNode
|
|
159
|
+
) -> List[dict]:
|
|
160
|
+
"""Emit updateParagraphStyle when style differs (text is equal)."""
|
|
161
|
+
if current_node.style == target_node.style:
|
|
162
|
+
return []
|
|
163
|
+
return [{
|
|
164
|
+
"updateParagraphStyle": {
|
|
165
|
+
"range": {
|
|
166
|
+
"startIndex": current_node.start_index,
|
|
167
|
+
"endIndex": current_node.end_index,
|
|
168
|
+
},
|
|
169
|
+
"paragraphStyle": {"namedStyleType": target_node.style},
|
|
170
|
+
"fields": "namedStyleType",
|
|
171
|
+
}
|
|
172
|
+
}]
|
|
173
|
+
|
|
174
|
+
def _make_text_style_requests(
|
|
175
|
+
self, text: str, style_attrs: dict, range_dict: dict
|
|
176
|
+
) -> List[dict]:
|
|
177
|
+
"""
|
|
178
|
+
Emit updateTextStyle with specific FieldMask (never '*').
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
text: The text content (for context; not used in request).
|
|
182
|
+
style_attrs: Dict with keys like 'bold', 'italic', 'link', 'monospace'.
|
|
183
|
+
range_dict: {'startIndex': int, 'endIndex': int}
|
|
184
|
+
"""
|
|
185
|
+
fields = []
|
|
186
|
+
text_style: dict = {}
|
|
187
|
+
|
|
188
|
+
if "bold" in style_attrs:
|
|
189
|
+
fields.append("bold")
|
|
190
|
+
text_style["bold"] = style_attrs["bold"]
|
|
191
|
+
if "italic" in style_attrs:
|
|
192
|
+
fields.append("italic")
|
|
193
|
+
text_style["italic"] = style_attrs["italic"]
|
|
194
|
+
if "link" in style_attrs:
|
|
195
|
+
fields.append("link")
|
|
196
|
+
text_style["link"] = {"url": style_attrs["link"]}
|
|
197
|
+
if style_attrs.get("monospace"):
|
|
198
|
+
fields.append("weightedFontFamily")
|
|
199
|
+
text_style["weightedFontFamily"] = {"fontFamily": "Courier New", "weight": 400}
|
|
200
|
+
|
|
201
|
+
if not fields:
|
|
202
|
+
return []
|
|
203
|
+
|
|
204
|
+
return [{
|
|
205
|
+
"updateTextStyle": {
|
|
206
|
+
"range": range_dict,
|
|
207
|
+
"textStyle": text_style,
|
|
208
|
+
"fields": ",".join(fields),
|
|
209
|
+
}
|
|
210
|
+
}]
|
|
211
|
+
|
|
212
|
+
# ──────────────────────────────────────────────
|
|
213
|
+
# Helpers
|
|
214
|
+
# ──────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _extract_start_index(request: dict) -> int:
|
|
218
|
+
"""Extract the primary startIndex from any request dict for sorting."""
|
|
219
|
+
for key in (
|
|
220
|
+
"deleteContentRange",
|
|
221
|
+
"insertText",
|
|
222
|
+
"updateParagraphStyle",
|
|
223
|
+
"createParagraphBullets",
|
|
224
|
+
"updateTextStyle",
|
|
225
|
+
):
|
|
226
|
+
if key in request:
|
|
227
|
+
inner = request[key]
|
|
228
|
+
if "range" in inner:
|
|
229
|
+
return inner["range"].get("startIndex", 0)
|
|
230
|
+
if "location" in inner:
|
|
231
|
+
return inner["location"].get("index", 0)
|
|
232
|
+
return 0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Parse a Google Docs JSON document into a list of DocsParagraphNode objects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TextSpan:
|
|
10
|
+
text: str
|
|
11
|
+
bold: bool = False
|
|
12
|
+
italic: bool = False
|
|
13
|
+
link: Optional[str] = None
|
|
14
|
+
monospace: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DocsParagraphNode:
|
|
19
|
+
"""Represents a single paragraph in a Google Docs document."""
|
|
20
|
+
style: str # e.g. "NORMAL_TEXT", "HEADING_1", "HEADING_2", ...
|
|
21
|
+
text: str # Concatenated plain text (trailing \n stripped)
|
|
22
|
+
is_list_item: bool = False
|
|
23
|
+
nesting_level: int = 0
|
|
24
|
+
start_index: int = 0
|
|
25
|
+
end_index: int = 0
|
|
26
|
+
spans: List[TextSpan] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DocsStructureParser:
|
|
30
|
+
"""Parse a Google Docs document dict into a list of DocsParagraphNode."""
|
|
31
|
+
|
|
32
|
+
def parse(self, doc: dict) -> List[DocsParagraphNode]:
|
|
33
|
+
"""
|
|
34
|
+
Parse a Google Docs document dict.
|
|
35
|
+
|
|
36
|
+
Handles both tabs-based format (doc['tabs'][0]['documentTab']['body']['content'])
|
|
37
|
+
and legacy single-tab format (doc['body']['content']).
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
doc: Full Google Docs document resource dict (from documents.get())
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of DocsParagraphNode in document order.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
KeyError: If the document has neither 'tabs' nor 'body' key.
|
|
47
|
+
"""
|
|
48
|
+
# Determine body content — handle tabs-based and legacy structure
|
|
49
|
+
if "tabs" in doc and doc["tabs"]:
|
|
50
|
+
body = doc["tabs"][0].get("documentTab", doc).get("body", {})
|
|
51
|
+
elif "body" in doc:
|
|
52
|
+
body = doc["body"]
|
|
53
|
+
else:
|
|
54
|
+
raise KeyError("Document has neither 'tabs' nor 'body' key")
|
|
55
|
+
|
|
56
|
+
content = body.get("content", [])
|
|
57
|
+
nodes: List[DocsParagraphNode] = []
|
|
58
|
+
|
|
59
|
+
for element in content:
|
|
60
|
+
if "paragraph" in element:
|
|
61
|
+
node = self._parse_paragraph(element)
|
|
62
|
+
if node is not None:
|
|
63
|
+
nodes.append(node)
|
|
64
|
+
# table, sectionBreak, tableOfContents are silently skipped
|
|
65
|
+
|
|
66
|
+
return nodes
|
|
67
|
+
|
|
68
|
+
def _parse_paragraph(self, element: dict) -> Optional[DocsParagraphNode]:
|
|
69
|
+
"""Parse a structural element that contains a paragraph."""
|
|
70
|
+
paragraph = element["paragraph"]
|
|
71
|
+
paragraph_style = paragraph.get("paragraphStyle", {})
|
|
72
|
+
style = paragraph_style.get("namedStyleType", "NORMAL_TEXT")
|
|
73
|
+
|
|
74
|
+
start_index = element.get("startIndex", 0)
|
|
75
|
+
end_index = element.get("endIndex", 0)
|
|
76
|
+
|
|
77
|
+
# Extract text from all TextRuns, collecting spans
|
|
78
|
+
spans: List[TextSpan] = []
|
|
79
|
+
text_parts: List[str] = []
|
|
80
|
+
|
|
81
|
+
for pe in paragraph.get("elements", []):
|
|
82
|
+
text_run = pe.get("textRun")
|
|
83
|
+
if text_run is None:
|
|
84
|
+
continue
|
|
85
|
+
run_content = text_run.get("content", "")
|
|
86
|
+
text_style = text_run.get("textStyle", {})
|
|
87
|
+
bold = text_style.get("bold", False)
|
|
88
|
+
italic = text_style.get("italic", False)
|
|
89
|
+
link = text_style.get("link", {}).get("url") if text_style.get("link") else None
|
|
90
|
+
# Monospace: check weightedFontFamily.fontFamily for "Courier New" or similar
|
|
91
|
+
font_family = text_style.get("weightedFontFamily", {}).get("fontFamily", "")
|
|
92
|
+
monospace = "Courier" in font_family or "mono" in font_family.lower()
|
|
93
|
+
|
|
94
|
+
text_parts.append(run_content)
|
|
95
|
+
spans.append(TextSpan(
|
|
96
|
+
text=run_content,
|
|
97
|
+
bold=bool(bold),
|
|
98
|
+
italic=bool(italic),
|
|
99
|
+
link=link,
|
|
100
|
+
monospace=monospace,
|
|
101
|
+
))
|
|
102
|
+
|
|
103
|
+
raw_text = "".join(text_parts)
|
|
104
|
+
# Strip trailing newline (each paragraph ends with \n in the Docs model)
|
|
105
|
+
text = raw_text.rstrip("\n")
|
|
106
|
+
|
|
107
|
+
# Check for bullet / list item
|
|
108
|
+
bullet = paragraph.get("bullet")
|
|
109
|
+
is_list_item = bullet is not None
|
|
110
|
+
nesting_level = bullet.get("nestingLevel", 0) if bullet else 0
|
|
111
|
+
|
|
112
|
+
return DocsParagraphNode(
|
|
113
|
+
style=style,
|
|
114
|
+
text=text,
|
|
115
|
+
is_list_item=is_list_item,
|
|
116
|
+
nesting_level=nesting_level,
|
|
117
|
+
start_index=start_index,
|
|
118
|
+
end_index=end_index,
|
|
119
|
+
spans=spans,
|
|
120
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Parse Markdown content into DocsParagraphNode list for Google Docs push."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from docspan.backends.google_docs.docs_structure_parser import DocsParagraphNode, TextSpan
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _extract_text_from_token(token: dict) -> str:
|
|
10
|
+
"""Recursively extract plain text from a mistune AST token."""
|
|
11
|
+
if token.get("type") == "raw":
|
|
12
|
+
return token.get("raw", "")
|
|
13
|
+
if token.get("type") == "text":
|
|
14
|
+
return token.get("raw", "")
|
|
15
|
+
if token.get("type") == "codespan":
|
|
16
|
+
return token.get("raw", "")
|
|
17
|
+
# For inline tokens: code_span, emphasis, strong, link, etc.
|
|
18
|
+
children = token.get("children") or token.get("children", [])
|
|
19
|
+
if children:
|
|
20
|
+
return "".join(_extract_text_from_token(c) for c in children)
|
|
21
|
+
return token.get("raw", "")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _walk_list_items(token: dict, nesting_level: int = 0) -> List[DocsParagraphNode]:
|
|
25
|
+
"""Walk a list token and yield DocsParagraphNode for each list item."""
|
|
26
|
+
nodes = []
|
|
27
|
+
for item in token.get("children", []):
|
|
28
|
+
if item.get("type") != "list_item":
|
|
29
|
+
continue
|
|
30
|
+
# Gather text from children of list_item (may be paragraph or inline tokens)
|
|
31
|
+
text_parts = []
|
|
32
|
+
for child in item.get("children", []):
|
|
33
|
+
if child.get("type") == "paragraph":
|
|
34
|
+
for inline in child.get("children", []):
|
|
35
|
+
text_parts.append(_extract_text_from_token(inline))
|
|
36
|
+
elif child.get("type") == "list":
|
|
37
|
+
# Nested list — recurse
|
|
38
|
+
nodes.append(DocsParagraphNode(
|
|
39
|
+
style="NORMAL_TEXT",
|
|
40
|
+
text="".join(text_parts).strip(),
|
|
41
|
+
is_list_item=True,
|
|
42
|
+
nesting_level=nesting_level,
|
|
43
|
+
start_index=0,
|
|
44
|
+
end_index=0,
|
|
45
|
+
))
|
|
46
|
+
text_parts = []
|
|
47
|
+
nodes.extend(_walk_list_items(child, nesting_level + 1))
|
|
48
|
+
continue
|
|
49
|
+
else:
|
|
50
|
+
text_parts.append(_extract_text_from_token(child))
|
|
51
|
+
text = "".join(text_parts).strip()
|
|
52
|
+
if text:
|
|
53
|
+
nodes.append(DocsParagraphNode(
|
|
54
|
+
style="NORMAL_TEXT",
|
|
55
|
+
text=text,
|
|
56
|
+
is_list_item=True,
|
|
57
|
+
nesting_level=nesting_level,
|
|
58
|
+
start_index=0,
|
|
59
|
+
end_index=0,
|
|
60
|
+
))
|
|
61
|
+
return nodes
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MarkdownToParagraphParser:
|
|
65
|
+
"""
|
|
66
|
+
Parse Markdown content into a list of DocsParagraphNode.
|
|
67
|
+
|
|
68
|
+
Uses mistune>=3.0 (AST renderer) for accurate block-level parsing.
|
|
69
|
+
All target nodes have start_index=0, end_index=0 (not meaningful for push targets).
|
|
70
|
+
|
|
71
|
+
Acceptance criteria:
|
|
72
|
+
- A blank line between paragraphs produces two separate nodes (not one)
|
|
73
|
+
- '## Heading' produces style="HEADING_2"
|
|
74
|
+
- '- item' produces is_list_item=True
|
|
75
|
+
- A fenced code block produces a single node with monospace=True span
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def parse(self, content: str) -> List[DocsParagraphNode]:
|
|
79
|
+
"""
|
|
80
|
+
Parse markdown content into DocsParagraphNode list.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
content: Raw markdown string.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of DocsParagraphNode in document order.
|
|
87
|
+
"""
|
|
88
|
+
import mistune
|
|
89
|
+
|
|
90
|
+
# mistune.create_markdown(renderer=None) returns AST tokens
|
|
91
|
+
md = mistune.create_markdown(renderer=None)
|
|
92
|
+
tokens = md(content) or []
|
|
93
|
+
|
|
94
|
+
nodes: List[DocsParagraphNode] = []
|
|
95
|
+
for token in tokens:
|
|
96
|
+
token_type = token.get("type")
|
|
97
|
+
|
|
98
|
+
if token_type == "heading":
|
|
99
|
+
level = token.get("attrs", {}).get("level", token.get("level", 1))
|
|
100
|
+
style = f"HEADING_{level}"
|
|
101
|
+
text_parts = []
|
|
102
|
+
for child in token.get("children", []):
|
|
103
|
+
text_parts.append(_extract_text_from_token(child))
|
|
104
|
+
text = "".join(text_parts).strip()
|
|
105
|
+
nodes.append(DocsParagraphNode(
|
|
106
|
+
style=style,
|
|
107
|
+
text=text,
|
|
108
|
+
start_index=0,
|
|
109
|
+
end_index=0,
|
|
110
|
+
))
|
|
111
|
+
|
|
112
|
+
elif token_type == "paragraph":
|
|
113
|
+
text_parts = []
|
|
114
|
+
for child in token.get("children", []):
|
|
115
|
+
text_parts.append(_extract_text_from_token(child))
|
|
116
|
+
text = "".join(text_parts).strip()
|
|
117
|
+
nodes.append(DocsParagraphNode(
|
|
118
|
+
style="NORMAL_TEXT",
|
|
119
|
+
text=text,
|
|
120
|
+
start_index=0,
|
|
121
|
+
end_index=0,
|
|
122
|
+
))
|
|
123
|
+
|
|
124
|
+
elif token_type == "list":
|
|
125
|
+
nodes.extend(_walk_list_items(token, nesting_level=0))
|
|
126
|
+
|
|
127
|
+
elif token_type in ("block_code", "code"):
|
|
128
|
+
# Fenced code block — single node with monospace span
|
|
129
|
+
raw = token.get("raw", "").strip()
|
|
130
|
+
span = TextSpan(text=raw, monospace=True)
|
|
131
|
+
nodes.append(DocsParagraphNode(
|
|
132
|
+
style="NORMAL_TEXT",
|
|
133
|
+
text=raw,
|
|
134
|
+
start_index=0,
|
|
135
|
+
end_index=0,
|
|
136
|
+
spans=[span],
|
|
137
|
+
))
|
|
138
|
+
|
|
139
|
+
elif token_type == "blank_line":
|
|
140
|
+
# blank_line between paragraphs — skip (mistune handles separation)
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# block_quote, thematic_break, html, etc. are silently skipped
|
|
144
|
+
|
|
145
|
+
return nodes
|
docspan/cli/__init__.py
ADDED
|
File without changes
|