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.
Files changed (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. 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
File without changes