1use crate::error::CodeSynapseError;
2use sha2::{Digest, Sha256};
3use std::collections::HashMap;
4use std::env;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8pub const GOOGLE_WORKSPACE_EXTENSIONS: &[&str] = &[".gdoc", ".gsheet", ".gslides"];
9
10pub fn google_workspace_enabled() -> bool {
11 let raw = env::var("CODESYNAPSE_GOOGLE_WORKSPACE").unwrap_or_default();
12 matches!(
13 raw.trim().to_lowercase().as_str(),
14 "1" | "true" | "yes" | "on"
15 )
16}
17
18fn safe_yaml_str(value: &str) -> String {
19 value
20 .replace('\\', "\\\\")
21 .replace('"', "\\\"")
22 .replace(['\n', '\r'], " ")
23}
24
25fn extract_file_id_from_url(url: &str) -> Option<String> {
26 if url.is_empty() {
27 return None;
28 }
29 if let Some(query_start) = url.find('?') {
31 let query = &url[query_start + 1..];
32 for part in query.split('&') {
33 if let Some(val) = part.strip_prefix("id=") {
34 if !val.is_empty() {
35 return Some(val.to_string());
36 }
37 }
38 }
39 }
40 let patterns = [
42 "/document/d/",
43 "/spreadsheets/d/",
44 "/presentation/d/",
45 "/file/d/",
46 ];
47 for pat in &patterns {
48 if let Some(start) = url.find(pat) {
49 let rest = &url[start + pat.len()..];
50 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
51 let id = &rest[..end];
52 if !id.is_empty() {
53 return Some(id.to_string());
54 }
55 }
56 }
57 None
58}
59
60fn extract_resource_key(url: &str, data: &serde_json::Value) -> Option<String> {
61 for key in &["resource_key", "resourceKey"] {
62 if let Some(v) = data.get(key).and_then(|v| v.as_str()) {
63 if !v.is_empty() {
64 return Some(v.to_string());
65 }
66 }
67 }
68 if url.is_empty() {
69 return None;
70 }
71 if let Some(query_start) = url.find('?') {
72 let query = &url[query_start + 1..];
73 for part in query.split('&') {
74 if let Some(val) = part.strip_prefix("resourcekey=") {
75 if !val.is_empty() {
76 return Some(val.to_string());
77 }
78 }
79 }
80 }
81 None
82}
83
84pub fn read_google_shortcut(
85 path: &Path,
86) -> Result<HashMap<String, Option<String>>, CodeSynapseError> {
87 let text = std::fs::read_to_string(path).map_err(CodeSynapseError::Io)?;
88 let data: serde_json::Value =
89 serde_json::from_str(&text).map_err(CodeSynapseError::Serialization)?;
90
91 let url = data
92 .get("url")
93 .and_then(|v| v.as_str())
94 .unwrap_or("")
95 .to_string();
96
97 let file_id = data
98 .get("doc_id")
99 .and_then(|v| v.as_str())
100 .filter(|s| !s.is_empty())
101 .or_else(|| {
102 data.get("file_id")
103 .and_then(|v| v.as_str())
104 .filter(|s| !s.is_empty())
105 })
106 .or_else(|| {
107 data.get("fileId")
108 .and_then(|v| v.as_str())
109 .filter(|s| !s.is_empty())
110 })
111 .or_else(|| {
112 data.get("id")
113 .and_then(|v| v.as_str())
114 .filter(|s| !s.is_empty())
115 })
116 .map(|s| s.to_string())
117 .or_else(|| extract_file_id_from_url(&url))
118 .or_else(|| {
119 let resource_id = data
120 .get("resource_id")
121 .and_then(|v| v.as_str())
122 .unwrap_or("");
123 if resource_id.contains(':') {
124 resource_id
125 .split_once(':')
126 .map(|x| x.1)
127 .map(|s| s.to_string())
128 } else {
129 None
130 }
131 });
132
133 let file_id = file_id.ok_or_else(|| {
134 CodeSynapseError::Validation(format!(
135 "Google Workspace shortcut {} does not include a Drive file ID",
136 path.display()
137 ))
138 })?;
139
140 let resource_key = extract_resource_key(&url, &data);
141 let account = data
142 .get("email")
143 .and_then(|v| v.as_str())
144 .map(|s| s.to_string());
145
146 let mut result = HashMap::new();
147 result.insert("file_id".to_string(), Some(file_id));
148 result.insert(
149 "url".to_string(),
150 if url.is_empty() { None } else { Some(url) },
151 );
152 result.insert("resource_key".to_string(), resource_key);
153 result.insert("account".to_string(), account);
154 Ok(result)
155}
156
157fn find_exe(name: &str) -> Option<PathBuf> {
158 let path_var = env::var_os("PATH")?;
159 for dir in env::split_paths(&path_var) {
160 let candidate = dir.join(name);
161 if candidate.is_file() {
162 return Some(candidate);
163 }
164 }
165 None
166}
167
168pub fn run_gws_export(
169 file_id: &str,
170 mime_type: &str,
171 output: &Path,
172 _resource_key: Option<&str>,
173) -> Result<(), CodeSynapseError> {
174 let exe = find_exe("gws").ok_or_else(|| {
175 CodeSynapseError::Validation(
176 "gws is required for Google Workspace export. Install it from \
177 https://github.com/googleworkspace/cli and run `gws auth login -s drive`."
178 .to_string(),
179 )
180 })?;
181
182 let params = serde_json::json!({"fileId": file_id, "mimeType": mime_type});
183 let out_dir = output.parent().unwrap_or(Path::new("."));
184 std::fs::create_dir_all(out_dir).map_err(CodeSynapseError::Io)?;
185
186 let filename = output
187 .file_name()
188 .unwrap_or_default()
189 .to_string_lossy()
190 .to_string();
191 let timeout_secs: u64 = env::var("CODESYNAPSE_GOOGLE_WORKSPACE_TIMEOUT")
192 .ok()
193 .and_then(|v| v.parse().ok())
194 .unwrap_or(120);
195
196 let result = Command::new(&exe)
197 .args([
198 "drive",
199 "files",
200 "export",
201 "--params",
202 ¶ms.to_string(),
203 "-o",
204 &filename,
205 ])
206 .current_dir(out_dir)
207 .output()
208 .map_err(CodeSynapseError::Io)?;
209
210 let _ = timeout_secs;
211 if !result.status.success() {
212 let stderr = String::from_utf8_lossy(&result.stderr);
213 let stdout = String::from_utf8_lossy(&result.stdout);
214 let msg = if !stderr.is_empty() { stderr } else { stdout };
215 let msg = if msg.len() > 1200 { &msg[..1200] } else { &msg };
216 return Err(CodeSynapseError::Validation(format!(
217 "gws export failed for {file_id}: {msg}"
218 )));
219 }
220 Ok(())
221}
222
223fn sidecar_path(path: &Path, out_dir: &Path) -> PathBuf {
224 let mut hasher = Sha256::new();
225 hasher.update(path.to_string_lossy().as_bytes());
226 let hash = format!("{:x}", hasher.finalize());
227 let stem = path.file_stem().unwrap_or_default().to_string_lossy();
228 out_dir.join(format!("{}_{}.md", stem, &hash[..8]))
229}
230
231fn with_frontmatter(
232 path: &Path,
233 shortcut: &HashMap<String, Option<String>>,
234 body: &str,
235 exported_mime_type: &str,
236) -> String {
237 let source_url = shortcut.get("url").and_then(|v| v.as_deref()).unwrap_or("");
238 let account = shortcut
239 .get("account")
240 .and_then(|v| v.as_deref())
241 .unwrap_or("");
242 let file_id = shortcut
243 .get("file_id")
244 .and_then(|v| v.as_deref())
245 .unwrap_or("");
246
247 let account_line = if !account.is_empty() {
248 let mut hasher = Sha256::new();
249 hasher.update(account.as_bytes());
250 let hash = format!("{:x}", hasher.finalize());
251 format!("google_account_hash: \"{}\"\n", &hash[..12])
252 } else {
253 String::new()
254 };
255
256 format!(
257 "---\nsource_file: \"{}\"\nsource_type: \"google_workspace\"\ngoogle_file_id: \"{}\"\ngoogle_export_mime_type: \"{}\"\nsource_url: \"{}\"\n{}---\n\n<!-- converted from Google Workspace shortcut: {} -->\n\n{}\n",
258 safe_yaml_str(&path.to_string_lossy()),
259 safe_yaml_str(file_id),
260 safe_yaml_str(exported_mime_type),
261 safe_yaml_str(source_url),
262 account_line,
263 path.file_name().unwrap_or_default().to_string_lossy(),
264 body.trim(),
265 )
266}
267
268#[allow(clippy::type_complexity)]
269pub fn convert_google_workspace_file(
270 path: &Path,
271 out_dir: &Path,
272 export_fn: impl Fn(&str, &str, &Path, Option<&str>) -> Result<(), CodeSynapseError>,
273 xlsx_to_markdown: Option<&dyn Fn(&Path) -> Result<String, CodeSynapseError>>,
274) -> Result<Option<PathBuf>, CodeSynapseError> {
275 let ext = path
276 .extension()
277 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
278 .unwrap_or_default();
279
280 if !GOOGLE_WORKSPACE_EXTENSIONS.contains(&ext.as_str()) {
281 return Ok(None);
282 }
283
284 let shortcut = read_google_shortcut(path)?;
285 std::fs::create_dir_all(out_dir).map_err(CodeSynapseError::Io)?;
286 let out_path = sidecar_path(path, out_dir);
287 let file_id = shortcut
288 .get("file_id")
289 .and_then(|v| v.as_deref())
290 .unwrap_or("");
291 let resource_key = shortcut.get("resource_key").and_then(|v| v.as_deref());
292
293 match ext.as_str() {
294 ".gdoc" => {
295 let tmp = out_dir.join(format!("_tmp_{}.md", file_id));
296 export_fn(file_id, "text/markdown", &tmp, resource_key)?;
297 let body = std::fs::read_to_string(&tmp).unwrap_or_default();
298 std::fs::remove_file(&tmp).ok();
299 if body.trim().is_empty() {
300 return Ok(None);
301 }
302 let content = with_frontmatter(path, &shortcut, &body, "text/markdown");
303 std::fs::write(&out_path, content).map_err(CodeSynapseError::Io)?;
304 Ok(Some(out_path))
305 }
306 ".gslides" => {
307 let tmp = out_dir.join(format!("_tmp_{}.txt", file_id));
308 export_fn(file_id, "text/plain", &tmp, resource_key)?;
309 let body = std::fs::read_to_string(&tmp).unwrap_or_default();
310 std::fs::remove_file(&tmp).ok();
311 if body.trim().is_empty() {
312 return Ok(None);
313 }
314 let content = with_frontmatter(path, &shortcut, &body, "text/plain");
315 std::fs::write(&out_path, content).map_err(CodeSynapseError::Io)?;
316 Ok(Some(out_path))
317 }
318 ".gsheet" => {
319 let cb = xlsx_to_markdown.ok_or_else(|| {
320 CodeSynapseError::Validation(
321 "Google Sheets export requires xlsx_to_markdown callback".to_string(),
322 )
323 })?;
324 let tmp = out_dir.join(format!("_tmp_{}.xlsx", file_id));
325 export_fn(
326 file_id,
327 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
328 &tmp,
329 resource_key,
330 )?;
331 let body = cb(&tmp)?;
332 std::fs::remove_file(&tmp).ok();
333 if body.trim().is_empty() {
334 return Ok(None);
335 }
336 let content = with_frontmatter(
337 path,
338 &shortcut,
339 &body,
340 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
341 );
342 std::fs::write(&out_path, content).map_err(CodeSynapseError::Io)?;
343 Ok(Some(out_path))
344 }
345 _ => Ok(None),
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use std::fs;
353 use tempfile::TempDir;
354
355 #[test]
356 fn test_read_google_shortcut_doc_id() {
357 let tmp = TempDir::new().unwrap();
358 let path = tmp.path().join("Planning.gdoc");
359 fs::write(
360 &path,
361 r#"{"url":"https://docs.google.com/document/d/doc-123/edit","doc_id":"doc-123","email":"me@example.com"}"#,
362 ).unwrap();
363
364 let metadata = read_google_shortcut(&path).unwrap();
365 assert_eq!(metadata["file_id"].as_deref(), Some("doc-123"));
366 assert_eq!(metadata["account"].as_deref(), Some("me@example.com"));
367 }
368
369 #[test]
370 fn test_read_google_shortcut_extracts_id_from_url() {
371 let tmp = TempDir::new().unwrap();
372 let path = tmp.path().join("Budget.gsheet");
373 fs::write(
374 &path,
375 r#"{"url":"https://docs.google.com/spreadsheets/d/sheet-456/edit?resourcekey=key-1"}"#,
376 )
377 .unwrap();
378
379 let metadata = read_google_shortcut(&path).unwrap();
380 assert_eq!(metadata["file_id"].as_deref(), Some("sheet-456"));
381 assert_eq!(metadata["resource_key"].as_deref(), Some("key-1"));
382 }
383
384 #[test]
385 fn test_convert_gdoc_to_markdown_sidecar() {
386 let tmp = TempDir::new().unwrap();
387 let shortcut = tmp.path().join("Planning.gdoc");
388 fs::write(
389 &shortcut,
390 r#"{"url":"https://docs.google.com/document/d/doc-123/edit","doc_id":"doc-123"}"#,
391 )
392 .unwrap();
393
394 let out_dir = tmp.path().join("converted");
395
396 let fake_export = |file_id: &str, mime_type: &str, output: &Path, _rk: Option<&str>| {
397 assert_eq!(file_id, "doc-123");
398 assert_eq!(mime_type, "text/markdown");
399 fs::write(output, "# Planning\n\nExported doc text.").unwrap();
400 Ok(())
401 };
402
403 let out = convert_google_workspace_file(&shortcut, &out_dir, fake_export, None).unwrap();
404 assert!(out.is_some());
405 let content = fs::read_to_string(out.unwrap()).unwrap();
406 assert!(content.contains("source_type: \"google_workspace\""));
407 assert!(content.contains("# Planning"));
408 }
409
410 #[test]
411 fn test_convert_gsheet_uses_xlsx_markdown_callback() {
412 let tmp = TempDir::new().unwrap();
413 let shortcut = tmp.path().join("Budget.gsheet");
414 fs::write(&shortcut, r#"{"doc_id":"sheet-456"}"#).unwrap();
415
416 let out_dir = tmp.path().join("converted");
417
418 let fake_export = |file_id: &str, mime_type: &str, output: &Path, _rk: Option<&str>| {
419 assert_eq!(file_id, "sheet-456");
420 assert_eq!(
421 mime_type,
422 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
423 );
424 fs::write(output, b"xlsx").unwrap();
425 Ok(())
426 };
427
428 let xlsx_cb: &dyn Fn(&Path) -> Result<String, CodeSynapseError> =
429 &|_path| Ok("## Sheet: Main\n\n| A |\n| --- |\n| 1 |".to_string());
430
431 let out =
432 convert_google_workspace_file(&shortcut, &out_dir, fake_export, Some(xlsx_cb)).unwrap();
433 assert!(out.is_some());
434 let content = fs::read_to_string(out.unwrap()).unwrap();
435 assert!(content.contains("## Sheet: Main"));
436 }
437
438 #[test]
439 fn test_run_gws_export_params_structure() {
440 let params = serde_json::json!({"fileId": "doc-123", "mimeType": "text/markdown"});
442 let s = params.to_string();
443 assert!(s.contains("\"fileId\":\"doc-123\""));
444 assert!(s.contains("\"mimeType\":\"text/markdown\""));
445 assert!(!s.contains("resourceKey"));
446 }
447
448 #[test]
449 fn test_google_workspace_enabled_env() {
450 unsafe {
451 env::set_var("CODESYNAPSE_GOOGLE_WORKSPACE", "yes");
452 }
453 assert!(google_workspace_enabled());
454
455 unsafe {
456 env::set_var("CODESYNAPSE_GOOGLE_WORKSPACE", "0");
457 }
458 assert!(!google_workspace_enabled());
459
460 unsafe {
461 env::remove_var("CODESYNAPSE_GOOGLE_WORKSPACE");
462 }
463 }
464
465 #[test]
466 fn test_extract_file_id_patterns() {
467 assert_eq!(
468 extract_file_id_from_url("https://docs.google.com/document/d/doc-abc/edit"),
469 Some("doc-abc".to_string())
470 );
471 assert_eq!(
472 extract_file_id_from_url(
473 "https://docs.google.com/spreadsheets/d/sheet-456/edit?resourcekey=key-1"
474 ),
475 Some("sheet-456".to_string())
476 );
477 assert_eq!(extract_file_id_from_url(""), None);
478 }
479}