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,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for managing Confluence spaces.
|
|
3
|
+
|
|
4
|
+
This module provides specialized functionality for working with Confluence spaces,
|
|
5
|
+
including retrieving space information and content.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from docspan.backends.confluence.config.models import ConfluenceConfig
|
|
11
|
+
from docspan.backends.confluence.services.confluence.base_client import BaseConfluenceClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SpaceClient(BaseConfluenceClient):
|
|
15
|
+
"""
|
|
16
|
+
Client for managing Confluence spaces.
|
|
17
|
+
|
|
18
|
+
Provides methods for retrieving space information and content.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: ConfluenceConfig):
|
|
22
|
+
"""
|
|
23
|
+
Initialize the space client.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config: Confluence configuration
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(config)
|
|
29
|
+
|
|
30
|
+
def get_space(self, space_key: str) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Get information about a space.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
space_key: Space key
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Space data
|
|
39
|
+
"""
|
|
40
|
+
endpoint = f"space/{space_key}"
|
|
41
|
+
params = {"expand": "description,homepage"}
|
|
42
|
+
|
|
43
|
+
return self._make_request(
|
|
44
|
+
method="GET",
|
|
45
|
+
endpoint=endpoint,
|
|
46
|
+
params=params
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def get_spaces(
|
|
50
|
+
self,
|
|
51
|
+
start: int = 0,
|
|
52
|
+
limit: int = 25,
|
|
53
|
+
type: Optional[str] = None,
|
|
54
|
+
status: Optional[str] = None,
|
|
55
|
+
expand: Optional[str] = None
|
|
56
|
+
) -> List[Dict[str, Any]]:
|
|
57
|
+
"""
|
|
58
|
+
Get all spaces or filtered spaces.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
start: Start index for pagination
|
|
62
|
+
limit: Maximum number of results to return
|
|
63
|
+
type: Space type filter (global, personal)
|
|
64
|
+
status: Space status filter (current, archived)
|
|
65
|
+
expand: Additional properties to expand in the response
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of space data
|
|
69
|
+
"""
|
|
70
|
+
endpoint = "space"
|
|
71
|
+
params = {
|
|
72
|
+
"start": start,
|
|
73
|
+
"limit": limit
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if type:
|
|
77
|
+
params["type"] = type
|
|
78
|
+
|
|
79
|
+
if status:
|
|
80
|
+
params["status"] = status
|
|
81
|
+
|
|
82
|
+
if expand:
|
|
83
|
+
params["expand"] = expand
|
|
84
|
+
|
|
85
|
+
response = self._make_request(
|
|
86
|
+
method="GET",
|
|
87
|
+
endpoint=endpoint,
|
|
88
|
+
params=params
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Return the results
|
|
92
|
+
if isinstance(response, dict):
|
|
93
|
+
return response.get("results", [])
|
|
94
|
+
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
def get_space_content(
|
|
98
|
+
self,
|
|
99
|
+
space_key: str,
|
|
100
|
+
content_type: str = "page",
|
|
101
|
+
start: int = 0,
|
|
102
|
+
limit: int = 25,
|
|
103
|
+
expand: Optional[str] = None
|
|
104
|
+
) -> List[Dict[str, Any]]:
|
|
105
|
+
"""
|
|
106
|
+
Get content in a space.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
space_key: Space key
|
|
110
|
+
content_type: Content type (page, blogpost, comment)
|
|
111
|
+
start: Start index for pagination
|
|
112
|
+
limit: Maximum number of results to return
|
|
113
|
+
expand: Additional properties to expand in the response
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
List of content data
|
|
117
|
+
"""
|
|
118
|
+
endpoint = f"space/{space_key}/content/{content_type}"
|
|
119
|
+
params = {
|
|
120
|
+
"start": start,
|
|
121
|
+
"limit": limit
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if expand:
|
|
125
|
+
params["expand"] = expand
|
|
126
|
+
|
|
127
|
+
response = self._make_request(
|
|
128
|
+
method="GET",
|
|
129
|
+
endpoint=endpoint,
|
|
130
|
+
params=params
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Return the results
|
|
134
|
+
if isinstance(response, dict):
|
|
135
|
+
return response.get("results", [])
|
|
136
|
+
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
def search_space_content(
|
|
140
|
+
self,
|
|
141
|
+
space_key: str,
|
|
142
|
+
query: str,
|
|
143
|
+
start: int = 0,
|
|
144
|
+
limit: int = 25
|
|
145
|
+
) -> List[Dict[str, Any]]:
|
|
146
|
+
"""
|
|
147
|
+
Search for content in a space.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
space_key: Space key
|
|
151
|
+
query: Search query
|
|
152
|
+
start: Start index for pagination
|
|
153
|
+
limit: Maximum number of results to return
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
List of content data matching the search query
|
|
157
|
+
"""
|
|
158
|
+
# Use CQL (Confluence Query Language)
|
|
159
|
+
cql = f"space = {space_key} AND text ~ \"{query}\""
|
|
160
|
+
|
|
161
|
+
endpoint = "content/search"
|
|
162
|
+
params = {
|
|
163
|
+
"cql": cql,
|
|
164
|
+
"start": start,
|
|
165
|
+
"limit": limit,
|
|
166
|
+
"expand": "space,version"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
response = self._make_request(
|
|
170
|
+
method="GET",
|
|
171
|
+
endpoint=endpoint,
|
|
172
|
+
params=params
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Return the results
|
|
176
|
+
if isinstance(response, dict):
|
|
177
|
+
return response.get("results", [])
|
|
178
|
+
|
|
179
|
+
return []
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utilities for parsing Confluence URLs.
|
|
3
|
+
|
|
4
|
+
This module provides functions to extract page IDs and space keys from Confluence URLs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional, Tuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfluenceUrlParser:
|
|
12
|
+
"""Parser for Confluence URLs."""
|
|
13
|
+
|
|
14
|
+
# Pattern for Confluence page URLs:
|
|
15
|
+
# https://{domain}/wiki/spaces/{space_key}/pages/{page_id}/{title}
|
|
16
|
+
PAGE_URL_PATTERN = re.compile(
|
|
17
|
+
r'/wiki/spaces/(?P<space_key>[^/]+)/pages/(?P<page_id>\d+)(?:/|$)'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def parse_page_url(cls, url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
22
|
+
"""
|
|
23
|
+
Parse a Confluence page URL to extract page ID and space key.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
url: Confluence page URL
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Tuple of (page_id, space_key), or (None, None) if parsing fails
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
>>> parser = ConfluenceUrlParser()
|
|
33
|
+
>>> parser.parse_page_url('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Page+Title')
|
|
34
|
+
('123456', 'MYSPACE')
|
|
35
|
+
>>> parser.parse_page_url('https://example.atlassian.net/wiki/spaces/~630044b443e43992b9a3e6f2/pages/973308410/Reliability+Manifesto')
|
|
36
|
+
('973308410', '~630044b443e43992b9a3e6f2')
|
|
37
|
+
"""
|
|
38
|
+
match = cls.PAGE_URL_PATTERN.search(url)
|
|
39
|
+
if match:
|
|
40
|
+
return match.group('page_id'), match.group('space_key')
|
|
41
|
+
return None, None
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def extract_page_id(cls, url_or_id: str) -> Optional[str]:
|
|
45
|
+
"""
|
|
46
|
+
Extract page ID from a URL or return the ID if already provided.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
url_or_id: Either a Confluence URL or a page ID
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Page ID or None if not found
|
|
53
|
+
|
|
54
|
+
Examples:
|
|
55
|
+
>>> parser = ConfluenceUrlParser()
|
|
56
|
+
>>> parser.extract_page_id('123456')
|
|
57
|
+
'123456'
|
|
58
|
+
>>> parser.extract_page_id('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Title')
|
|
59
|
+
'123456'
|
|
60
|
+
"""
|
|
61
|
+
# If it's already just a number, return it
|
|
62
|
+
if url_or_id.isdigit():
|
|
63
|
+
return url_or_id
|
|
64
|
+
|
|
65
|
+
# Try to parse as URL
|
|
66
|
+
page_id, _ = cls.parse_page_url(url_or_id)
|
|
67
|
+
return page_id
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def extract_space_key(cls, url: str) -> Optional[str]:
|
|
71
|
+
"""
|
|
72
|
+
Extract space key from a Confluence URL.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
url: Confluence page URL
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Space key or None if not found
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> parser = ConfluenceUrlParser()
|
|
82
|
+
>>> parser.extract_space_key('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456/Title')
|
|
83
|
+
'MYSPACE'
|
|
84
|
+
"""
|
|
85
|
+
_, space_key = cls.parse_page_url(url)
|
|
86
|
+
return space_key
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def is_confluence_url(cls, text: str) -> bool:
|
|
90
|
+
"""
|
|
91
|
+
Check if the text looks like a Confluence URL.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
text: Text to check
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if it looks like a Confluence URL
|
|
98
|
+
|
|
99
|
+
Examples:
|
|
100
|
+
>>> parser = ConfluenceUrlParser()
|
|
101
|
+
>>> parser.is_confluence_url('https://example.atlassian.net/wiki/spaces/MYSPACE/pages/123456')
|
|
102
|
+
True
|
|
103
|
+
>>> parser.is_confluence_url('123456')
|
|
104
|
+
False
|
|
105
|
+
"""
|
|
106
|
+
return cls.PAGE_URL_PATTERN.search(text) is not None
|
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google Drive Authentication Module
|
|
3
|
+
|
|
4
|
+
Handles authentication for two separate Google accounts:
|
|
5
|
+
- Account A: Google Docs source
|
|
6
|
+
- Account B: Obsidian vault storage
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from google.auth.transport.requests import Request
|
|
14
|
+
from google.oauth2 import service_account
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Google Drive API scopes
|
|
19
|
+
PULL_SCOPES = [
|
|
20
|
+
"https://www.googleapis.com/auth/documents.readonly",
|
|
21
|
+
"https://www.googleapis.com/auth/drive.readonly",
|
|
22
|
+
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
PUSH_SCOPES = [
|
|
26
|
+
"https://www.googleapis.com/auth/documents",
|
|
27
|
+
"https://www.googleapis.com/auth/drive",
|
|
28
|
+
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Default to read-write scopes so push works out of the box.
|
|
32
|
+
# Note: existing tokens created with documents.readonly are insufficient for push.
|
|
33
|
+
DEFAULT_SCOPES = PUSH_SCOPES
|
|
34
|
+
|
|
35
|
+
# Legacy alias — kept so any code that references SCOPES still works.
|
|
36
|
+
SCOPES = DEFAULT_SCOPES
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GoogleAuthenticator:
|
|
40
|
+
"""Handles Google API authentication"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, credentials_json=None, credentials_path=None):
|
|
43
|
+
"""
|
|
44
|
+
Initialize authenticator with service account credentials
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
credentials_json: JSON string of service account credentials
|
|
48
|
+
credentials_path: Path to service account JSON file
|
|
49
|
+
"""
|
|
50
|
+
self.credentials = None
|
|
51
|
+
|
|
52
|
+
if credentials_json:
|
|
53
|
+
# Load from JSON string (for Railway env vars)
|
|
54
|
+
creds_dict = json.loads(credentials_json)
|
|
55
|
+
self.credentials = service_account.Credentials.from_service_account_info(
|
|
56
|
+
creds_dict, scopes=SCOPES
|
|
57
|
+
)
|
|
58
|
+
logger.info("Loaded credentials from JSON string")
|
|
59
|
+
elif credentials_path:
|
|
60
|
+
# Load from file (for local development)
|
|
61
|
+
self.credentials = service_account.Credentials.from_service_account_file(
|
|
62
|
+
credentials_path, scopes=SCOPES
|
|
63
|
+
)
|
|
64
|
+
logger.info(f"Loaded credentials from file: {credentials_path}")
|
|
65
|
+
else:
|
|
66
|
+
raise ValueError("Either credentials_json or credentials_path must be provided")
|
|
67
|
+
|
|
68
|
+
def get_credentials(self):
|
|
69
|
+
"""
|
|
70
|
+
Get valid credentials
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
google.auth.credentials.Credentials
|
|
74
|
+
"""
|
|
75
|
+
if not self.credentials:
|
|
76
|
+
raise ValueError("Credentials not initialized")
|
|
77
|
+
|
|
78
|
+
# Refresh if needed
|
|
79
|
+
if not self.credentials.valid:
|
|
80
|
+
logger.info("Refreshing credentials")
|
|
81
|
+
self.credentials.refresh(Request())
|
|
82
|
+
|
|
83
|
+
return self.credentials
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DualAccountAuth:
|
|
87
|
+
"""Manages authentication for both Google accounts"""
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
"""Initialize dual account authentication from environment variables"""
|
|
91
|
+
self.account_a_auth = None
|
|
92
|
+
self.account_b_auth = None
|
|
93
|
+
self._load_from_env()
|
|
94
|
+
|
|
95
|
+
def _load_from_env(self):
|
|
96
|
+
"""Load credentials from environment variables"""
|
|
97
|
+
# Account A (Google Docs)
|
|
98
|
+
account_a_json = os.getenv('ACCOUNT_A_CREDENTIALS')
|
|
99
|
+
account_a_path = os.getenv('ACCOUNT_A_CREDENTIALS_PATH')
|
|
100
|
+
|
|
101
|
+
if account_a_json:
|
|
102
|
+
self.account_a_auth = GoogleAuthenticator(credentials_json=account_a_json)
|
|
103
|
+
elif account_a_path:
|
|
104
|
+
self.account_a_auth = GoogleAuthenticator(credentials_path=account_a_path)
|
|
105
|
+
else:
|
|
106
|
+
logger.warning("Account A credentials not found in environment")
|
|
107
|
+
|
|
108
|
+
# Account B (Obsidian Vault)
|
|
109
|
+
account_b_json = os.getenv('ACCOUNT_B_CREDENTIALS')
|
|
110
|
+
account_b_path = os.getenv('ACCOUNT_B_CREDENTIALS_PATH')
|
|
111
|
+
|
|
112
|
+
if account_b_json:
|
|
113
|
+
self.account_b_auth = GoogleAuthenticator(credentials_json=account_b_json)
|
|
114
|
+
elif account_b_path:
|
|
115
|
+
self.account_b_auth = GoogleAuthenticator(credentials_path=account_b_path)
|
|
116
|
+
else:
|
|
117
|
+
logger.warning("Account B credentials not found in environment")
|
|
118
|
+
|
|
119
|
+
def get_account_a_credentials(self):
|
|
120
|
+
"""
|
|
121
|
+
Get credentials for Account A (Google Docs)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
google.auth.credentials.Credentials
|
|
125
|
+
"""
|
|
126
|
+
if not self.account_a_auth:
|
|
127
|
+
raise ValueError("Account A not authenticated. Check ACCOUNT_A_CREDENTIALS env var")
|
|
128
|
+
return self.account_a_auth.get_credentials()
|
|
129
|
+
|
|
130
|
+
def get_account_b_credentials(self):
|
|
131
|
+
"""
|
|
132
|
+
Get credentials for Account B (Obsidian Vault)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
google.auth.credentials.Credentials
|
|
136
|
+
"""
|
|
137
|
+
if not self.account_b_auth:
|
|
138
|
+
raise ValueError("Account B not authenticated. Check ACCOUNT_B_CREDENTIALS env var")
|
|
139
|
+
return self.account_b_auth.get_credentials()
|
|
140
|
+
|
|
141
|
+
def is_authenticated(self) -> bool:
|
|
142
|
+
"""Return True if the primary Google Docs account (account A) is configured."""
|
|
143
|
+
return self.account_a_auth is not None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Google Docs backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from docspan.backends.base import Backend, PullResult, PushResult
|
|
10
|
+
from docspan.backends.google_docs.auth import DualAccountAuth, GoogleAuthenticator
|
|
11
|
+
from docspan.backends.google_docs.client import GoogleDocsClient
|
|
12
|
+
from docspan.backends.google_docs.converter import DocumentConverter
|
|
13
|
+
from docspan.backends.google_docs.docs_request_builder import DocsRequestBuilder
|
|
14
|
+
from docspan.backends.google_docs.docs_structure_parser import DocsStructureParser
|
|
15
|
+
from docspan.backends.google_docs.markdown_to_paragraph_parser import MarkdownToParagraphParser
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from docspan.config import GoogleDocsConfig, MarkgateConfig
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GoogleDocsBackend(Backend):
|
|
22
|
+
name = "google_docs"
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: "GoogleDocsConfig") -> None:
|
|
25
|
+
self.config = config
|
|
26
|
+
self._client: GoogleDocsClient | None = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_config(cls, markgate_config: "MarkgateConfig") -> "GoogleDocsBackend":
|
|
30
|
+
from docspan.config import GoogleDocsConfig
|
|
31
|
+
return cls(markgate_config.backends.google_docs or GoogleDocsConfig())
|
|
32
|
+
|
|
33
|
+
def _ensure_client(self) -> None:
|
|
34
|
+
if self._client is not None:
|
|
35
|
+
return
|
|
36
|
+
if self.config.credentials_path:
|
|
37
|
+
auth = GoogleAuthenticator(credentials_path=self.config.credentials_path)
|
|
38
|
+
self._client = GoogleDocsClient(auth.get_credentials())
|
|
39
|
+
else:
|
|
40
|
+
dual = DualAccountAuth()
|
|
41
|
+
if not dual.is_authenticated():
|
|
42
|
+
raise RuntimeError(
|
|
43
|
+
"Google Docs credentials not found. "
|
|
44
|
+
"Set credentials_path in markgate.yaml or ACCOUNT_A_CREDENTIALS_PATH env var. "
|
|
45
|
+
"Run: docspan auth setup google_docs"
|
|
46
|
+
)
|
|
47
|
+
self._client = GoogleDocsClient(dual.get_account_a_credentials())
|
|
48
|
+
|
|
49
|
+
def push(self, local_path: str, doc_id: str, **kwargs: object) -> PushResult:
|
|
50
|
+
"""Convert local markdown to Google Docs format using structural diff and batch update."""
|
|
51
|
+
self._ensure_client()
|
|
52
|
+
assert self._client is not None
|
|
53
|
+
try:
|
|
54
|
+
content = pathlib.Path(local_path).read_text()
|
|
55
|
+
|
|
56
|
+
target_nodes = MarkdownToParagraphParser().parse(content)
|
|
57
|
+
doc = self._client.get_document(doc_id)
|
|
58
|
+
current_nodes = DocsStructureParser().parse(doc)
|
|
59
|
+
|
|
60
|
+
if "tabs" in doc and doc["tabs"]:
|
|
61
|
+
body_content = doc["tabs"][0].get("documentTab", doc).get("body", {}).get("content", [])
|
|
62
|
+
else:
|
|
63
|
+
body_content = doc.get("body", {}).get("content", [])
|
|
64
|
+
doc_end_index = body_content[-1].get("endIndex", 1) if body_content else 1
|
|
65
|
+
|
|
66
|
+
requests = DocsRequestBuilder().build(current_nodes, target_nodes, doc_end_index)
|
|
67
|
+
if not requests:
|
|
68
|
+
return PushResult(status="skipped", doc_id=doc_id, message="No changes detected")
|
|
69
|
+
|
|
70
|
+
self._client.batch_update(doc_id, requests)
|
|
71
|
+
url = f"https://docs.google.com/document/d/{doc_id}/edit"
|
|
72
|
+
return PushResult(status="ok", doc_id=doc_id, url=url)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
return PushResult(status="error", doc_id=doc_id, message=str(exc))
|
|
75
|
+
|
|
76
|
+
def pull(self, doc_id: str, local_path: str, **kwargs: object) -> PullResult:
|
|
77
|
+
"""Export Google Doc as HTML, convert to markdown, write locally."""
|
|
78
|
+
self._ensure_client()
|
|
79
|
+
assert self._client is not None
|
|
80
|
+
try:
|
|
81
|
+
html_content = self._client.get_doc_content(doc_id)
|
|
82
|
+
markdown_content = DocumentConverter().html_to_markdown(html_content)
|
|
83
|
+
pathlib.Path(local_path).parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
pathlib.Path(local_path).write_text(markdown_content)
|
|
85
|
+
return PullResult(status="ok", doc_id=doc_id, local_path=local_path)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
return PullResult(status="error", doc_id=doc_id, local_path=local_path, message=str(exc))
|
|
88
|
+
|
|
89
|
+
def get_remote_version(self, doc_id: str) -> str:
|
|
90
|
+
"""Return the revisionId of the Google Doc (opaque, non-empty string)."""
|
|
91
|
+
self._ensure_client()
|
|
92
|
+
assert self._client is not None
|
|
93
|
+
doc = self._client.get_document(doc_id)
|
|
94
|
+
return doc["revisionId"]
|
|
95
|
+
|
|
96
|
+
def auth_setup(self) -> None:
|
|
97
|
+
"""Print setup instructions for Google Docs service account credentials."""
|
|
98
|
+
has_path = self.config.credentials_path or os.getenv("ACCOUNT_A_CREDENTIALS_PATH")
|
|
99
|
+
has_json = os.getenv("ACCOUNT_A_CREDENTIALS")
|
|
100
|
+
|
|
101
|
+
if has_path or has_json:
|
|
102
|
+
print("Google Docs credentials are already configured.")
|
|
103
|
+
try:
|
|
104
|
+
self._ensure_client()
|
|
105
|
+
print("✓ Connection verified successfully.")
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
print(f"✗ Connection test failed: {exc}")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
print("\nGoogle Docs Auth Setup")
|
|
111
|
+
print("=" * 40)
|
|
112
|
+
print("docspan uses Google service account credentials for Google Docs access.")
|
|
113
|
+
print("\nSetup steps:")
|
|
114
|
+
print(" 1. Create a service account at:")
|
|
115
|
+
print(" https://console.cloud.google.com/iam-admin/serviceaccounts")
|
|
116
|
+
print(" 2. Enable Google Docs API and Google Drive API in your project")
|
|
117
|
+
print(" 3. Download the service account JSON key file")
|
|
118
|
+
print(" 4. Share your Google Docs with the service account email")
|
|
119
|
+
print("\nConfigure credentials via one of:")
|
|
120
|
+
print(" Option A — YAML config:")
|
|
121
|
+
print(" backends:")
|
|
122
|
+
print(" google_docs:")
|
|
123
|
+
print(" credentials_path: /path/to/service-account.json")
|
|
124
|
+
print(" Option B — environment variable (path):")
|
|
125
|
+
print(" export ACCOUNT_A_CREDENTIALS_PATH=/path/to/service-account.json")
|
|
126
|
+
print(" Option C — environment variable (inline JSON):")
|
|
127
|
+
print(" export ACCOUNT_A_CREDENTIALS='{ ... service account JSON ... }'")
|
|
128
|
+
|
|
129
|
+
def validate_config(self) -> None:
|
|
130
|
+
has_credentials = (
|
|
131
|
+
self.config.credentials_path
|
|
132
|
+
or os.getenv("ACCOUNT_A_CREDENTIALS_PATH")
|
|
133
|
+
or os.getenv("ACCOUNT_A_CREDENTIALS")
|
|
134
|
+
)
|
|
135
|
+
if not has_credentials:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
"Missing Google Docs credentials. "
|
|
138
|
+
"Set credentials_path in markgate.yaml or ACCOUNT_A_CREDENTIALS_PATH env var. "
|
|
139
|
+
"Run: docspan auth setup google_docs"
|
|
140
|
+
)
|