janet-cli 0.2.2__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.
- janet/__init__.py +3 -0
- janet/__main__.py +6 -0
- janet/api/__init__.py +0 -0
- janet/api/client.py +128 -0
- janet/api/models.py +92 -0
- janet/api/organizations.py +57 -0
- janet/api/projects.py +57 -0
- janet/api/tickets.py +125 -0
- janet/auth/__init__.py +0 -0
- janet/auth/callback_server.py +360 -0
- janet/auth/oauth_flow.py +276 -0
- janet/auth/token_manager.py +92 -0
- janet/cli.py +602 -0
- janet/config/__init__.py +0 -0
- janet/config/manager.py +116 -0
- janet/config/models.py +66 -0
- janet/markdown/__init__.py +0 -0
- janet/markdown/generator.py +272 -0
- janet/markdown/yjs_converter.py +225 -0
- janet/sync/__init__.py +0 -0
- janet/sync/file_manager.py +199 -0
- janet/sync/readme_generator.py +174 -0
- janet/sync/sync_engine.py +271 -0
- janet/utils/__init__.py +0 -0
- janet/utils/console.py +39 -0
- janet/utils/errors.py +49 -0
- janet/utils/paths.py +66 -0
- janet_cli-0.2.2.dist-info/METADATA +220 -0
- janet_cli-0.2.2.dist-info/RECORD +33 -0
- janet_cli-0.2.2.dist-info/WHEEL +5 -0
- janet_cli-0.2.2.dist-info/entry_points.txt +2 -0
- janet_cli-0.2.2.dist-info/licenses/LICENSE +21 -0
- janet_cli-0.2.2.dist-info/top_level.txt +1 -0
janet/config/manager.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Configuration file management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from janet.config.models import Config
|
|
8
|
+
from janet.utils.errors import ConfigurationError
|
|
9
|
+
from janet.utils.paths import get_config_file
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ConfigManager:
|
|
13
|
+
"""Manages configuration file read/write operations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, config_path: Optional[Path] = None):
|
|
16
|
+
"""
|
|
17
|
+
Initialize configuration manager.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
config_path: Optional custom config file path
|
|
21
|
+
"""
|
|
22
|
+
self.config_path = config_path or get_config_file()
|
|
23
|
+
self._config: Optional[Config] = None
|
|
24
|
+
|
|
25
|
+
def load(self) -> Config:
|
|
26
|
+
"""
|
|
27
|
+
Load configuration from file.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Configuration object
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ConfigurationError: If config file is invalid
|
|
34
|
+
"""
|
|
35
|
+
if not self.config_path.exists():
|
|
36
|
+
# Create default configuration
|
|
37
|
+
self._config = Config()
|
|
38
|
+
self.save()
|
|
39
|
+
return self._config
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
with open(self.config_path, "r") as f:
|
|
43
|
+
data = json.load(f)
|
|
44
|
+
self._config = Config(**data)
|
|
45
|
+
return self._config
|
|
46
|
+
except Exception as e:
|
|
47
|
+
raise ConfigurationError(f"Failed to load configuration: {e}")
|
|
48
|
+
|
|
49
|
+
def save(self) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Save configuration to file.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ConfigurationError: If unable to save config
|
|
55
|
+
"""
|
|
56
|
+
if self._config is None:
|
|
57
|
+
raise ConfigurationError("No configuration loaded")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Ensure parent directory exists
|
|
61
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# Write config with indentation for readability
|
|
64
|
+
with open(self.config_path, "w") as f:
|
|
65
|
+
json.dump(self._config.model_dump(mode="json"), f, indent=2, default=str)
|
|
66
|
+
|
|
67
|
+
# Set restrictive permissions (user read/write only)
|
|
68
|
+
self.config_path.chmod(0o600)
|
|
69
|
+
except Exception as e:
|
|
70
|
+
raise ConfigurationError(f"Failed to save configuration: {e}")
|
|
71
|
+
|
|
72
|
+
def get(self) -> Config:
|
|
73
|
+
"""
|
|
74
|
+
Get current configuration.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Configuration object
|
|
78
|
+
"""
|
|
79
|
+
if self._config is None:
|
|
80
|
+
self._config = self.load()
|
|
81
|
+
return self._config
|
|
82
|
+
|
|
83
|
+
def update(self, config: Config) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Update configuration and save to file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
config: Updated configuration object
|
|
89
|
+
"""
|
|
90
|
+
self._config = config
|
|
91
|
+
self.save()
|
|
92
|
+
|
|
93
|
+
def reset(self) -> None:
|
|
94
|
+
"""Reset configuration to defaults."""
|
|
95
|
+
self._config = Config()
|
|
96
|
+
self.save()
|
|
97
|
+
|
|
98
|
+
def is_authenticated(self) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Check if user is authenticated.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if valid access token exists
|
|
104
|
+
"""
|
|
105
|
+
config = self.get()
|
|
106
|
+
return config.auth.access_token is not None
|
|
107
|
+
|
|
108
|
+
def has_organization(self) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Check if organization is selected.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if organization is selected
|
|
114
|
+
"""
|
|
115
|
+
config = self.get()
|
|
116
|
+
return config.selected_organization is not None
|
janet/config/models.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Pydantic models for configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthConfig(BaseModel):
|
|
10
|
+
"""Authentication configuration."""
|
|
11
|
+
|
|
12
|
+
access_token: Optional[str] = None
|
|
13
|
+
refresh_token: Optional[str] = None
|
|
14
|
+
expires_at: Optional[datetime] = None
|
|
15
|
+
user_id: Optional[str] = None
|
|
16
|
+
user_email: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OrganizationInfo(BaseModel):
|
|
20
|
+
"""Organization information."""
|
|
21
|
+
|
|
22
|
+
id: str
|
|
23
|
+
name: str
|
|
24
|
+
uuid: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class APIConfig(BaseModel):
|
|
28
|
+
"""API configuration."""
|
|
29
|
+
|
|
30
|
+
# Production URL by default, override with env var for development
|
|
31
|
+
base_url: str = Field(
|
|
32
|
+
default_factory=lambda: os.getenv(
|
|
33
|
+
"JANET_API_BASE_URL",
|
|
34
|
+
"https://janet-ai-backend-prod-service-11399-85b8d46f-d4i7j6xw.onporter.run"
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
timeout: int = 30
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SyncConfig(BaseModel):
|
|
41
|
+
"""Sync configuration."""
|
|
42
|
+
|
|
43
|
+
root_directory: str = "~/janet-tickets"
|
|
44
|
+
last_sync_times: Dict[str, str] = Field(default_factory=dict)
|
|
45
|
+
sync_on_init: bool = False
|
|
46
|
+
batch_size: int = 50
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class MarkdownConfig(BaseModel):
|
|
50
|
+
"""Markdown generation configuration."""
|
|
51
|
+
|
|
52
|
+
include_comments: bool = True
|
|
53
|
+
include_attachments: bool = True
|
|
54
|
+
include_metadata: bool = True
|
|
55
|
+
yjs_fallback_mode: str = "plain_text"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Config(BaseModel):
|
|
59
|
+
"""Complete configuration model."""
|
|
60
|
+
|
|
61
|
+
version: str = "1.0"
|
|
62
|
+
auth: AuthConfig = Field(default_factory=AuthConfig)
|
|
63
|
+
selected_organization: Optional[OrganizationInfo] = None
|
|
64
|
+
api: APIConfig = Field(default_factory=APIConfig)
|
|
65
|
+
sync: SyncConfig = Field(default_factory=SyncConfig)
|
|
66
|
+
markdown: MarkdownConfig = Field(default_factory=MarkdownConfig)
|
|
File without changes
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Generate markdown from ticket data."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from janet.markdown.yjs_converter import YjsConverter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MarkdownGenerator:
|
|
10
|
+
"""
|
|
11
|
+
Generate markdown documents from ticket data.
|
|
12
|
+
|
|
13
|
+
Based on TypeScript implementation in copyTicketAsMarkdown.ts
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
"""Initialize markdown generator."""
|
|
18
|
+
self.yjs_converter = YjsConverter()
|
|
19
|
+
|
|
20
|
+
def generate(
|
|
21
|
+
self,
|
|
22
|
+
ticket: Dict,
|
|
23
|
+
organization_members: Optional[List[Dict]] = None,
|
|
24
|
+
attachments: Optional[Dict] = None,
|
|
25
|
+
) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Generate complete markdown document from ticket data.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
ticket: Ticket dictionary
|
|
31
|
+
organization_members: List of organization members (for name resolution)
|
|
32
|
+
attachments: Dictionary with direct_attachments and indirect_attachments
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Complete markdown string
|
|
36
|
+
"""
|
|
37
|
+
sections = []
|
|
38
|
+
|
|
39
|
+
# 1. Title
|
|
40
|
+
ticket_key = ticket.get("ticket_key", "UNKNOWN")
|
|
41
|
+
title = ticket.get("title", "Untitled")
|
|
42
|
+
sections.append(f"# {ticket_key}: {title}\n")
|
|
43
|
+
|
|
44
|
+
# 2. Metadata
|
|
45
|
+
sections.append(self._generate_metadata(ticket, organization_members))
|
|
46
|
+
|
|
47
|
+
# 3. Description
|
|
48
|
+
sections.append(self._generate_description(ticket))
|
|
49
|
+
|
|
50
|
+
# 4. Comments
|
|
51
|
+
if ticket.get("comments"):
|
|
52
|
+
sections.append(
|
|
53
|
+
self._generate_comments(ticket["comments"], organization_members)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# 5. Attachments
|
|
57
|
+
if attachments:
|
|
58
|
+
sections.append(self._generate_attachments(attachments, organization_members))
|
|
59
|
+
|
|
60
|
+
# 6. Child Tasks
|
|
61
|
+
if ticket.get("child_tasks"):
|
|
62
|
+
sections.append(self._generate_child_tasks(ticket["child_tasks"]))
|
|
63
|
+
|
|
64
|
+
# 7. Footer
|
|
65
|
+
sections.append(self._generate_footer(ticket_key))
|
|
66
|
+
|
|
67
|
+
return "\n".join(sections)
|
|
68
|
+
|
|
69
|
+
def _generate_metadata(
|
|
70
|
+
self, ticket: Dict, organization_members: Optional[List[Dict]] = None
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Generate metadata section."""
|
|
73
|
+
lines = ["## Metadata\n"]
|
|
74
|
+
|
|
75
|
+
# Status
|
|
76
|
+
lines.append(f"- **Status:** {ticket.get('status', 'Unknown')}")
|
|
77
|
+
|
|
78
|
+
# Priority
|
|
79
|
+
lines.append(f"- **Priority:** {ticket.get('priority', 'Unknown')}")
|
|
80
|
+
|
|
81
|
+
# Type
|
|
82
|
+
lines.append(f"- **Type:** {ticket.get('issue_type', 'Unknown')}")
|
|
83
|
+
|
|
84
|
+
# Assignees
|
|
85
|
+
assignees = ticket.get("assignees", [])
|
|
86
|
+
if assignees:
|
|
87
|
+
assignee_names = [
|
|
88
|
+
self._resolve_user_name(email, organization_members) for email in assignees
|
|
89
|
+
]
|
|
90
|
+
lines.append(f"- **Assignees:** {', '.join(assignee_names)}")
|
|
91
|
+
else:
|
|
92
|
+
lines.append("- **Assignees:** Unassigned")
|
|
93
|
+
|
|
94
|
+
# Creator
|
|
95
|
+
creator = ticket.get("creator", "Unknown")
|
|
96
|
+
creator_name = self._resolve_user_name(creator, organization_members)
|
|
97
|
+
lines.append(f"- **Creator:** {creator_name}")
|
|
98
|
+
|
|
99
|
+
# Dates
|
|
100
|
+
created_at = ticket.get("created_at", "")
|
|
101
|
+
updated_at = ticket.get("updated_at", "")
|
|
102
|
+
lines.append(f"- **Created:** {self._format_date(created_at)}")
|
|
103
|
+
lines.append(f"- **Updated:** {self._format_date(updated_at)}")
|
|
104
|
+
|
|
105
|
+
# Optional fields
|
|
106
|
+
if ticket.get("story_points"):
|
|
107
|
+
lines.append(f"- **Story Points:** {ticket['story_points']}")
|
|
108
|
+
|
|
109
|
+
if ticket.get("due_date"):
|
|
110
|
+
lines.append(f"- **Due Date:** {self._format_date(ticket['due_date'])}")
|
|
111
|
+
|
|
112
|
+
if ticket.get("sprint"):
|
|
113
|
+
lines.append(f"- **Sprint:** {ticket['sprint']}")
|
|
114
|
+
|
|
115
|
+
if ticket.get("labels"):
|
|
116
|
+
labels = ticket["labels"]
|
|
117
|
+
if labels:
|
|
118
|
+
lines.append(f"- **Labels:** {', '.join(labels)}")
|
|
119
|
+
|
|
120
|
+
lines.append("") # Empty line after metadata
|
|
121
|
+
return "\n".join(lines)
|
|
122
|
+
|
|
123
|
+
def _generate_description(self, ticket: Dict) -> str:
|
|
124
|
+
"""Generate description section."""
|
|
125
|
+
lines = ["## Description\n"]
|
|
126
|
+
|
|
127
|
+
yjs_binary = ticket.get("description_yjs_binary")
|
|
128
|
+
plain_text = ticket.get("description")
|
|
129
|
+
|
|
130
|
+
# Convert Yjs binary to markdown
|
|
131
|
+
markdown = self.yjs_converter.convert(yjs_binary, plain_text)
|
|
132
|
+
lines.append(markdown)
|
|
133
|
+
|
|
134
|
+
lines.append("") # Empty line after description
|
|
135
|
+
return "\n".join(lines)
|
|
136
|
+
|
|
137
|
+
def _generate_comments(
|
|
138
|
+
self, comments: List[Dict], organization_members: Optional[List[Dict]] = None
|
|
139
|
+
) -> str:
|
|
140
|
+
"""Generate comments section."""
|
|
141
|
+
lines = [f"## Comments ({len(comments)})\n"]
|
|
142
|
+
|
|
143
|
+
for comment in comments:
|
|
144
|
+
author = comment.get("created_by", "Unknown")
|
|
145
|
+
author_name = self._resolve_user_name(author, organization_members)
|
|
146
|
+
timestamp = self._format_date(comment.get("created_at", ""))
|
|
147
|
+
|
|
148
|
+
lines.append(f"### {author_name} - {timestamp}\n")
|
|
149
|
+
lines.append(comment.get("content", ""))
|
|
150
|
+
lines.append("") # Empty line between comments
|
|
151
|
+
|
|
152
|
+
return "\n".join(lines)
|
|
153
|
+
|
|
154
|
+
def _generate_attachments(
|
|
155
|
+
self, attachments: Dict, organization_members: Optional[List[Dict]] = None
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Generate attachments section."""
|
|
158
|
+
direct = attachments.get("direct_attachments", [])
|
|
159
|
+
indirect = attachments.get("indirect_attachments", [])
|
|
160
|
+
all_attachments = direct + indirect
|
|
161
|
+
|
|
162
|
+
if not all_attachments:
|
|
163
|
+
return ""
|
|
164
|
+
|
|
165
|
+
lines = [f"## Attachments ({len(all_attachments)})\n"]
|
|
166
|
+
|
|
167
|
+
for attachment in all_attachments:
|
|
168
|
+
filename = attachment.get("original_filename", "Unknown")
|
|
169
|
+
lines.append(f"### {filename}")
|
|
170
|
+
|
|
171
|
+
lines.append(f"- **Type:** {attachment.get('mime_type', 'Unknown')}")
|
|
172
|
+
|
|
173
|
+
uploader = attachment.get("uploaded_by", "Unknown")
|
|
174
|
+
uploader_name = self._resolve_user_name(uploader, organization_members)
|
|
175
|
+
lines.append(f"- **Uploaded by:** {uploader_name}")
|
|
176
|
+
|
|
177
|
+
created_at = attachment.get("created_at", "")
|
|
178
|
+
lines.append(f"- **Uploaded:** {self._format_date(created_at)}")
|
|
179
|
+
|
|
180
|
+
if attachment.get("ai_description"):
|
|
181
|
+
lines.append(f"- **AI Description:** {attachment['ai_description']}")
|
|
182
|
+
|
|
183
|
+
lines.append("") # Empty line between attachments
|
|
184
|
+
|
|
185
|
+
return "\n".join(lines)
|
|
186
|
+
|
|
187
|
+
def _generate_child_tasks(self, child_tasks: List[Dict]) -> str:
|
|
188
|
+
"""Generate child tasks section."""
|
|
189
|
+
if not child_tasks:
|
|
190
|
+
return ""
|
|
191
|
+
|
|
192
|
+
lines = [f"## Child Tasks ({len(child_tasks)})\n"]
|
|
193
|
+
|
|
194
|
+
for task in child_tasks:
|
|
195
|
+
identifier = task.get("fullIdentifier", task.get("childIdentifier", ""))
|
|
196
|
+
title = task.get("title", "Untitled")
|
|
197
|
+
status = task.get("status", "")
|
|
198
|
+
priority = task.get("priority", "")
|
|
199
|
+
|
|
200
|
+
# Format: - [STATUS] IDENTIFIER: Title (Priority)
|
|
201
|
+
status_badge = f"[{status}]" if status else ""
|
|
202
|
+
priority_info = f"({priority})" if priority else ""
|
|
203
|
+
|
|
204
|
+
line = f"- {status_badge} **{identifier}**: {title}"
|
|
205
|
+
if priority_info:
|
|
206
|
+
line += f" {priority_info}"
|
|
207
|
+
|
|
208
|
+
lines.append(line)
|
|
209
|
+
|
|
210
|
+
lines.append("") # Empty line after child tasks
|
|
211
|
+
return "\n".join(lines)
|
|
212
|
+
|
|
213
|
+
def _generate_footer(self, ticket_key: str) -> str:
|
|
214
|
+
"""Generate footer section."""
|
|
215
|
+
export_date = self._format_date(datetime.utcnow().isoformat())
|
|
216
|
+
return f"---\n*Exported from {ticket_key} on {export_date}*"
|
|
217
|
+
|
|
218
|
+
def _resolve_user_name(
|
|
219
|
+
self, email: str, organization_members: Optional[List[Dict]] = None
|
|
220
|
+
) -> str:
|
|
221
|
+
"""
|
|
222
|
+
Resolve user email to display name.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
email: User email
|
|
226
|
+
organization_members: List of organization members
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Display name or email if not found
|
|
230
|
+
"""
|
|
231
|
+
if not organization_members:
|
|
232
|
+
return email
|
|
233
|
+
|
|
234
|
+
for member in organization_members:
|
|
235
|
+
if member.get("email") == email:
|
|
236
|
+
first_name = member.get("firstName", "")
|
|
237
|
+
last_name = member.get("lastName", "")
|
|
238
|
+
if first_name and last_name:
|
|
239
|
+
return f"{first_name} {last_name}"
|
|
240
|
+
elif first_name:
|
|
241
|
+
return first_name
|
|
242
|
+
break
|
|
243
|
+
|
|
244
|
+
return email
|
|
245
|
+
|
|
246
|
+
def _format_date(self, iso_string: str) -> str:
|
|
247
|
+
"""
|
|
248
|
+
Format ISO timestamp to human-readable date.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
iso_string: ISO 8601 timestamp
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
Formatted date string
|
|
255
|
+
"""
|
|
256
|
+
if not iso_string:
|
|
257
|
+
return "Unknown"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
# Parse ISO string (handle both with and without microseconds)
|
|
261
|
+
if "." in iso_string:
|
|
262
|
+
# Has microseconds
|
|
263
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
264
|
+
else:
|
|
265
|
+
# No microseconds
|
|
266
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
267
|
+
|
|
268
|
+
# Format: Jan 15, 2024 10:30 AM
|
|
269
|
+
return dt.strftime("%b %d, %Y %I:%M %p")
|
|
270
|
+
except Exception:
|
|
271
|
+
# Fallback: return as-is
|
|
272
|
+
return iso_string
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""Convert Yjs binary to markdown using pycrdt."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Optional, List
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class YjsConverter:
|
|
11
|
+
"""
|
|
12
|
+
Convert Yjs binary to markdown.
|
|
13
|
+
|
|
14
|
+
Uses pycrdt (actively maintained, replaces abandoned y-py).
|
|
15
|
+
Based on TypeScript implementation in copyTicketAsMarkdown.ts
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def convert(
|
|
19
|
+
self, yjs_binary_base64: Optional[str], plain_text_fallback: Optional[str] = None
|
|
20
|
+
) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Convert Yjs binary to markdown.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
yjs_binary_base64: Base64-encoded Yjs binary
|
|
26
|
+
plain_text_fallback: Plain text description (NOT USED - only for compatibility)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Markdown string
|
|
30
|
+
"""
|
|
31
|
+
# If no Yjs binary, show placeholder
|
|
32
|
+
if not yjs_binary_base64:
|
|
33
|
+
return "*No description provided*"
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
# Try pycrdt conversion
|
|
37
|
+
markdown = self._convert_with_pycrdt(yjs_binary_base64)
|
|
38
|
+
if markdown and markdown.strip():
|
|
39
|
+
return markdown
|
|
40
|
+
else:
|
|
41
|
+
return "*[Empty description]*"
|
|
42
|
+
except Exception as e:
|
|
43
|
+
# Show error instead of falling back
|
|
44
|
+
logger.error(f"Yjs conversion failed: {e}")
|
|
45
|
+
return f"*[Yjs conversion failed: {e}]*"
|
|
46
|
+
|
|
47
|
+
def _convert_with_pycrdt(self, base64_str: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
Use pycrdt library to parse Yjs binary and extract text.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base64_str: Base64-encoded Yjs binary
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Markdown string
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
Exception: If conversion fails
|
|
59
|
+
"""
|
|
60
|
+
try:
|
|
61
|
+
from pycrdt import Doc, XmlElement, XmlText, XmlFragment
|
|
62
|
+
except ImportError:
|
|
63
|
+
raise Exception("pycrdt library not installed. Run: pip install pycrdt")
|
|
64
|
+
|
|
65
|
+
# Decode base64
|
|
66
|
+
binary_data = self._base64_to_bytes(base64_str)
|
|
67
|
+
if not binary_data:
|
|
68
|
+
raise Exception("Failed to decode base64")
|
|
69
|
+
|
|
70
|
+
# Create Doc and apply update
|
|
71
|
+
doc = Doc()
|
|
72
|
+
doc.apply_update(binary_data)
|
|
73
|
+
|
|
74
|
+
# Get the BlockNote editor fragment
|
|
75
|
+
try:
|
|
76
|
+
# pycrdt uses get() with type= parameter
|
|
77
|
+
fragment = doc.get("blocknote-editor", type=XmlFragment)
|
|
78
|
+
except Exception as e:
|
|
79
|
+
raise Exception(f"Failed to get blocknote-editor fragment: {e}")
|
|
80
|
+
|
|
81
|
+
if fragment is None:
|
|
82
|
+
raise Exception("blocknote-editor fragment is None")
|
|
83
|
+
|
|
84
|
+
# Extract text from fragment
|
|
85
|
+
return self._extract_text_from_fragment(fragment)
|
|
86
|
+
|
|
87
|
+
def _base64_to_bytes(self, base64_str: str) -> Optional[bytes]:
|
|
88
|
+
"""Convert base64 string to bytes."""
|
|
89
|
+
try:
|
|
90
|
+
if not base64_str or not isinstance(base64_str, str):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
cleaned = base64_str.strip()
|
|
94
|
+
|
|
95
|
+
# Validate base64 format
|
|
96
|
+
import re
|
|
97
|
+
if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', cleaned):
|
|
98
|
+
logger.warning("Invalid base64 format detected")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
return base64.b64decode(cleaned)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.warning(f"Failed to decode base64: {e}")
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def _extract_text_from_fragment(self, fragment) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Extract markdown text from XmlFragment.
|
|
109
|
+
|
|
110
|
+
This mimics the TypeScript extractTextFromYDoc function.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
fragment: XmlFragment containing BlockNote content
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Markdown string
|
|
117
|
+
"""
|
|
118
|
+
from pycrdt import XmlElement, XmlText
|
|
119
|
+
|
|
120
|
+
lines: List[str] = []
|
|
121
|
+
|
|
122
|
+
def process_node(node, indent: int = 0, add_blank_line: bool = True):
|
|
123
|
+
"""Process a single XML node recursively."""
|
|
124
|
+
try:
|
|
125
|
+
if isinstance(node, XmlText):
|
|
126
|
+
# Handle text nodes - these are inline, no blank line
|
|
127
|
+
text = str(node).strip()
|
|
128
|
+
if text:
|
|
129
|
+
lines.append(" " * indent + text)
|
|
130
|
+
|
|
131
|
+
elif isinstance(node, XmlElement):
|
|
132
|
+
# Handle element nodes
|
|
133
|
+
node_name = node.tag
|
|
134
|
+
children = list(node.children) if hasattr(node, "children") else []
|
|
135
|
+
|
|
136
|
+
# Collect text content from XmlText children
|
|
137
|
+
text_content = "".join(
|
|
138
|
+
str(child) for child in children if isinstance(child, XmlText)
|
|
139
|
+
).strip()
|
|
140
|
+
|
|
141
|
+
# Node processing (debug removed)
|
|
142
|
+
|
|
143
|
+
# Process based on node type (matching BlockNote structure)
|
|
144
|
+
|
|
145
|
+
# BlockNote wrapper elements - just recurse into children
|
|
146
|
+
if node_name in ("blockGroup", "blockContainer"):
|
|
147
|
+
for child in children:
|
|
148
|
+
process_node(child, indent, add_blank_line)
|
|
149
|
+
|
|
150
|
+
# Actual content blocks
|
|
151
|
+
elif node_name == "heading" and text_content:
|
|
152
|
+
level = int(node.attributes.get("level", 1))
|
|
153
|
+
lines.append("#" * level + " " + text_content)
|
|
154
|
+
lines.append("") # Blank line after heading
|
|
155
|
+
|
|
156
|
+
elif node_name == "paragraph" and text_content:
|
|
157
|
+
lines.append(text_content)
|
|
158
|
+
lines.append("") # Blank line after paragraph
|
|
159
|
+
|
|
160
|
+
elif node_name == "quote" and text_content:
|
|
161
|
+
# BlockNote quote becomes markdown blockquote
|
|
162
|
+
lines.append("> " + text_content)
|
|
163
|
+
lines.append("") # Blank line after quote
|
|
164
|
+
|
|
165
|
+
elif node_name == "bulletListItem" and text_content:
|
|
166
|
+
lines.append(" " * indent + "- " + text_content)
|
|
167
|
+
# Process nested lists
|
|
168
|
+
for child in children:
|
|
169
|
+
if isinstance(child, XmlElement):
|
|
170
|
+
process_node(child, indent + 1, add_blank_line=False)
|
|
171
|
+
|
|
172
|
+
elif node_name == "numberedListItem" and text_content:
|
|
173
|
+
lines.append(" " * indent + "1. " + text_content)
|
|
174
|
+
# Process nested lists
|
|
175
|
+
for child in children:
|
|
176
|
+
if isinstance(child, XmlElement):
|
|
177
|
+
process_node(child, indent + 1, add_blank_line=False)
|
|
178
|
+
|
|
179
|
+
elif node_name == "checkListItem":
|
|
180
|
+
checked = node.attributes.get("checked", "false") == "true"
|
|
181
|
+
checkbox = "[x]" if checked else "[ ]"
|
|
182
|
+
if text_content:
|
|
183
|
+
lines.append(" " * indent + f"- {checkbox} " + text_content)
|
|
184
|
+
|
|
185
|
+
elif node_name == "codeBlock":
|
|
186
|
+
language = node.attributes.get("language", "")
|
|
187
|
+
lines.append(f"```{language}")
|
|
188
|
+
if text_content:
|
|
189
|
+
lines.append(text_content)
|
|
190
|
+
lines.append("```")
|
|
191
|
+
lines.append("")
|
|
192
|
+
|
|
193
|
+
elif node_name == "hardBreak":
|
|
194
|
+
# Hard breaks are inline - ignore them as we add blank lines between blocks
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Handle empty paragraphs or unknown blocks
|
|
198
|
+
elif text_content:
|
|
199
|
+
lines.append(text_content)
|
|
200
|
+
lines.append("")
|
|
201
|
+
|
|
202
|
+
# Always process children for unknown types
|
|
203
|
+
elif node_name not in ("hardBreak",):
|
|
204
|
+
for child in children:
|
|
205
|
+
process_node(child, indent, add_blank_line=True)
|
|
206
|
+
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.debug(f"Error processing node: {e}")
|
|
209
|
+
|
|
210
|
+
# Process all top-level children
|
|
211
|
+
try:
|
|
212
|
+
if hasattr(fragment, "children"):
|
|
213
|
+
children_list = list(fragment.children)
|
|
214
|
+
for child in children_list:
|
|
215
|
+
process_node(child)
|
|
216
|
+
else:
|
|
217
|
+
# Try iterating directly
|
|
218
|
+
for child in fragment:
|
|
219
|
+
process_node(child)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Failed to iterate fragment: {e}")
|
|
222
|
+
raise Exception(f"Could not iterate fragment children")
|
|
223
|
+
|
|
224
|
+
result = "\n".join(lines).strip()
|
|
225
|
+
return result if result else ""
|
janet/sync/__init__.py
ADDED
|
File without changes
|