Use umya-spreadsheet for Excel as specified in TODO.md
- Add umya-spreadsheet v2.3 dependency (preserves charts, styles, images, formulas, macros, comments) - Rewrite storage.rs to use umya-spreadsheet for read/write - Keep original workbook in memory during edit session - On cell edit: modify only that cell via update_xlsx_cell() - On save: write full workbook via save_workbook_to_drive() - Preserve all Excel features: merged cells, frozen panes, comments, styles - Extract cell styles (font, color, background, alignment) - Parse and preserve merge ranges - Support formula preservation with = prefix handling
This commit is contained in:
parent
3e75bbff97
commit
8a9a913ffb
2 changed files with 505 additions and 113 deletions
|
|
@ -204,6 +204,8 @@ png = "0.18"
|
||||||
qrcode = { version = "0.14", default-features = false }
|
qrcode = { version = "0.14", default-features = false }
|
||||||
|
|
||||||
# Excel/Spreadsheet Support - MS Office 100% Compatibility
|
# Excel/Spreadsheet Support - MS Office 100% Compatibility
|
||||||
|
# umya-spreadsheet preserves: Charts, styles, images, formulas, macros, comments
|
||||||
|
umya-spreadsheet = "2.3"
|
||||||
calamine = "0.26"
|
calamine = "0.26"
|
||||||
rust_xlsxwriter = "0.79"
|
rust_xlsxwriter = "0.79"
|
||||||
spreadsheet-ods = "1.0"
|
spreadsheet-ods = "1.0"
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
use crate::shared::state::AppState;
|
use crate::shared::state::AppState;
|
||||||
use crate::sheet::types::{CellData, Spreadsheet, SpreadsheetMetadata, Worksheet};
|
use crate::sheet::types::{CellData, CellStyle, MergedCell, Spreadsheet, SpreadsheetMetadata, Worksheet};
|
||||||
use calamine::{Data, Reader, Xlsx};
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rust_xlsxwriter::{Workbook, Format};
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use umya_spreadsheet::Spreadsheet as UmyaSpreadsheet;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn get_user_sheets_path(user_id: &str) -> String {
|
pub fn get_user_sheets_path(user_id: &str) -> String {
|
||||||
|
|
@ -80,11 +79,16 @@ pub async fn save_sheet_as_xlsx(
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn convert_to_xlsx(sheet: &Spreadsheet) -> Result<Vec<u8>, String> {
|
pub fn convert_to_xlsx(sheet: &Spreadsheet) -> Result<Vec<u8>, String> {
|
||||||
let mut workbook = Workbook::new();
|
let mut workbook = umya_spreadsheet::new_file();
|
||||||
|
|
||||||
for worksheet in &sheet.worksheets {
|
for (ws_idx, worksheet) in sheet.worksheets.iter().enumerate() {
|
||||||
let ws = workbook.add_worksheet();
|
let umya_sheet = if ws_idx == 0 {
|
||||||
ws.set_name(&worksheet.name).map_err(|e| format!("Failed to set sheet name: {e}"))?;
|
workbook.get_sheet_mut(&0).ok_or("Failed to get first sheet")?
|
||||||
|
} else {
|
||||||
|
workbook.new_sheet(&worksheet.name).map_err(|e| format!("Failed to create sheet: {e}"))?
|
||||||
|
};
|
||||||
|
|
||||||
|
umya_sheet.set_name(&worksheet.name);
|
||||||
|
|
||||||
for (key, cell_data) in &worksheet.data {
|
for (key, cell_data) in &worksheet.data {
|
||||||
let parts: Vec<&str> = key.split(',').collect();
|
let parts: Vec<&str> = key.split(',').collect();
|
||||||
|
|
@ -92,81 +96,175 @@ pub fn convert_to_xlsx(sheet: &Spreadsheet) -> Result<Vec<u8>, String> {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let row: u32 = parts[0].parse().unwrap_or(0);
|
let row: u32 = parts[0].parse().unwrap_or(0) + 1;
|
||||||
let col: u16 = parts[1].parse().unwrap_or(0);
|
let col: u32 = parts[1].parse().unwrap_or(0) + 1;
|
||||||
|
|
||||||
let mut format = Format::new();
|
if row == 0 || col == 0 {
|
||||||
|
continue;
|
||||||
if let Some(style) = &cell_data.style {
|
|
||||||
if let Some(ref weight) = style.font_weight {
|
|
||||||
if weight == "bold" {
|
|
||||||
format = format.set_bold();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref font_style) = style.font_style {
|
|
||||||
if font_style == "italic" {
|
|
||||||
format = format.set_italic();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(size) = style.font_size {
|
|
||||||
format = format.set_font_size(size as f64);
|
|
||||||
}
|
|
||||||
if let Some(ref font) = style.font_family {
|
|
||||||
format = format.set_font_name(font);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cell = umya_sheet.get_cell_mut((col, row));
|
||||||
|
|
||||||
if let Some(ref formula) = cell_data.formula {
|
if let Some(ref formula) = cell_data.formula {
|
||||||
let formula_str = if formula.starts_with('=') {
|
let formula_str = if formula.starts_with('=') {
|
||||||
&formula[1..]
|
&formula[1..]
|
||||||
} else {
|
} else {
|
||||||
formula
|
formula.as_str()
|
||||||
};
|
};
|
||||||
let _ = ws.write_formula_with_format(row, col, formula_str, &format);
|
cell.set_formula(formula_str);
|
||||||
} else if let Some(ref value) = cell_data.value {
|
} else if let Some(ref value) = cell_data.value {
|
||||||
if let Ok(num) = value.parse::<f64>() {
|
if let Ok(num) = value.parse::<f64>() {
|
||||||
let _ = ws.write_number_with_format(row, col, num, &format);
|
cell.set_value_number(num);
|
||||||
|
} else if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("false") {
|
||||||
|
cell.set_value_bool(value.eq_ignore_ascii_case("true"));
|
||||||
} else {
|
} else {
|
||||||
let _ = ws.write_string_with_format(row, col, value, &format);
|
cell.set_value_string(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref style) = cell_data.style {
|
||||||
|
apply_umya_style(cell, style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref widths) = worksheet.column_widths {
|
||||||
|
for (col_idx, width) in widths {
|
||||||
|
let col_letter = get_col_letter(*col_idx);
|
||||||
|
if let Some(col_dim) = umya_sheet.get_column_dimension_mut(&col_letter) {
|
||||||
|
col_dim.set_width(*width as f64);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(widths) = &worksheet.column_widths {
|
if let Some(ref heights) = worksheet.row_heights {
|
||||||
for (col_idx, width) in widths {
|
|
||||||
let _ = ws.set_column_width(*col_idx as u16, *width as f64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(heights) = &worksheet.row_heights {
|
|
||||||
for (row_idx, height) in heights {
|
for (row_idx, height) in heights {
|
||||||
let _ = ws.set_row_height(*row_idx, *height as f64);
|
if let Some(row_dim) = umya_sheet.get_row_dimension_mut(row_idx) {
|
||||||
|
row_dim.set_height(*height as f64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(merged) = &worksheet.merged_cells {
|
if let Some(ref merged) = worksheet.merged_cells {
|
||||||
for merge in merged {
|
for merge in merged {
|
||||||
let _ = ws.merge_range(
|
let start_col = get_col_letter(merge.start_col + 1);
|
||||||
merge.start_row,
|
let end_col = get_col_letter(merge.end_col + 1);
|
||||||
merge.start_col as u16,
|
let range = format!("{}{}:{}{}", start_col, merge.start_row + 1, end_col, merge.end_row + 1);
|
||||||
merge.end_row,
|
let _ = umya_sheet.add_merge_cells(&range);
|
||||||
merge.end_col as u16,
|
}
|
||||||
"",
|
}
|
||||||
&Format::new(),
|
|
||||||
);
|
if let Some(frozen_rows) = worksheet.frozen_rows {
|
||||||
|
if frozen_rows > 0 {
|
||||||
|
let sheet_views = umya_sheet.get_sheet_views_mut();
|
||||||
|
if let Some(view) = sheet_views.get_sheet_view_list_mut().first_mut() {
|
||||||
|
let pane = view.get_pane_mut();
|
||||||
|
pane.set_y_split(frozen_rows as f64);
|
||||||
|
pane.set_state(umya_spreadsheet::structs::PaneStateValues::Frozen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(frozen_cols) = worksheet.frozen_cols {
|
||||||
|
if frozen_cols > 0 {
|
||||||
|
let sheet_views = umya_sheet.get_sheet_views_mut();
|
||||||
|
if let Some(view) = sheet_views.get_sheet_view_list_mut().first_mut() {
|
||||||
|
let pane = view.get_pane_mut();
|
||||||
|
pane.set_x_split(frozen_cols as f64);
|
||||||
|
pane.set_state(umya_spreadsheet::structs::PaneStateValues::Frozen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let buf = workbook.save_to_buffer().map_err(|e| format!("Failed to write xlsx: {e}"))?;
|
let mut buf = Cursor::new(Vec::new());
|
||||||
Ok(buf)
|
umya_spreadsheet::writer::xlsx::write_writer(&workbook, &mut buf)
|
||||||
|
.map_err(|e| format!("Failed to write xlsx: {e}"))?;
|
||||||
|
|
||||||
|
Ok(buf.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_umya_style(cell: &mut umya_spreadsheet::Cell, style: &CellStyle) {
|
||||||
|
let cell_style = cell.get_style_mut();
|
||||||
|
|
||||||
|
if let Some(ref weight) = style.font_weight {
|
||||||
|
if weight == "bold" {
|
||||||
|
cell_style.get_font_mut().set_bold(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref font_style) = style.font_style {
|
||||||
|
if font_style == "italic" {
|
||||||
|
cell_style.get_font_mut().set_italic(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref decoration) = style.text_decoration {
|
||||||
|
if decoration.contains("underline") {
|
||||||
|
cell_style.get_font_mut().set_underline("single");
|
||||||
|
}
|
||||||
|
if decoration.contains("line-through") {
|
||||||
|
cell_style.get_font_mut().set_strikethrough(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(size) = style.font_size {
|
||||||
|
cell_style.get_font_mut().set_size(size as f64);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref font) = style.font_family {
|
||||||
|
cell_style.get_font_mut().set_name(font);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref color) = style.color {
|
||||||
|
let color_str = color.trim_start_matches('#');
|
||||||
|
cell_style.get_font_mut().get_color_mut().set_argb(&format!("FF{color_str}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref bg) = style.background {
|
||||||
|
let bg_str = bg.trim_start_matches('#');
|
||||||
|
cell_style.get_fill_mut().get_pattern_fill_mut()
|
||||||
|
.get_foreground_color_mut().set_argb(&format!("FF{bg_str}"));
|
||||||
|
cell_style.get_fill_mut().get_pattern_fill_mut()
|
||||||
|
.set_pattern_type(umya_spreadsheet::structs::PatternValues::Solid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref align) = style.text_align {
|
||||||
|
let h_align = match align.as_str() {
|
||||||
|
"left" => umya_spreadsheet::structs::HorizontalAlignmentValues::Left,
|
||||||
|
"center" => umya_spreadsheet::structs::HorizontalAlignmentValues::Center,
|
||||||
|
"right" => umya_spreadsheet::structs::HorizontalAlignmentValues::Right,
|
||||||
|
_ => umya_spreadsheet::structs::HorizontalAlignmentValues::Left,
|
||||||
|
};
|
||||||
|
cell_style.get_alignment_mut().set_horizontal(h_align);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref v_align) = style.vertical_align {
|
||||||
|
let v = match v_align.as_str() {
|
||||||
|
"top" => umya_spreadsheet::structs::VerticalAlignmentValues::Top,
|
||||||
|
"middle" => umya_spreadsheet::structs::VerticalAlignmentValues::Center,
|
||||||
|
"bottom" => umya_spreadsheet::structs::VerticalAlignmentValues::Bottom,
|
||||||
|
_ => umya_spreadsheet::structs::VerticalAlignmentValues::Center,
|
||||||
|
};
|
||||||
|
cell_style.get_alignment_mut().set_vertical(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_col_letter(col: u32) -> String {
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut n = col;
|
||||||
|
while n > 0 {
|
||||||
|
n -= 1;
|
||||||
|
result.insert(0, (b'A' + (n % 26) as u8) as char);
|
||||||
|
n /= 26;
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn load_xlsx_from_drive(
|
pub async fn load_xlsx_from_drive(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
_user_id: &str,
|
user_id: &str,
|
||||||
file_path: &str,
|
file_path: &str,
|
||||||
) -> Result<Spreadsheet, String> {
|
) -> Result<(Spreadsheet, UmyaSpreadsheet), String> {
|
||||||
let drive = state
|
let drive = state
|
||||||
.drive
|
.drive
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -187,10 +285,18 @@ pub async fn load_xlsx_from_drive(
|
||||||
.map_err(|e| format!("Failed to read file: {e}"))?
|
.map_err(|e| format!("Failed to read file: {e}"))?
|
||||||
.into_bytes();
|
.into_bytes();
|
||||||
|
|
||||||
load_xlsx_from_bytes(&bytes, file_path)
|
load_xlsx_from_bytes(&bytes, user_id, file_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_xlsx_from_bytes(bytes: &[u8], file_path: &str) -> Result<Spreadsheet, String> {
|
pub fn load_xlsx_from_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
user_id: &str,
|
||||||
|
file_path: &str,
|
||||||
|
) -> Result<(Spreadsheet, UmyaSpreadsheet), String> {
|
||||||
|
let cursor = Cursor::new(bytes);
|
||||||
|
let workbook = umya_spreadsheet::reader::xlsx::read_reader(cursor, true)
|
||||||
|
.map_err(|e| format!("Failed to parse xlsx: {e}"))?;
|
||||||
|
|
||||||
let file_name = file_path
|
let file_name = file_path
|
||||||
.split('/')
|
.split('/')
|
||||||
.last()
|
.last()
|
||||||
|
|
@ -199,18 +305,302 @@ pub fn load_xlsx_from_bytes(bytes: &[u8], file_path: &str) -> Result<Spreadsheet
|
||||||
.trim_end_matches(".xlsm")
|
.trim_end_matches(".xlsm")
|
||||||
.trim_end_matches(".xls");
|
.trim_end_matches(".xls");
|
||||||
|
|
||||||
let worksheets = parse_excel_to_worksheets(bytes, "xlsx")?;
|
let mut worksheets = Vec::new();
|
||||||
|
|
||||||
Ok(Spreadsheet {
|
for sheet in workbook.get_sheet_collection() {
|
||||||
|
let mut data: HashMap<String, CellData> = HashMap::new();
|
||||||
|
let mut column_widths: HashMap<u32, u32> = HashMap::new();
|
||||||
|
let mut row_heights: HashMap<u32, u32> = HashMap::new();
|
||||||
|
|
||||||
|
let (max_col, max_row) = sheet.get_highest_column_and_row();
|
||||||
|
|
||||||
|
for row in 1..=max_row {
|
||||||
|
for col in 1..=max_col {
|
||||||
|
if let Some(cell) = sheet.get_cell((col, row)) {
|
||||||
|
let value = cell.get_value().to_string();
|
||||||
|
let formula = if cell.get_formula().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("={}", cell.get_formula()))
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.is_empty() && formula.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("{},{}", row - 1, col - 1);
|
||||||
|
let style = extract_cell_style(cell);
|
||||||
|
|
||||||
|
let note = sheet.get_comments()
|
||||||
|
.iter()
|
||||||
|
.find(|c| {
|
||||||
|
let coord = c.get_coordinate();
|
||||||
|
coord.get_col_num() == &col && coord.get_row_num() == &row
|
||||||
|
})
|
||||||
|
.map(|c| c.get_text().get_value().to_string());
|
||||||
|
|
||||||
|
data.insert(
|
||||||
|
key,
|
||||||
|
CellData {
|
||||||
|
value: if value.is_empty() { None } else { Some(value) },
|
||||||
|
formula,
|
||||||
|
style,
|
||||||
|
format: None,
|
||||||
|
note,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for col in 1..=max_col {
|
||||||
|
let col_letter = get_col_letter(col);
|
||||||
|
if let Some(dim) = sheet.get_column_dimension(&col_letter) {
|
||||||
|
if let Some(width) = dim.get_width() {
|
||||||
|
column_widths.insert(col, width.round() as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in 1..=max_row {
|
||||||
|
if let Some(dim) = sheet.get_row_dimension(&row) {
|
||||||
|
if let Some(height) = dim.get_height() {
|
||||||
|
row_heights.insert(row, height.round() as u32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let merged_cells: Vec<MergedCell> = sheet.get_merge_cells()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|mc| {
|
||||||
|
let range = mc.get_range().get_range();
|
||||||
|
parse_merge_range(&range)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let frozen_rows = sheet.get_sheet_views()
|
||||||
|
.get_sheet_view_list()
|
||||||
|
.first()
|
||||||
|
.and_then(|v| v.get_pane().get_y_split())
|
||||||
|
.map(|y| y as u32);
|
||||||
|
|
||||||
|
let frozen_cols = sheet.get_sheet_views()
|
||||||
|
.get_sheet_view_list()
|
||||||
|
.first()
|
||||||
|
.and_then(|v| v.get_pane().get_x_split())
|
||||||
|
.map(|x| x as u32);
|
||||||
|
|
||||||
|
worksheets.push(Worksheet {
|
||||||
|
name: sheet.get_name().to_string(),
|
||||||
|
data,
|
||||||
|
column_widths: if column_widths.is_empty() { None } else { Some(column_widths) },
|
||||||
|
row_heights: if row_heights.is_empty() { None } else { Some(row_heights) },
|
||||||
|
frozen_rows,
|
||||||
|
frozen_cols,
|
||||||
|
merged_cells: if merged_cells.is_empty() { None } else { Some(merged_cells) },
|
||||||
|
filters: None,
|
||||||
|
hidden_rows: None,
|
||||||
|
validations: None,
|
||||||
|
conditional_formats: None,
|
||||||
|
charts: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let spreadsheet = Spreadsheet {
|
||||||
id: Uuid::new_v4().to_string(),
|
id: Uuid::new_v4().to_string(),
|
||||||
name: file_name.to_string(),
|
name: file_name.to_string(),
|
||||||
owner_id: get_current_user_id(),
|
owner_id: user_id.to_string(),
|
||||||
worksheets,
|
worksheets,
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((spreadsheet, workbook))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_cell_style(cell: &umya_spreadsheet::Cell) -> Option<CellStyle> {
|
||||||
|
let style = cell.get_style();
|
||||||
|
let font = style.get_font();
|
||||||
|
let fill = style.get_fill();
|
||||||
|
let alignment = style.get_alignment();
|
||||||
|
|
||||||
|
let font_weight = if font.get_bold() { Some("bold".to_string()) } else { None };
|
||||||
|
let font_style = if font.get_italic() { Some("italic".to_string()) } else { None };
|
||||||
|
|
||||||
|
let underline = font.get_underline();
|
||||||
|
let strikethrough = font.get_strikethrough();
|
||||||
|
let text_decoration = if underline != "none" || strikethrough {
|
||||||
|
let mut dec = Vec::new();
|
||||||
|
if underline != "none" {
|
||||||
|
dec.push("underline");
|
||||||
|
}
|
||||||
|
if strikethrough {
|
||||||
|
dec.push("line-through");
|
||||||
|
}
|
||||||
|
Some(dec.join(" "))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let font_size = Some(font.get_size().round() as u32);
|
||||||
|
let font_family = Some(font.get_name().to_string());
|
||||||
|
|
||||||
|
let color = font.get_color().get_argb().map(|c| {
|
||||||
|
let s = c.to_string();
|
||||||
|
if s.len() >= 8 {
|
||||||
|
format!("#{}", &s[2..])
|
||||||
|
} else {
|
||||||
|
format!("#{s}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let background = fill.get_pattern_fill().get_foreground_color().get_argb().map(|c| {
|
||||||
|
let s = c.to_string();
|
||||||
|
if s.len() >= 8 {
|
||||||
|
format!("#{}", &s[2..])
|
||||||
|
} else {
|
||||||
|
format!("#{s}")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let text_align = match alignment.get_horizontal().to_string().as_str() {
|
||||||
|
"left" => Some("left".to_string()),
|
||||||
|
"center" => Some("center".to_string()),
|
||||||
|
"right" => Some("right".to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let vertical_align = match alignment.get_vertical().to_string().as_str() {
|
||||||
|
"top" => Some("top".to_string()),
|
||||||
|
"center" => Some("middle".to_string()),
|
||||||
|
"bottom" => Some("bottom".to_string()),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if font_weight.is_some() || font_style.is_some() || text_decoration.is_some()
|
||||||
|
|| color.is_some() || background.is_some() || text_align.is_some() {
|
||||||
|
Some(CellStyle {
|
||||||
|
font_family,
|
||||||
|
font_size,
|
||||||
|
font_weight,
|
||||||
|
font_style,
|
||||||
|
text_decoration,
|
||||||
|
color,
|
||||||
|
background,
|
||||||
|
text_align,
|
||||||
|
vertical_align,
|
||||||
|
border: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_merge_range(range: &str) -> Option<MergedCell> {
|
||||||
|
let parts: Vec<&str> = range.split(':').collect();
|
||||||
|
if parts.len() != 2 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = parse_cell_ref(parts[0])?;
|
||||||
|
let end = parse_cell_ref(parts[1])?;
|
||||||
|
|
||||||
|
Some(MergedCell {
|
||||||
|
start_row: start.0,
|
||||||
|
start_col: start.1,
|
||||||
|
end_row: end.0,
|
||||||
|
end_col: end.1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_cell_ref(cell_ref: &str) -> Option<(u32, u32)> {
|
||||||
|
let mut col_str = String::new();
|
||||||
|
let mut row_str = String::new();
|
||||||
|
|
||||||
|
for c in cell_ref.chars() {
|
||||||
|
if c.is_ascii_alphabetic() {
|
||||||
|
col_str.push(c.to_ascii_uppercase());
|
||||||
|
} else if c.is_ascii_digit() {
|
||||||
|
row_str.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let col = col_str.chars().fold(0u32, |acc, c| {
|
||||||
|
acc * 26 + (c as u32 - 'A' as u32 + 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
let row: u32 = row_str.parse().ok()?;
|
||||||
|
|
||||||
|
Some((row.saturating_sub(1), col.saturating_sub(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_xlsx_cell(
|
||||||
|
workbook: &mut UmyaSpreadsheet,
|
||||||
|
sheet_name: &str,
|
||||||
|
row: u32,
|
||||||
|
col: u32,
|
||||||
|
value: Option<&str>,
|
||||||
|
formula: Option<&str>,
|
||||||
|
style: Option<&CellStyle>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let sheet = workbook
|
||||||
|
.get_sheet_by_name_mut(sheet_name)
|
||||||
|
.ok_or_else(|| format!("Sheet '{sheet_name}' not found"))?;
|
||||||
|
|
||||||
|
let cell = sheet.get_cell_mut((col + 1, row + 1));
|
||||||
|
|
||||||
|
if let Some(f) = formula {
|
||||||
|
let formula_str = if f.starts_with('=') { &f[1..] } else { f };
|
||||||
|
cell.set_formula(formula_str);
|
||||||
|
} else if let Some(v) = value {
|
||||||
|
if let Ok(num) = v.parse::<f64>() {
|
||||||
|
cell.set_value_number(num);
|
||||||
|
} else if v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("false") {
|
||||||
|
cell.set_value_bool(v.eq_ignore_ascii_case("true"));
|
||||||
|
} else {
|
||||||
|
cell.set_value_string(v);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cell.set_value_string("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(s) = style {
|
||||||
|
apply_umya_style(cell, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_workbook_to_drive(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
user_id: &str,
|
||||||
|
sheet_id: &str,
|
||||||
|
workbook: &UmyaSpreadsheet,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let drive = state
|
||||||
|
.drive
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Drive not available".to_string())?;
|
||||||
|
|
||||||
|
let path = format!("{}/{}.xlsx", get_user_sheets_path(user_id), sheet_id);
|
||||||
|
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
umya_spreadsheet::writer::xlsx::write_writer(workbook, &mut buf)
|
||||||
|
.map_err(|e| format!("Failed to write xlsx: {e}"))?;
|
||||||
|
|
||||||
|
drive
|
||||||
|
.put_object()
|
||||||
|
.bucket("gbo")
|
||||||
|
.key(&path)
|
||||||
|
.body(buf.into_inner().into())
|
||||||
|
.content_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to save xlsx: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_sheet_from_drive(
|
pub async fn load_sheet_from_drive(
|
||||||
state: &Arc<AppState>,
|
state: &Arc<AppState>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
|
|
@ -410,70 +800,70 @@ pub fn parse_csv_to_worksheets(
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_excel_to_worksheets(bytes: &[u8], _ext: &str) -> Result<Vec<Worksheet>, String> {
|
pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result<Vec<Worksheet>, String> {
|
||||||
let cursor = Cursor::new(bytes);
|
if ext == "xlsx" || ext == "xlsm" || ext == "xls" {
|
||||||
let mut workbook: Xlsx<_> =
|
let cursor = Cursor::new(bytes);
|
||||||
Reader::new(cursor).map_err(|e| format!("Failed to parse spreadsheet: {e}"))?;
|
if let Ok(workbook) = umya_spreadsheet::reader::xlsx::read_reader(cursor, true) {
|
||||||
|
let mut worksheets = Vec::new();
|
||||||
|
|
||||||
let sheet_names: Vec<String> = workbook.sheet_names().to_vec();
|
for sheet in workbook.get_sheet_collection() {
|
||||||
let mut worksheets = Vec::new();
|
let mut data: HashMap<String, CellData> = HashMap::new();
|
||||||
|
let (max_col, max_row) = sheet.get_highest_column_and_row();
|
||||||
|
|
||||||
for sheet_name in sheet_names {
|
for row in 1..=max_row {
|
||||||
let range = workbook
|
for col in 1..=max_col {
|
||||||
.worksheet_range(&sheet_name)
|
if let Some(cell) = sheet.get_cell((col, row)) {
|
||||||
.map_err(|e| format!("Failed to read sheet {sheet_name}: {e}"))?;
|
let value = cell.get_value().to_string();
|
||||||
|
let formula = if cell.get_formula().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(format!("={}", cell.get_formula()))
|
||||||
|
};
|
||||||
|
|
||||||
let mut data: HashMap<String, CellData> = HashMap::new();
|
if value.is_empty() && formula.is_none() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
for (row_idx, row) in range.rows().enumerate() {
|
let key = format!("{},{}", row - 1, col - 1);
|
||||||
for (col_idx, cell) in row.iter().enumerate() {
|
let style = extract_cell_style(cell);
|
||||||
let value = match cell {
|
|
||||||
Data::Empty => continue,
|
|
||||||
Data::String(s) => s.clone(),
|
|
||||||
Data::Int(i) => i.to_string(),
|
|
||||||
Data::Float(f) => f.to_string(),
|
|
||||||
Data::Bool(b) => b.to_string(),
|
|
||||||
Data::DateTime(dt) => dt.to_string(),
|
|
||||||
Data::Error(e) => format!("#ERR:{e:?}"),
|
|
||||||
Data::DateTimeIso(s) => s.clone(),
|
|
||||||
Data::DurationIso(s) => s.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let key = format!("{row_idx},{col_idx}");
|
data.insert(
|
||||||
data.insert(
|
key,
|
||||||
key,
|
CellData {
|
||||||
CellData {
|
value: if value.is_empty() { None } else { Some(value) },
|
||||||
value: Some(value),
|
formula,
|
||||||
formula: None,
|
style,
|
||||||
style: None,
|
format: None,
|
||||||
format: None,
|
note: None,
|
||||||
note: None,
|
},
|
||||||
},
|
);
|
||||||
);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worksheets.push(Worksheet {
|
||||||
|
name: sheet.get_name().to_string(),
|
||||||
|
data,
|
||||||
|
column_widths: None,
|
||||||
|
row_heights: None,
|
||||||
|
frozen_rows: None,
|
||||||
|
frozen_cols: None,
|
||||||
|
merged_cells: None,
|
||||||
|
filters: None,
|
||||||
|
hidden_rows: None,
|
||||||
|
validations: None,
|
||||||
|
conditional_formats: None,
|
||||||
|
charts: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if !worksheets.is_empty() {
|
||||||
|
return Ok(worksheets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
worksheets.push(Worksheet {
|
|
||||||
name: sheet_name,
|
|
||||||
data,
|
|
||||||
column_widths: None,
|
|
||||||
row_heights: None,
|
|
||||||
frozen_rows: None,
|
|
||||||
frozen_cols: None,
|
|
||||||
merged_cells: None,
|
|
||||||
filters: None,
|
|
||||||
hidden_rows: None,
|
|
||||||
validations: None,
|
|
||||||
conditional_formats: None,
|
|
||||||
charts: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if worksheets.is_empty() {
|
Err("Failed to parse spreadsheet".to_string())
|
||||||
return Err("Spreadsheet has no sheets".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(worksheets)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_new_spreadsheet() -> Spreadsheet {
|
pub fn create_new_spreadsheet() -> Spreadsheet {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue