claude-code-tools 1.0.6__py3-none-any.whl → 1.4.6__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.
- claude_code_tools/__init__.py +1 -1
- claude_code_tools/action_rpc.py +16 -10
- claude_code_tools/aichat.py +793 -51
- claude_code_tools/claude_continue.py +4 -0
- claude_code_tools/codex_continue.py +48 -0
- claude_code_tools/export_session.py +94 -11
- claude_code_tools/find_claude_session.py +36 -12
- claude_code_tools/find_codex_session.py +33 -18
- claude_code_tools/find_session.py +30 -16
- claude_code_tools/gdoc2md.py +220 -0
- claude_code_tools/md2gdoc.py +549 -0
- claude_code_tools/search_index.py +119 -15
- claude_code_tools/session_menu_cli.py +1 -1
- claude_code_tools/session_utils.py +3 -3
- claude_code_tools/smart_trim.py +18 -8
- claude_code_tools/smart_trim_core.py +4 -2
- claude_code_tools/tmux_cli_controller.py +35 -25
- claude_code_tools/trim_session.py +28 -2
- claude_code_tools-1.4.6.dist-info/METADATA +1112 -0
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/RECORD +31 -24
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/entry_points.txt +2 -0
- docs/linked-in-20260102.md +32 -0
- docs/local-llm-setup.md +286 -0
- docs/reddit-aichat-resume-v2.md +80 -0
- docs/reddit-aichat-resume.md +29 -0
- docs/reddit-aichat.md +79 -0
- docs/rollover-details.md +67 -0
- node_ui/action_config.js +3 -3
- node_ui/menu.js +67 -113
- claude_code_tools/session_tui.py +0 -516
- claude_code_tools-1.0.6.dist-info/METADATA +0 -685
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/WHEEL +0 -0
- {claude_code_tools-1.0.6.dist-info → claude_code_tools-1.4.6.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
md2gdoc: Upload Markdown files to Google Drive as native Google Docs.
|
|
4
|
+
|
|
5
|
+
This tool uses the Google Drive API to upload markdown files with native
|
|
6
|
+
conversion to Google Docs format - the same conversion that happens when
|
|
7
|
+
you manually upload a .md file and click "Open in Google Docs".
|
|
8
|
+
|
|
9
|
+
Prerequisites:
|
|
10
|
+
- First run: Will open browser for OAuth authentication (one-time setup)
|
|
11
|
+
- Credentials stored in ~/.config/md2gdoc/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.panel import Panel
|
|
24
|
+
from rich.prompt import Prompt
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
console = Console()
|
|
28
|
+
|
|
29
|
+
# Google Drive API scopes
|
|
30
|
+
# Using 'drive' scope to access all files (including shortcuts/shared folders)
|
|
31
|
+
# 'drive.file' only sees files created by this app
|
|
32
|
+
SCOPES = ["https://www.googleapis.com/auth/drive"]
|
|
33
|
+
|
|
34
|
+
# Config directory for storing credentials (global fallback)
|
|
35
|
+
CONFIG_DIR = Path.home() / ".config" / "md2gdoc"
|
|
36
|
+
|
|
37
|
+
# Local (project-specific) credential files - checked first
|
|
38
|
+
LOCAL_TOKEN_FILE = Path(".gdoc-token.json")
|
|
39
|
+
LOCAL_CREDENTIALS_FILE = Path(".gdoc-credentials.json")
|
|
40
|
+
|
|
41
|
+
# Global credential files - fallback
|
|
42
|
+
GLOBAL_TOKEN_FILE = CONFIG_DIR / "token.json"
|
|
43
|
+
GLOBAL_CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_token_file() -> Path:
|
|
47
|
+
"""Get token file path - local first, then global."""
|
|
48
|
+
if LOCAL_TOKEN_FILE.exists():
|
|
49
|
+
return LOCAL_TOKEN_FILE
|
|
50
|
+
return GLOBAL_TOKEN_FILE
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_credentials_file() -> Path:
|
|
54
|
+
"""Get credentials file path - local first, then global."""
|
|
55
|
+
if LOCAL_CREDENTIALS_FILE.exists():
|
|
56
|
+
return LOCAL_CREDENTIALS_FILE
|
|
57
|
+
if GLOBAL_CREDENTIALS_FILE.exists():
|
|
58
|
+
return GLOBAL_CREDENTIALS_FILE
|
|
59
|
+
# Return local path for error messages (preferred location)
|
|
60
|
+
return LOCAL_CREDENTIALS_FILE
|
|
61
|
+
|
|
62
|
+
# Default OAuth client credentials (for CLI tools)
|
|
63
|
+
# Users can override by placing their own credentials.json in CONFIG_DIR
|
|
64
|
+
DEFAULT_CLIENT_CONFIG = {
|
|
65
|
+
"installed": {
|
|
66
|
+
"client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
|
67
|
+
"project_id": "md2gdoc",
|
|
68
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
69
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
70
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
71
|
+
"client_secret": "YOUR_CLIENT_SECRET",
|
|
72
|
+
"redirect_uris": ["http://localhost"],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_dependencies() -> bool:
|
|
78
|
+
"""Check if required Google API packages are installed."""
|
|
79
|
+
try:
|
|
80
|
+
from google.oauth2.credentials import Credentials # noqa: F401
|
|
81
|
+
from google_auth_oauthlib.flow import InstalledAppFlow # noqa: F401
|
|
82
|
+
from googleapiclient.discovery import build # noqa: F401
|
|
83
|
+
|
|
84
|
+
return True
|
|
85
|
+
except ImportError:
|
|
86
|
+
console.print(
|
|
87
|
+
Panel(
|
|
88
|
+
"[red]Missing dependencies:[/red] google-api-python-client, "
|
|
89
|
+
"google-auth-oauthlib\n\n"
|
|
90
|
+
"Install with:\n"
|
|
91
|
+
"[cyan]pip install google-api-python-client google-auth-oauthlib[/cyan]",
|
|
92
|
+
title="Dependencies Required",
|
|
93
|
+
border_style="red",
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_credentials():
|
|
100
|
+
"""
|
|
101
|
+
Get OAuth credentials. Tries in order:
|
|
102
|
+
1. Saved token from previous run (local .gdoc-token.json, then global)
|
|
103
|
+
2. Application Default Credentials (from gcloud)
|
|
104
|
+
3. Manual OAuth flow using credentials.json (local, then global)
|
|
105
|
+
"""
|
|
106
|
+
from google.auth.transport.requests import Request
|
|
107
|
+
from google.oauth2.credentials import Credentials
|
|
108
|
+
|
|
109
|
+
creds = None
|
|
110
|
+
token_file = get_token_file()
|
|
111
|
+
credentials_file = get_credentials_file()
|
|
112
|
+
|
|
113
|
+
# Option 1: Load existing token if available
|
|
114
|
+
if token_file.exists():
|
|
115
|
+
try:
|
|
116
|
+
creds = Credentials.from_authorized_user_file(str(token_file), SCOPES)
|
|
117
|
+
if creds and creds.valid:
|
|
118
|
+
console.print(f"[dim]Using token: {token_file}[/dim]")
|
|
119
|
+
return creds
|
|
120
|
+
if creds and creds.expired and creds.refresh_token:
|
|
121
|
+
creds.refresh(Request())
|
|
122
|
+
# Save refreshed token
|
|
123
|
+
with open(token_file, "w") as f:
|
|
124
|
+
f.write(creds.to_json())
|
|
125
|
+
console.print(f"[dim]Using token: {token_file}[/dim]")
|
|
126
|
+
return creds
|
|
127
|
+
except Exception:
|
|
128
|
+
pass # Fall through to other methods
|
|
129
|
+
|
|
130
|
+
# Option 2: Try Application Default Credentials (gcloud auth)
|
|
131
|
+
try:
|
|
132
|
+
import google.auth
|
|
133
|
+
|
|
134
|
+
creds, _ = google.auth.default(scopes=SCOPES)
|
|
135
|
+
if creds and creds.valid:
|
|
136
|
+
console.print("[dim]Using Application Default Credentials[/dim]")
|
|
137
|
+
return creds
|
|
138
|
+
except Exception:
|
|
139
|
+
pass # Fall through to OAuth flow
|
|
140
|
+
|
|
141
|
+
# Option 3: Manual OAuth flow
|
|
142
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
143
|
+
|
|
144
|
+
if not credentials_file.exists():
|
|
145
|
+
console.print(
|
|
146
|
+
Panel(
|
|
147
|
+
"[red]OAuth credentials not found![/red]\n\n"
|
|
148
|
+
"[bold]Option A - Project-specific (recommended):[/bold]\n"
|
|
149
|
+
f"Save OAuth client JSON as: [cyan].gdoc-credentials.json[/cyan]\n"
|
|
150
|
+
"(in current directory)\n\n"
|
|
151
|
+
"[bold]Option B - Global:[/bold]\n"
|
|
152
|
+
f"Save as: [cyan]{GLOBAL_CREDENTIALS_FILE}[/cyan]\n\n"
|
|
153
|
+
"[bold]To get the OAuth client JSON:[/bold]\n"
|
|
154
|
+
"1. Go to console.cloud.google.com\n"
|
|
155
|
+
"2. APIs & Services → Credentials\n"
|
|
156
|
+
"3. Create Credentials → OAuth client ID → Desktop app\n"
|
|
157
|
+
"4. Download JSON",
|
|
158
|
+
title="Setup Required",
|
|
159
|
+
border_style="yellow",
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
console.print(f"[dim]Using credentials: {credentials_file}[/dim]")
|
|
165
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
166
|
+
str(credentials_file), SCOPES
|
|
167
|
+
)
|
|
168
|
+
console.print("[cyan]Opening browser for Google authentication...[/cyan]")
|
|
169
|
+
creds = flow.run_local_server(port=0)
|
|
170
|
+
|
|
171
|
+
# Save token locally (in current directory)
|
|
172
|
+
with open(LOCAL_TOKEN_FILE, "w") as token:
|
|
173
|
+
token.write(creds.to_json())
|
|
174
|
+
console.print(f"[green]Token saved to {LOCAL_TOKEN_FILE}[/green]")
|
|
175
|
+
|
|
176
|
+
return creds
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def get_drive_service():
|
|
180
|
+
"""Get authenticated Google Drive service."""
|
|
181
|
+
from googleapiclient.discovery import build
|
|
182
|
+
|
|
183
|
+
creds = get_credentials()
|
|
184
|
+
if not creds:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
return build("drive", "v3", credentials=creds)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def find_folder_id(service, folder_path: str, create_if_missing: bool = True) -> Optional[str]:
|
|
191
|
+
"""Find folder by path (following shortcuts), optionally create if missing."""
|
|
192
|
+
if not folder_path:
|
|
193
|
+
return None # Root folder
|
|
194
|
+
|
|
195
|
+
parts = folder_path.strip("/").split("/")
|
|
196
|
+
parent_id = "root"
|
|
197
|
+
|
|
198
|
+
for part in parts:
|
|
199
|
+
# Search for folder OR shortcut with this name under parent
|
|
200
|
+
query = (
|
|
201
|
+
f"name = '{part}' and "
|
|
202
|
+
f"'{parent_id}' in parents and "
|
|
203
|
+
f"(mimeType = 'application/vnd.google-apps.folder' or "
|
|
204
|
+
f"mimeType = 'application/vnd.google-apps.shortcut') and "
|
|
205
|
+
f"trashed = false"
|
|
206
|
+
)
|
|
207
|
+
console.print(f"[dim]Looking for '{part}' in parent={parent_id}[/dim]")
|
|
208
|
+
results = (
|
|
209
|
+
service.files()
|
|
210
|
+
.list(q=query, fields="files(id, name, mimeType, shortcutDetails)", pageSize=10)
|
|
211
|
+
.execute()
|
|
212
|
+
)
|
|
213
|
+
files = results.get("files", [])
|
|
214
|
+
console.print(f"[dim]Found {len(files)} matches: {[(f['name'], f['mimeType']) for f in files]}[/dim]")
|
|
215
|
+
|
|
216
|
+
if files:
|
|
217
|
+
file = files[0]
|
|
218
|
+
if file.get("mimeType") == "application/vnd.google-apps.shortcut":
|
|
219
|
+
# Follow the shortcut to get target folder ID
|
|
220
|
+
shortcut_details = file.get("shortcutDetails", {})
|
|
221
|
+
target_id = shortcut_details.get("targetId")
|
|
222
|
+
if target_id:
|
|
223
|
+
console.print(f"[dim]Following shortcut: {part}[/dim]")
|
|
224
|
+
parent_id = target_id
|
|
225
|
+
else:
|
|
226
|
+
console.print(f"[yellow]Warning: Shortcut {part} has no target[/yellow]")
|
|
227
|
+
return None
|
|
228
|
+
else:
|
|
229
|
+
parent_id = file["id"]
|
|
230
|
+
else:
|
|
231
|
+
if not create_if_missing:
|
|
232
|
+
return None
|
|
233
|
+
# Folder doesn't exist - create it
|
|
234
|
+
file_metadata = {
|
|
235
|
+
"name": part,
|
|
236
|
+
"mimeType": "application/vnd.google-apps.folder",
|
|
237
|
+
"parents": [parent_id],
|
|
238
|
+
}
|
|
239
|
+
folder = (
|
|
240
|
+
service.files().create(body=file_metadata, fields="id").execute()
|
|
241
|
+
)
|
|
242
|
+
parent_id = folder["id"]
|
|
243
|
+
console.print(f"[dim]Created folder: {part}[/dim]")
|
|
244
|
+
|
|
245
|
+
return parent_id
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def check_file_exists(service, folder_id: Optional[str], filename: str) -> bool:
|
|
249
|
+
"""Check if a file with this name exists in the folder."""
|
|
250
|
+
parent = folder_id if folder_id else "root"
|
|
251
|
+
query = (
|
|
252
|
+
f"name = '{filename}' and "
|
|
253
|
+
f"'{parent}' in parents and "
|
|
254
|
+
f"mimeType = 'application/vnd.google-apps.document' and "
|
|
255
|
+
f"trashed = false"
|
|
256
|
+
)
|
|
257
|
+
console.print(f"[dim]Checking for existing file: {filename}[/dim]")
|
|
258
|
+
results = (
|
|
259
|
+
service.files()
|
|
260
|
+
.list(q=query, fields="files(id, name)", pageSize=10)
|
|
261
|
+
.execute()
|
|
262
|
+
)
|
|
263
|
+
files = results.get("files", [])
|
|
264
|
+
if files:
|
|
265
|
+
console.print(f"[dim]Found {len(files)} existing file(s): {[f['name'] for f in files]}[/dim]")
|
|
266
|
+
return len(files) > 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def list_existing_versions(
|
|
270
|
+
service, folder_id: Optional[str], base_name: str
|
|
271
|
+
) -> list[str]:
|
|
272
|
+
"""List existing files that match the base name pattern."""
|
|
273
|
+
parent = folder_id if folder_id else "root"
|
|
274
|
+
query = (
|
|
275
|
+
f"name contains '{base_name}' and "
|
|
276
|
+
f"'{parent}' in parents and "
|
|
277
|
+
f"mimeType = 'application/vnd.google-apps.document' and "
|
|
278
|
+
f"trashed = false"
|
|
279
|
+
)
|
|
280
|
+
results = (
|
|
281
|
+
service.files()
|
|
282
|
+
.list(q=query, fields="files(id, name)", pageSize=100)
|
|
283
|
+
.execute()
|
|
284
|
+
)
|
|
285
|
+
return [f["name"] for f in results.get("files", [])]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_next_version_name(
|
|
289
|
+
service, folder_id: Optional[str], base_name: str
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Get the next available version name (e.g., report-1, report-2)."""
|
|
292
|
+
existing = list_existing_versions(service, folder_id, base_name)
|
|
293
|
+
|
|
294
|
+
if not existing:
|
|
295
|
+
return f"{base_name}-1"
|
|
296
|
+
|
|
297
|
+
# Find the highest version number
|
|
298
|
+
max_version = 0
|
|
299
|
+
for f in existing:
|
|
300
|
+
match = re.match(rf"^{re.escape(base_name)}-(\d+)$", f)
|
|
301
|
+
if match:
|
|
302
|
+
version = int(match.group(1))
|
|
303
|
+
max_version = max(max_version, version)
|
|
304
|
+
|
|
305
|
+
return f"{base_name}-{max_version + 1}"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def delete_file(service, folder_id: Optional[str], filename: str) -> bool:
|
|
309
|
+
"""Delete a file by name (for overwrite)."""
|
|
310
|
+
parent = folder_id if folder_id else "root"
|
|
311
|
+
query = (
|
|
312
|
+
f"name = '{filename}' and "
|
|
313
|
+
f"'{parent}' in parents and "
|
|
314
|
+
f"mimeType = 'application/vnd.google-apps.document' and "
|
|
315
|
+
f"trashed = false"
|
|
316
|
+
)
|
|
317
|
+
results = (
|
|
318
|
+
service.files()
|
|
319
|
+
.list(q=query, fields="files(id)", pageSize=1)
|
|
320
|
+
.execute()
|
|
321
|
+
)
|
|
322
|
+
files = results.get("files", [])
|
|
323
|
+
if files:
|
|
324
|
+
service.files().delete(fileId=files[0]["id"]).execute()
|
|
325
|
+
return True
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def prompt_for_conflict(existing_name: str, versioned_name: str) -> Optional[str]:
|
|
330
|
+
"""
|
|
331
|
+
Prompt user for action when file already exists.
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
- "version": use versioned name
|
|
335
|
+
- "overwrite": overwrite existing
|
|
336
|
+
- None: user cancelled
|
|
337
|
+
"""
|
|
338
|
+
console.print()
|
|
339
|
+
console.print(
|
|
340
|
+
Panel(
|
|
341
|
+
f"[yellow]File already exists:[/yellow] [bold]{existing_name}[/bold]",
|
|
342
|
+
title="Conflict Detected",
|
|
343
|
+
border_style="yellow",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
console.print()
|
|
347
|
+
|
|
348
|
+
# Show options
|
|
349
|
+
options_text = Text()
|
|
350
|
+
options_text.append(" [Enter] ", style="cyan bold")
|
|
351
|
+
options_text.append("Add version suffix → ", style="dim")
|
|
352
|
+
options_text.append(f"{versioned_name}\n", style="green")
|
|
353
|
+
options_text.append(" [YES] ", style="red bold")
|
|
354
|
+
options_text.append("Overwrite existing file", style="dim")
|
|
355
|
+
|
|
356
|
+
console.print(options_text)
|
|
357
|
+
console.print()
|
|
358
|
+
|
|
359
|
+
choice = Prompt.ask(
|
|
360
|
+
"Your choice",
|
|
361
|
+
default="",
|
|
362
|
+
show_default=False,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if choice == "":
|
|
366
|
+
return "version"
|
|
367
|
+
elif choice.upper() == "YES":
|
|
368
|
+
return "overwrite"
|
|
369
|
+
else:
|
|
370
|
+
console.print("[dim]Invalid choice. Cancelling.[/dim]")
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def upload_markdown(
|
|
375
|
+
service,
|
|
376
|
+
md_path: Path,
|
|
377
|
+
folder_id: Optional[str],
|
|
378
|
+
filename: str,
|
|
379
|
+
) -> Optional[str]:
|
|
380
|
+
"""
|
|
381
|
+
Upload markdown file to Google Drive with native conversion to Google Docs.
|
|
382
|
+
|
|
383
|
+
Returns the file ID on success, None on failure.
|
|
384
|
+
"""
|
|
385
|
+
from googleapiclient.http import MediaFileUpload
|
|
386
|
+
|
|
387
|
+
file_metadata = {
|
|
388
|
+
"name": filename,
|
|
389
|
+
"mimeType": "application/vnd.google-apps.document",
|
|
390
|
+
}
|
|
391
|
+
if folder_id:
|
|
392
|
+
file_metadata["parents"] = [folder_id]
|
|
393
|
+
|
|
394
|
+
# Upload with text/markdown MIME type - Google converts natively
|
|
395
|
+
media = MediaFileUpload(
|
|
396
|
+
str(md_path),
|
|
397
|
+
mimetype="text/markdown",
|
|
398
|
+
resumable=True,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
with console.status("[cyan]Uploading to Google Drive...[/cyan]"):
|
|
403
|
+
file = (
|
|
404
|
+
service.files()
|
|
405
|
+
.create(body=file_metadata, media_body=media, fields="id,webViewLink")
|
|
406
|
+
.execute()
|
|
407
|
+
)
|
|
408
|
+
return file.get("webViewLink")
|
|
409
|
+
except Exception as e:
|
|
410
|
+
console.print(f"[red]Upload error:[/red] {e}")
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def main() -> None:
|
|
415
|
+
parser = argparse.ArgumentParser(
|
|
416
|
+
description="Upload Markdown files to Google Drive as native Google Docs.",
|
|
417
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
418
|
+
epilog="""
|
|
419
|
+
Examples:
|
|
420
|
+
md2gdoc report.md # Upload to root of Drive
|
|
421
|
+
md2gdoc report.md --folder "OTA/Reports" # Upload to specific folder
|
|
422
|
+
md2gdoc report.md --name "Q4 Summary" # Upload with custom name
|
|
423
|
+
md2gdoc report.md --on-existing version # Auto-version if exists (no prompt)
|
|
424
|
+
|
|
425
|
+
Credentials (in order of precedence):
|
|
426
|
+
1. .gdoc-token.json in current directory (project-specific)
|
|
427
|
+
2. ~/.config/md2gdoc/token.json (global)
|
|
428
|
+
3. Application Default Credentials (gcloud)
|
|
429
|
+
|
|
430
|
+
First run opens browser for OAuth (one-time per project).
|
|
431
|
+
""",
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"markdown_file",
|
|
436
|
+
type=Path,
|
|
437
|
+
help="Path to the Markdown file to upload",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
parser.add_argument(
|
|
441
|
+
"--folder",
|
|
442
|
+
"-f",
|
|
443
|
+
type=str,
|
|
444
|
+
default="",
|
|
445
|
+
help="Target folder in Google Drive (e.g., 'OTA/Reports')",
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
parser.add_argument(
|
|
449
|
+
"--name",
|
|
450
|
+
"-n",
|
|
451
|
+
type=str,
|
|
452
|
+
default="",
|
|
453
|
+
help="Name for the Google Doc (default: markdown filename without extension)",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
parser.add_argument(
|
|
457
|
+
"--on-existing",
|
|
458
|
+
type=str,
|
|
459
|
+
choices=["ask", "version", "overwrite"],
|
|
460
|
+
default="ask",
|
|
461
|
+
help="Action when file exists: ask (prompt), version (auto-increment), "
|
|
462
|
+
"overwrite (replace). Default: ask",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
args = parser.parse_args()
|
|
466
|
+
|
|
467
|
+
# Check dependencies
|
|
468
|
+
if not check_dependencies():
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
# Validate input file
|
|
472
|
+
if not args.markdown_file.exists():
|
|
473
|
+
console.print(f"[red]Error:[/red] File not found: {args.markdown_file}")
|
|
474
|
+
sys.exit(1)
|
|
475
|
+
|
|
476
|
+
if args.markdown_file.suffix.lower() not in [".md", ".markdown"]:
|
|
477
|
+
console.print(
|
|
478
|
+
"[yellow]Warning:[/yellow] "
|
|
479
|
+
"File doesn't have .md extension, proceeding anyway"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
# Get Drive service
|
|
483
|
+
service = get_drive_service()
|
|
484
|
+
if not service:
|
|
485
|
+
sys.exit(1)
|
|
486
|
+
|
|
487
|
+
# Find or create target folder
|
|
488
|
+
folder_id = None
|
|
489
|
+
if args.folder:
|
|
490
|
+
console.print(f"[dim]Finding folder: {args.folder}[/dim]")
|
|
491
|
+
folder_id = find_folder_id(service, args.folder)
|
|
492
|
+
|
|
493
|
+
# Determine the target filename
|
|
494
|
+
if args.name:
|
|
495
|
+
target_name = args.name
|
|
496
|
+
else:
|
|
497
|
+
target_name = args.markdown_file.stem
|
|
498
|
+
|
|
499
|
+
# Check if file already exists
|
|
500
|
+
file_exists = check_file_exists(service, folder_id, target_name)
|
|
501
|
+
|
|
502
|
+
final_name = target_name
|
|
503
|
+
if file_exists:
|
|
504
|
+
versioned_name = get_next_version_name(service, folder_id, target_name)
|
|
505
|
+
|
|
506
|
+
# Determine action based on --on-existing flag
|
|
507
|
+
on_existing = getattr(args, "on_existing", "ask")
|
|
508
|
+
if on_existing == "ask":
|
|
509
|
+
action = prompt_for_conflict(target_name, versioned_name)
|
|
510
|
+
if action is None:
|
|
511
|
+
console.print("[dim]Upload cancelled.[/dim]")
|
|
512
|
+
sys.exit(0)
|
|
513
|
+
else:
|
|
514
|
+
action = on_existing
|
|
515
|
+
|
|
516
|
+
if action == "version":
|
|
517
|
+
final_name = versioned_name
|
|
518
|
+
console.print(f"[dim]File exists, using versioned name: {final_name}[/dim]")
|
|
519
|
+
elif action == "overwrite":
|
|
520
|
+
console.print(f"[dim]Deleting existing file: {target_name}[/dim]")
|
|
521
|
+
delete_file(service, folder_id, target_name)
|
|
522
|
+
|
|
523
|
+
# Upload with native markdown conversion
|
|
524
|
+
console.print(
|
|
525
|
+
f"[cyan]Uploading[/cyan] {args.markdown_file.name} → Google Docs..."
|
|
526
|
+
)
|
|
527
|
+
web_link = upload_markdown(service, args.markdown_file, folder_id, final_name)
|
|
528
|
+
|
|
529
|
+
if not web_link:
|
|
530
|
+
sys.exit(1)
|
|
531
|
+
|
|
532
|
+
# Success message
|
|
533
|
+
location = f"{args.folder}/{final_name}" if args.folder else final_name
|
|
534
|
+
|
|
535
|
+
console.print()
|
|
536
|
+
console.print(
|
|
537
|
+
Panel(
|
|
538
|
+
f"[green]Successfully uploaded![/green]\n\n"
|
|
539
|
+
f"[dim]Name:[/dim] {final_name}\n"
|
|
540
|
+
f"[dim]Location:[/dim] {location}\n"
|
|
541
|
+
f"[dim]Link:[/dim] {web_link}",
|
|
542
|
+
title="Done",
|
|
543
|
+
border_style="green",
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
if __name__ == "__main__":
|
|
549
|
+
main()
|