Skip to main content

codesynapse_core/
google_workspace.rs

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    // Check ?id= query param
30    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    // Check /document/d/<id>, /spreadsheets/d/<id>, /presentation/d/<id>, /file/d/<id>
41    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            &params.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        // Verify param JSON structure: fileId + mimeType only (no resourceKey)
441        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}