1use crate::config::Config;
4use crate::csv_handler::CellRange;
5use crate::traits::{DataReader, DataWriteOptions, DataWriter, FileHandler};
6use anyhow::{anyhow, Context, Result};
7use tokio::runtime::Runtime;
8
9pub struct GoogleSheetsHandler {
11 config: Config,
12 rt: Runtime,
13}
14
15impl GoogleSheetsHandler {
16 pub fn new() -> Self {
18 Self {
19 config: Config::default(),
20 rt: Runtime::new().expect("Failed to create tokio runtime"),
21 }
22 }
23
24 pub fn with_config(config: Config) -> Self {
26 Self {
27 config,
28 rt: Runtime::new().expect("Failed to create tokio runtime"),
29 }
30 }
31
32 pub fn parse_spreadsheet_id(&self, path: &str) -> Result<String> {
34 if path.starts_with("gsheet://") {
36 let id = path
37 .strip_prefix("gsheet://")
38 .ok_or_else(|| anyhow!("Invalid gsheet URL"))?;
39 return Ok(id.split('/').next().unwrap_or(id).to_string());
40 }
41
42 if path.starts_with("https://docs.google.com/spreadsheets/") {
44 if let Some(start) = path.find("/d/") {
45 let start = start + 3;
46 if let Some(end) = path[start..].find('/') {
47 return Ok(path[start..start + end].to_string());
48 } else {
49 return Ok(path[start..].to_string());
50 }
51 }
52 }
53
54 if path.len() >= 44
56 && path
57 .chars()
58 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
59 {
60 return Ok(path.to_string());
61 }
62
63 Err(anyhow!("Invalid Google Sheets URL or ID: {}", path))
64 }
65
66 pub fn parse_sheet_name(&self, path: &str) -> Option<String> {
68 if path.starts_with("gsheet://") {
70 let parts: Vec<&str> = path[9..].split('/').collect();
71 if parts.len() > 1 {
72 return Some(parts[1].to_string());
73 }
74 }
75
76 None
77 }
78
79 pub fn a1_to_row_col(&self, a1: &str) -> Result<(usize, usize)> {
81 let mut col_start = 0;
82 let mut row_start = 0;
83
84 for (i, c) in a1.chars().enumerate() {
86 if c.is_alphabetic() {
87 col_start += 1;
88 } else if c.is_numeric() {
89 row_start = i;
90 break;
91 }
92 }
93
94 if col_start == 0 || row_start == 0 {
95 return Err(anyhow!("Invalid A1 notation: {}", a1));
96 }
97
98 let col_str = &a1[..col_start];
100 let mut col = 0;
101 for c in col_str.chars() {
102 col = col * 26 + (c.to_ascii_uppercase() as u8 - b'A' + 1) as usize;
103 }
104 col -= 1; let row = a1[row_start..].parse::<usize>()? - 1; Ok((row, col))
110 }
111
112 pub fn row_col_to_a1(&self, row: usize, col: usize) -> String {
114 let mut col = col + 1;
115 let mut col_str = String::new();
116
117 while col > 0 {
118 col -= 1;
119 col_str.insert(0, ((col % 26) as u8 + b'A') as char);
120 col /= 26;
121 }
122
123 format!("{}{}", col_str, row + 1)
124 }
125
126 pub fn cell_range_to_a1(&self, range: &CellRange, sheet_name: Option<&str>) -> String {
128 let start = self.row_col_to_a1(range.start_row, range.start_col);
129 let end = self.row_col_to_a1(range.end_row, range.end_col);
130
131 let range_str = if start == end {
132 start
133 } else {
134 format!("{}:{}", start, end)
135 };
136
137 if let Some(name) = sheet_name {
138 format!("'{}'!{}", name, range_str)
139 } else {
140 range_str
141 }
142 }
143
144 pub fn list_sheet_titles(&self, spreadsheet_ref: &str) -> Result<Vec<String>> {
149 let api_key = self.config.google_sheets.api_key.as_deref().ok_or_else(|| {
150 anyhow!(
151 "google_sheets.api_key is not set in config; required to list sheet titles via the API"
152 )
153 })?;
154
155 let id = self.parse_spreadsheet_id(spreadsheet_ref)?;
156 let url = format!(
157 "https://sheets.googleapis.com/v4/spreadsheets/{id}?fields=sheets.properties.title&key={api_key}"
158 );
159
160 let resp = ureq::get(&url)
161 .call()
162 .map_err(|e| anyhow!("Google Sheets request failed: {}", e))?;
163
164 let status = resp.status();
165 let body = resp
166 .into_string()
167 .map_err(|e| anyhow!("Failed to read Sheets API response: {}", e))?;
168
169 if status != 200 {
170 anyhow::bail!("Google Sheets API returned HTTP {}: {}", status, body);
171 }
172
173 let v: serde_json::Value =
174 serde_json::from_str(&body).with_context(|| "Invalid JSON from Sheets API")?;
175
176 let sheets = v
177 .get("sheets")
178 .and_then(|s| s.as_array())
179 .ok_or_else(|| anyhow!("Sheets API response missing 'sheets' array"))?;
180
181 let mut titles = Vec::new();
182 for sheet in sheets {
183 if let Some(title) = sheet
184 .get("properties")
185 .and_then(|p| p.get("title"))
186 .and_then(|t| t.as_str())
187 {
188 titles.push(title.to_string());
189 }
190 }
191
192 Ok(titles)
193 }
194}
195
196impl Default for GoogleSheetsHandler {
197 fn default() -> Self {
198 Self::new()
199 }
200}
201
202impl DataReader for GoogleSheetsHandler {
203 fn read(&self, path: &str) -> Result<Vec<Vec<String>>> {
204 let _spreadsheet_id = self.parse_spreadsheet_id(path)?;
207 let _sheet_name = self.parse_sheet_name(path);
208
209 Ok(vec![
211 vec!["Column1".to_string(), "Column2".to_string()],
212 vec!["Value1".to_string(), "Value2".to_string()],
213 ])
214 }
215
216 fn read_with_headers(&self, path: &str) -> Result<Vec<Vec<String>>> {
217 self.read(path)
218 }
219
220 fn read_range(&self, path: &str, range: &CellRange) -> Result<Vec<Vec<String>>> {
221 let _spreadsheet_id = self.parse_spreadsheet_id(path)?;
222 let _sheet_name = self.parse_sheet_name(path);
223 let _range_str = self.cell_range_to_a1(range, _sheet_name.as_deref());
224
225 Ok(vec![vec![
227 "RangeValue1".to_string(),
228 "RangeValue2".to_string(),
229 ]])
230 }
231
232 fn read_as_json(&self, path: &str) -> Result<String> {
233 let data = self.read(path)?;
234 serde_json::to_string_pretty(&data).map_err(Into::into)
235 }
236
237 fn supports_format(&self, path: &str) -> bool {
238 path.starts_with("gsheet://")
239 || path.starts_with("https://docs.google.com/spreadsheets/")
240 || (path.len() >= 44
241 && path
242 .chars()
243 .all(|c| c.is_alphanumeric() || c == '-' || c == '_'))
244 }
245}
246
247impl DataWriter for GoogleSheetsHandler {
248 fn write(&self, path: &str, data: &[Vec<String>], options: DataWriteOptions) -> Result<()> {
249 let spreadsheet_id = self.parse_spreadsheet_id(path)?;
250 let sheet_name = options.sheet_name.or_else(|| self.parse_sheet_name(path));
251
252 println!("Writing to Google Sheets: {}", spreadsheet_id);
254 if let Some(name) = &sheet_name {
255 println!("Sheet: {}", name);
256 }
257 println!("Data rows: {}", data.len());
258
259 Ok(())
260 }
261
262 fn write_range(
263 &self,
264 path: &str,
265 data: &[Vec<String>],
266 start_row: usize,
267 start_col: usize,
268 ) -> Result<()> {
269 let spreadsheet_id = self.parse_spreadsheet_id(path)?;
270 let sheet_name = self.parse_sheet_name(path);
271 let start_a1 = self.row_col_to_a1(start_row, start_col);
272
273 println!("Writing range to Google Sheets: {}", spreadsheet_id);
275 if let Some(name) = &sheet_name {
276 println!("Sheet: {}", name);
277 }
278 println!("Start: {}", start_a1);
279 println!("Data rows: {}", data.len());
280
281 Ok(())
282 }
283
284 fn append(&self, path: &str, data: &[Vec<String>]) -> Result<()> {
285 let spreadsheet_id = self.parse_spreadsheet_id(path)?;
286 let sheet_name = self.parse_sheet_name(path);
287
288 println!("Appending to Google Sheets: {}", spreadsheet_id);
290 if let Some(name) = &sheet_name {
291 println!("Sheet: {}", name);
292 }
293 println!("Data rows: {}", data.len());
294
295 Ok(())
296 }
297
298 fn supports_format(&self, path: &str) -> bool {
299 path.starts_with("gsheet://")
300 || path.starts_with("https://docs.google.com/spreadsheets/")
301 || (path.len() >= 44
302 && path
303 .chars()
304 .all(|c| c.is_alphanumeric() || c == '-' || c == '_'))
305 }
306}
307
308impl FileHandler for GoogleSheetsHandler {
309 fn format_name(&self) -> &'static str {
310 "gsheet"
311 }
312
313 fn supported_extensions(&self) -> &'static [&'static str] {
314 &["gsheet"]
315 }
316}
317
318impl Clone for GoogleSheetsHandler {
319 fn clone(&self) -> Self {
320 Self {
321 config: self.config.clone(),
322 rt: Runtime::new().expect("Failed to create tokio runtime"),
323 }
324 }
325}