1use anyhow::{Context, Result};
2use calamine::{open_workbook_auto, Data, Reader, Xls, Xlsx};
3use chrono::Local;
4use rust_xlsxwriter::{Format, Workbook as XlsxWorkbook};
5use std::collections::HashSet;
6use std::fs::File;
7use std::io::BufReader;
8use std::path::Path;
9
10use crate::excel::{Cell, CellType, DataTypeInfo, Sheet};
11
12pub enum CalamineWorkbook {
13 Xlsx(Box<Xlsx<BufReader<File>>>),
14 Xls(Xls<BufReader<File>>),
15 None,
16}
17
18impl Clone for CalamineWorkbook {
19 fn clone(&self) -> Self {
20 CalamineWorkbook::None
21 }
22}
23
24pub struct Workbook {
25 sheets: Vec<Sheet>,
26 current_sheet_index: usize,
27 file_path: String,
28 is_modified: bool,
29 calamine_workbook: CalamineWorkbook,
30 lazy_loading: bool,
31 loaded_sheets: HashSet<usize>, }
33
34impl Clone for Workbook {
35 fn clone(&self) -> Self {
36 Workbook {
37 sheets: self.sheets.clone(),
38 current_sheet_index: self.current_sheet_index,
39 file_path: self.file_path.clone(),
40 is_modified: self.is_modified,
41 calamine_workbook: CalamineWorkbook::None,
42 lazy_loading: false,
43 loaded_sheets: self.loaded_sheets.clone(),
44 }
45 }
46}
47
48pub fn open_workbook<P: AsRef<Path>>(path: P, enable_lazy_loading: bool) -> Result<Workbook> {
49 let path_str = path.as_ref().to_string_lossy().to_string();
50 let path_ref = path.as_ref();
51
52 let extension = path_ref
54 .extension()
55 .and_then(|ext| ext.to_str())
56 .map(|ext| ext.to_lowercase());
57
58 let supports_lazy_loading =
60 enable_lazy_loading && matches!(extension.as_deref(), Some("xlsx" | "xlsm"));
61
62 let mut workbook = open_workbook_auto(&path)
64 .with_context(|| format!("Unable to parse Excel file: {}", path_str))?;
65
66 let sheet_names = workbook.sheet_names().to_vec();
67
68 let mut sheets = Vec::with_capacity(sheet_names.len());
70
71 let mut calamine_workbook = CalamineWorkbook::None;
73
74 if supports_lazy_loading {
75 for name in &sheet_names {
78 let sheet = Sheet {
80 name: name.to_string(),
81 data: vec![vec![Cell::empty(); 1]; 1],
82 max_rows: 0,
83 max_cols: 0,
84 is_loaded: false,
85 };
86
87 sheets.push(sheet);
88 }
89
90 if let Ok(file) = File::open(&path) {
92 let reader = BufReader::new(file);
93
94 if let Ok(xlsx_workbook) = Xlsx::new(reader) {
96 calamine_workbook = CalamineWorkbook::Xlsx(Box::new(xlsx_workbook));
97 } else {
98 if let Ok(file) = File::open(&path) {
100 let reader = BufReader::new(file);
101 if let Ok(xls_workbook) = Xls::new(reader) {
102 calamine_workbook = CalamineWorkbook::Xls(xls_workbook);
103 }
104 }
105 }
106 }
107 } else {
108 for name in &sheet_names {
110 let range = workbook
111 .worksheet_range(name)
112 .with_context(|| format!("Unable to read worksheet: {}", name))?;
113
114 let mut sheet = create_sheet_from_range(name, range);
115 sheet.is_loaded = true;
116 sheets.push(sheet);
117 }
118 }
119
120 if sheets.is_empty() {
121 anyhow::bail!("No worksheets found in file");
122 }
123
124 let mut loaded_sheets = HashSet::new();
125
126 if !supports_lazy_loading {
127 for i in 0..sheets.len() {
128 loaded_sheets.insert(i);
129 }
130 }
131
132 Ok(Workbook {
133 sheets,
134 current_sheet_index: 0,
135 file_path: path_str,
136 is_modified: false,
137 calamine_workbook,
138 lazy_loading: supports_lazy_loading,
139 loaded_sheets,
140 })
141}
142
143fn create_sheet_from_range(name: &str, range: calamine::Range<Data>) -> Sheet {
144 let (height, width) = range.get_size();
145
146 let mut data = vec![vec![Cell::empty(); width + 1]; height + 1];
148
149 for (row_idx, col_idx, cell) in range.used_cells() {
151 let (value, cell_type, original_type) = match cell {
153 Data::Empty => (String::new(), CellType::Empty, Some(DataTypeInfo::Empty)),
154
155 Data::String(s) => {
156 let value = s.clone();
157 (value, CellType::Text, Some(DataTypeInfo::String))
158 }
159
160 Data::Float(f) => {
161 let value = if *f == (*f as i64) as f64 && f.abs() < 1e10 {
162 (*f as i64).to_string()
163 } else {
164 f.to_string()
165 };
166 (value, CellType::Number, Some(DataTypeInfo::Float(*f)))
167 }
168
169 Data::Int(i) => (i.to_string(), CellType::Number, Some(DataTypeInfo::Int(*i))),
170
171 Data::Bool(b) => (
172 if *b {
173 "TRUE".to_string()
174 } else {
175 "FALSE".to_string()
176 },
177 CellType::Boolean,
178 Some(DataTypeInfo::Bool(*b)),
179 ),
180
181 Data::Error(e) => {
182 let mut value = String::with_capacity(15);
183 value.push_str("Error: ");
184 value.push_str(&format!("{:?}", e));
185 (value, CellType::Text, Some(DataTypeInfo::Error))
186 }
187
188 Data::DateTime(dt) => (
189 dt.to_string(),
190 CellType::Date,
191 Some(DataTypeInfo::DateTime(dt.as_f64())),
192 ),
193
194 Data::DateTimeIso(s) => {
195 let value = s.clone();
196 (
197 value.clone(),
198 CellType::Date,
199 Some(DataTypeInfo::DateTimeIso(value)),
200 )
201 }
202
203 Data::DurationIso(s) => {
204 let value = s.clone();
205 (
206 value.clone(),
207 CellType::Text,
208 Some(DataTypeInfo::DurationIso(value)),
209 )
210 }
211 };
212
213 let is_formula = !value.is_empty() && value.starts_with('=');
214
215 data[row_idx + 1][col_idx + 1] =
217 Cell::new_with_type(value, is_formula, cell_type, original_type);
218 }
219
220 Sheet {
221 name: name.to_string(),
222 data,
223 max_rows: height,
224 max_cols: width,
225 is_loaded: true,
226 }
227}
228
229impl Workbook {
230 pub fn get_current_sheet(&self) -> &Sheet {
231 &self.sheets[self.current_sheet_index]
232 }
233
234 pub fn get_current_sheet_mut(&mut self) -> &mut Sheet {
235 &mut self.sheets[self.current_sheet_index]
236 }
237
238 pub fn ensure_sheet_loaded(&mut self, sheet_index: usize, sheet_name: &str) -> Result<()> {
239 if !self.lazy_loading || self.sheets[sheet_index].is_loaded {
240 return Ok(());
241 }
242
243 match &mut self.calamine_workbook {
245 CalamineWorkbook::Xlsx(xlsx) => {
246 if let Ok(range) = xlsx.worksheet_range(sheet_name) {
247 let mut sheet = create_sheet_from_range(sheet_name, range);
249
250 let original_name = self.sheets[sheet_index].name.clone();
252 sheet.name = original_name;
253
254 self.sheets[sheet_index] = sheet;
255
256 self.loaded_sheets.insert(sheet_index);
258 }
259 }
260 CalamineWorkbook::Xls(xls) => {
261 if let Ok(range) = xls.worksheet_range(sheet_name) {
262 let mut sheet = create_sheet_from_range(sheet_name, range);
264
265 let original_name = self.sheets[sheet_index].name.clone();
267 sheet.name = original_name;
268
269 self.sheets[sheet_index] = sheet;
270
271 self.loaded_sheets.insert(sheet_index);
273 }
274 }
275 CalamineWorkbook::None => {
276 return Err(anyhow::anyhow!("Cannot load sheet: no workbook available"));
277 }
278 }
279
280 Ok(())
281 }
282
283 pub fn get_sheet_by_index(&self, index: usize) -> Option<&Sheet> {
284 self.sheets.get(index)
285 }
286
287 pub fn ensure_cell_exists(&mut self, row: usize, col: usize) {
288 let sheet = &mut self.sheets[self.current_sheet_index];
289
290 if row >= sheet.data.len() {
292 let default_row_len = if sheet.data.is_empty() {
293 col + 1
294 } else {
295 sheet.data[0].len()
296 };
297 let rows_to_add = row + 1 - sheet.data.len();
298
299 sheet
300 .data
301 .extend(vec![vec![Cell::empty(); default_row_len]; rows_to_add]);
302 sheet.max_rows = sheet.max_rows.max(row);
303 }
304
305 if col >= sheet.data[0].len() {
307 for row_data in &mut sheet.data {
308 row_data.resize_with(col + 1, Cell::empty);
309 }
310
311 sheet.max_cols = sheet.max_cols.max(col);
312 }
313 }
314
315 pub fn set_cell_value(&mut self, row: usize, col: usize, value: String) -> Result<()> {
316 self.ensure_cell_exists(row, col);
317
318 let sheet = &mut self.sheets[self.current_sheet_index];
319 let current_value = &sheet.data[row][col].value;
320
321 if current_value != &value {
323 let is_formula = value.starts_with('=');
324 sheet.data[row][col] = Cell::new(value, is_formula);
325
326 if col > sheet.max_cols && !sheet.data[row][col].value.is_empty() {
328 sheet.max_cols = col;
329 }
330
331 self.is_modified = true;
332 }
333
334 Ok(())
335 }
336
337 pub fn get_sheet_names(&self) -> Vec<String> {
338 let mut names = Vec::with_capacity(self.sheets.len());
339 for sheet in &self.sheets {
340 names.push(sheet.name.clone());
341 }
342 names
343 }
344
345 pub fn get_current_sheet_name(&self) -> String {
346 self.sheets[self.current_sheet_index].name.clone()
347 }
348
349 pub fn get_current_sheet_index(&self) -> usize {
350 self.current_sheet_index
351 }
352
353 pub fn switch_sheet(&mut self, index: usize) -> Result<()> {
354 if index >= self.sheets.len() {
355 anyhow::bail!("Sheet index out of range");
356 }
357
358 self.current_sheet_index = index;
359 Ok(())
360 }
361
362 pub fn add_sheet(&mut self, name: &str, index: usize) -> Result<String> {
363 let sheet_name = name.trim();
364
365 self.validate_sheet_name(sheet_name)?;
366 self.insert_sheet_at_index(Sheet::blank(sheet_name.to_string()), index)?;
367
368 Ok(sheet_name.to_string())
369 }
370
371 pub fn delete_current_sheet(&mut self) -> Result<()> {
372 self.delete_sheet_at_index(self.current_sheet_index)
373 }
374
375 pub fn delete_sheet_at_index(&mut self, index: usize) -> Result<()> {
376 if self.sheets.len() <= 1 {
378 anyhow::bail!("Cannot delete the last sheet");
379 }
380
381 if index >= self.sheets.len() {
382 anyhow::bail!("Sheet index out of range");
383 }
384
385 self.sheets.remove(index);
386 self.is_modified = true;
387
388 if index < self.current_sheet_index {
389 self.current_sheet_index = self.current_sheet_index.saturating_sub(1);
390 } else if self.current_sheet_index >= self.sheets.len() {
391 self.current_sheet_index = self.sheets.len() - 1;
392 }
393
394 Ok(())
395 }
396
397 pub fn delete_row(&mut self, row: usize) -> Result<()> {
398 let sheet = &mut self.sheets[self.current_sheet_index];
399
400 if row < 1 {
402 return Ok(());
403 }
404
405 if row > sheet.max_rows {
407 return Ok(());
408 }
409
410 if row < sheet.data.len() {
412 sheet.data.remove(row);
413 self.recalculate_max_cols();
414 self.is_modified = true;
415 }
416
417 Ok(())
418 }
419
420 pub fn delete_rows(&mut self, start_row: usize, end_row: usize) -> Result<()> {
422 let sheet = &mut self.sheets[self.current_sheet_index];
423
424 if start_row < 1 || start_row > end_row {
426 return Ok(());
427 }
428
429 if start_row > sheet.max_rows {
431 return Ok(());
432 }
433
434 let adjusted_end_row = end_row.min(sheet.data.len() - 1);
436
437 let effective_end_row = if end_row > sheet.max_rows {
439 sheet.max_rows
440 } else {
441 adjusted_end_row
442 };
443
444 if start_row <= effective_end_row && start_row < sheet.data.len() {
446 for row in (start_row..=effective_end_row).rev() {
448 if row < sheet.data.len() {
449 sheet.data.remove(row);
450 }
451 }
452
453 self.recalculate_max_cols();
454 self.is_modified = true;
455 }
456
457 Ok(())
458 }
459
460 pub fn delete_column(&mut self, col: usize) -> Result<()> {
461 let sheet = &mut self.sheets[self.current_sheet_index];
462
463 if col < 1 {
465 return Ok(());
466 }
467
468 if col > sheet.max_cols {
470 return Ok(());
471 }
472
473 let mut has_data = false;
474 for row in &sheet.data {
475 if col < row.len() && !row[col].value.is_empty() {
476 has_data = true;
477 break;
478 }
479 }
480
481 for row in sheet.data.iter_mut() {
482 if col < row.len() {
483 row.remove(col);
484 }
485 }
486
487 self.recalculate_max_cols();
488 self.recalculate_max_rows();
489
490 if has_data {
491 self.is_modified = true;
492 }
493
494 Ok(())
495 }
496
497 pub fn delete_columns(&mut self, start_col: usize, end_col: usize) -> Result<()> {
499 let sheet = &mut self.sheets[self.current_sheet_index];
500
501 if start_col < 1 || start_col > end_col {
503 return Ok(());
504 }
505
506 if start_col > sheet.max_cols {
508 return Ok(());
509 }
510
511 let effective_end_col = end_col.min(sheet.max_cols);
513
514 let mut has_data = false;
515 for row in &sheet.data {
516 for col in start_col..=effective_end_col {
517 if col < row.len() && !row[col].value.is_empty() {
518 has_data = true;
519 break;
520 }
521 }
522 if has_data {
523 break;
524 }
525 }
526
527 for row in sheet.data.iter_mut() {
528 for col in (start_col..=effective_end_col).rev() {
529 if col < row.len() {
530 row.remove(col);
531 }
532 }
533 }
534
535 self.recalculate_max_cols();
536 self.recalculate_max_rows();
537
538 if has_data {
539 self.is_modified = true;
540 }
541
542 Ok(())
543 }
544
545 pub fn is_modified(&self) -> bool {
546 self.is_modified
547 }
548
549 pub fn set_modified(&mut self, modified: bool) {
550 self.is_modified = modified;
551 }
552
553 pub fn get_file_path(&self) -> &str {
554 &self.file_path
555 }
556
557 pub fn is_lazy_loading(&self) -> bool {
558 self.lazy_loading
559 }
560
561 pub fn is_sheet_loaded(&self, sheet_index: usize) -> bool {
562 if !self.lazy_loading || sheet_index >= self.sheets.len() {
563 return true;
564 }
565
566 self.sheets[sheet_index].is_loaded
567 }
568
569 pub fn save(&mut self) -> Result<()> {
570 if !self.is_modified {
571 println!("No changes to save.");
572 return Ok(());
573 }
574
575 self.ensure_all_sheets_loaded()?;
576
577 let mut workbook = XlsxWorkbook::new();
579
580 let now = Local::now();
581 let timestamp = now.format("%Y%m%d_%H%M%S").to_string();
582 let path = Path::new(&self.file_path);
583 let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("sheet");
584 let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("xlsx");
585 let parent_dir = path.parent().unwrap_or_else(|| Path::new(""));
586 let new_filename = format!("{}_{}.{}", file_stem, timestamp, extension);
587 let new_filepath = parent_dir.join(new_filename);
588
589 let number_format = Format::new().set_num_format("General");
591 let date_format = Format::new().set_num_format("yyyy-mm-dd");
592
593 for sheet in &self.sheets {
595 let worksheet = workbook.add_worksheet().set_name(&sheet.name)?;
596
597 for col in 0..sheet.max_cols {
599 worksheet.set_column_width(col as u16, 15)?;
600 }
601
602 for row in 1..sheet.data.len() {
604 if row <= sheet.max_rows {
605 for col in 1..sheet.data[0].len() {
606 if col <= sheet.max_cols {
607 let cell = &sheet.data[row][col];
608
609 if cell.value.is_empty() {
611 continue;
612 }
613
614 let row_idx = (row - 1) as u32;
615 let col_idx = (col - 1) as u16;
616
617 match cell.cell_type {
619 CellType::Number => {
620 if let Ok(num) = cell.value.parse::<f64>() {
621 worksheet.write_number_with_format(
622 row_idx,
623 col_idx,
624 num,
625 &number_format,
626 )?;
627 } else {
628 worksheet.write_string(row_idx, col_idx, &cell.value)?;
629 }
630 }
631 CellType::Date => {
632 worksheet.write_string_with_format(
633 row_idx,
634 col_idx,
635 &cell.value,
636 &date_format,
637 )?;
638 }
639 CellType::Boolean => {
640 if let Ok(b) = cell.value.parse::<bool>() {
641 worksheet.write_boolean(row_idx, col_idx, b)?;
642 } else {
643 worksheet.write_string(row_idx, col_idx, &cell.value)?;
644 }
645 }
646 CellType::Text => {
647 if cell.is_formula {
648 let formula = rust_xlsxwriter::Formula::new(&cell.value);
649 worksheet.write_formula(row_idx, col_idx, formula)?;
650 } else {
651 worksheet.write_string(row_idx, col_idx, &cell.value)?;
652 }
653 }
654 CellType::Empty => {}
655 }
656 }
657 }
658 }
659 }
660 }
661
662 workbook.save(&new_filepath)?;
663 self.is_modified = false;
664
665 Ok(())
666 }
667
668 pub fn insert_sheet_at_index(&mut self, sheet: Sheet, index: usize) -> Result<()> {
669 if index > self.sheets.len() {
670 anyhow::bail!(
671 "Cannot insert sheet at index {}: index out of bounds (max index: {})",
672 index,
673 self.sheets.len()
674 );
675 }
676
677 if index <= self.current_sheet_index {
678 self.current_sheet_index += 1;
679 }
680
681 self.sheets.insert(index, sheet);
682 self.is_modified = true;
683 Ok(())
684 }
685
686 pub fn recalculate_max_cols(&mut self) {
687 let sheet = &mut self.sheets[self.current_sheet_index];
688
689 let actual_max_col = sheet
691 .data
692 .iter()
693 .map(|row| {
694 row.iter()
696 .enumerate()
697 .rev()
698 .find(|(_, cell)| !cell.value.is_empty())
699 .map(|(idx, _)| idx)
700 .unwrap_or(0)
701 })
702 .max()
703 .unwrap_or(0);
704
705 sheet.max_cols = actual_max_col.max(1);
706 }
707
708 pub fn recalculate_max_rows(&mut self) {
709 let sheet = &mut self.sheets[self.current_sheet_index];
710
711 let actual_max_row = sheet
713 .data
714 .iter()
715 .enumerate()
716 .rev()
717 .find(|(_, row)| row.iter().any(|cell| !cell.value.is_empty()))
718 .map(|(idx, _)| idx)
719 .unwrap_or(0);
720
721 sheet.max_rows = actual_max_row.max(1);
722 }
723
724 fn ensure_all_sheets_loaded(&mut self) -> Result<()> {
725 if !self.lazy_loading {
726 return Ok(());
727 }
728
729 let pending_sheets: Vec<(usize, String)> = self
730 .sheets
731 .iter()
732 .enumerate()
733 .filter(|(_, sheet)| !sheet.is_loaded)
734 .map(|(index, sheet)| (index, sheet.name.clone()))
735 .collect();
736
737 for (index, name) in pending_sheets {
738 self.ensure_sheet_loaded(index, &name)?;
739 }
740
741 Ok(())
742 }
743
744 fn validate_sheet_name(&self, name: &str) -> Result<()> {
745 if name.is_empty() {
746 anyhow::bail!("Sheet name cannot be empty");
747 }
748
749 if name.chars().count() > 31 {
750 anyhow::bail!("Sheet name cannot exceed 31 characters");
751 }
752
753 if name.starts_with('\'') || name.ends_with('\'') {
754 anyhow::bail!("Sheet name cannot start or end with apostrophes");
755 }
756
757 if name
758 .chars()
759 .any(|c| matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\'))
760 {
761 anyhow::bail!("Sheet name cannot contain any of these characters: [ ] : * ? / \\");
762 }
763
764 if self
765 .sheets
766 .iter()
767 .any(|sheet| sheet.name.eq_ignore_ascii_case(name))
768 {
769 anyhow::bail!("Sheet '{}' already exists", name);
770 }
771
772 Ok(())
773 }
774
775 #[cfg(test)]
776 pub(crate) fn from_sheets_for_test(sheets: Vec<Sheet>) -> Self {
777 let loaded_sheets = (0..sheets.len()).collect();
778
779 Self {
780 sheets,
781 current_sheet_index: 0,
782 file_path: "test.xlsx".to_string(),
783 is_modified: false,
784 calamine_workbook: CalamineWorkbook::None,
785 lazy_loading: false,
786 loaded_sheets,
787 }
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::Workbook;
794 use crate::excel::Sheet;
795
796 fn blank_sheet(name: &str) -> Sheet {
797 Sheet::blank(name.to_string())
798 }
799
800 #[test]
801 fn adds_blank_sheet_after_current_sheet() {
802 let mut workbook =
803 Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1"), blank_sheet("Sheet2")]);
804
805 let sheet_name = workbook.add_sheet("Added", 1).unwrap();
806
807 assert_eq!(sheet_name, "Added");
808 assert_eq!(
809 workbook.get_sheet_names(),
810 vec!["Sheet1", "Added", "Sheet2"]
811 );
812
813 let added_sheet = workbook.get_sheet_by_index(1).unwrap();
814 assert_eq!(added_sheet.name, "Added");
815 assert_eq!(added_sheet.max_rows, 1);
816 assert_eq!(added_sheet.max_cols, 1);
817 assert!(added_sheet.is_loaded);
818 assert_eq!(added_sheet.data.len(), 2);
819 assert_eq!(added_sheet.data[1].len(), 2);
820 }
821
822 #[test]
823 fn rejects_duplicate_sheet_names_case_insensitively() {
824 let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Summary")]);
825
826 let error = workbook.add_sheet("summary", 1).unwrap_err().to_string();
827
828 assert!(error.contains("already exists"));
829 }
830
831 #[test]
832 fn rejects_invalid_sheet_names() {
833 let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]);
834
835 assert!(workbook.add_sheet("", 1).is_err());
836 assert!(workbook.add_sheet("Bad/Name", 1).is_err());
837 assert!(workbook.add_sheet("'quoted", 1).is_err());
838 assert!(workbook
839 .add_sheet("this-sheet-name-is-definitely-too-long", 1)
840 .is_err());
841 }
842
843 #[test]
844 fn counts_sheet_name_length_by_characters() {
845 let mut workbook = Workbook::from_sheets_for_test(vec![blank_sheet("Sheet1")]);
846 let valid_name = "表".repeat(31);
847 let invalid_name = "表".repeat(32);
848
849 assert!(workbook.add_sheet(&valid_name, 1).is_ok());
850 assert!(workbook.add_sheet(&invalid_name, 2).is_err());
851 }
852}