diff --git a/src/docs/handlers.rs b/src/docs/handlers.rs index 35cf7935e..b39655721 100644 --- a/src/docs/handlers.rs +++ b/src/docs/handlers.rs @@ -6,7 +6,7 @@ use crate::docs::types::{ DocsSaveRequest, DocsSaveResponse, DocsAiRequest, DocsAiResponse, Document, DocumentMetadata, SearchQuery, TemplateResponse, }; -use crate::docs::utils::{html_to_markdown, strip_html}; +use crate::docs::utils::{convert_to_html, detect_document_format, html_to_markdown, markdown_to_html, rtf_to_html, strip_html}; use crate::docs::types::{ AcceptRejectAllRequest, AcceptRejectChangeRequest, AddCommentRequest, AddEndnoteRequest, AddFootnoteRequest, ApplyStyleRequest, CompareDocumentsRequest, CompareDocumentsResponse, @@ -1575,6 +1575,65 @@ pub async fn handle_get_outline( Ok(Json(OutlineResponse { items })) } +pub async fn handle_import_document( + State(state): State>, + mut multipart: axum::extract::Multipart, +) -> Result, (StatusCode, Json)> { + let mut file_bytes: Option> = None; + let mut filename = "import.docx".to_string(); + + while let Ok(Some(field)) = multipart.next_field().await { + if field.name() == Some("file") { + filename = field.file_name().unwrap_or("import.docx").to_string(); + if let Ok(bytes) = field.bytes().await { + file_bytes = Some(bytes.to_vec()); + } + } + } + + let bytes = file_bytes.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No file uploaded" })), + ) + })?; + + let format = detect_document_format(&bytes); + let content = match format { + "rtf" => rtf_to_html(&String::from_utf8_lossy(&bytes)), + "html" => String::from_utf8_lossy(&bytes).to_string(), + "markdown" => markdown_to_html(&String::from_utf8_lossy(&bytes)), + "txt" => { + let text = String::from_utf8_lossy(&bytes); + format!("

{}

", text.replace('\n', "

")) + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Unsupported format: {}", format) })), + )) + } + }; + + let title = filename.rsplit('/').next().unwrap_or(&filename) + .rsplit('.').last().unwrap_or(&filename) + .to_string(); + + let user_id = get_current_user_id(); + let mut doc = create_new_document(&title); + doc.content = content; + doc.owner_id = user_id.clone(); + + if let Err(e) = save_document_to_drive(&state, &user_id, &doc).await { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )); + } + + Ok(Json(doc)) +} + pub async fn handle_compare_documents( State(state): State>, Json(req): Json, diff --git a/src/docs/mod.rs b/src/docs/mod.rs index a8fc61865..2864b4c19 100644 --- a/src/docs/mod.rs +++ b/src/docs/mod.rs @@ -21,12 +21,12 @@ pub use handlers::{ handle_delete_endnote, handle_delete_footnote, handle_delete_style, handle_docs_ai, handle_docs_get_by_id, handle_docs_save, handle_enable_track_changes, handle_export_docx, handle_export_html, handle_export_md, handle_export_pdf, handle_export_txt, - handle_generate_toc, handle_get_document, handle_get_outline, handle_list_comments, - handle_list_documents, handle_list_endnotes, handle_list_footnotes, handle_list_styles, - handle_list_track_changes, handle_new_document, handle_reply_comment, handle_resolve_comment, - handle_save_document, handle_search_documents, handle_template_blank, handle_template_letter, - handle_template_meeting, handle_template_report, handle_update_endnote, handle_update_footnote, - handle_update_style, handle_update_toc, + handle_generate_toc, handle_get_document, handle_get_outline, handle_import_document, + handle_list_comments, handle_list_documents, handle_list_endnotes, handle_list_footnotes, + handle_list_styles, handle_list_track_changes, handle_new_document, handle_reply_comment, + handle_resolve_comment, handle_save_document, handle_search_documents, handle_template_blank, + handle_template_letter, handle_template_meeting, handle_template_report, handle_update_endnote, + handle_update_footnote, handle_update_style, handle_update_toc, }; pub use types::{ AiRequest, AiResponse, Collaborator, CollabMessage, CommentReply, ComparisonSummary, Document, @@ -61,6 +61,7 @@ pub fn configure_docs_routes() -> Router> { .route("/api/docs/export/md", get(handle_export_md)) .route("/api/docs/export/html", get(handle_export_html)) .route("/api/docs/export/txt", get(handle_export_txt)) + .route("/api/docs/import", post(handle_import_document)) .route("/api/docs/comment", post(handle_add_comment)) .route("/api/docs/comment/reply", post(handle_reply_comment)) .route("/api/docs/comment/resolve", post(handle_resolve_comment)) diff --git a/src/docs/utils.rs b/src/docs/utils.rs index 3670368e4..d2316a937 100644 --- a/src/docs/utils.rs +++ b/src/docs/utils.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Duration, Utc}; +use std::collections::HashMap; pub fn format_document_list_item( id: &str, @@ -269,3 +270,304 @@ pub fn generate_document_id() -> String { pub fn get_user_docs_path(user_id: &str) -> String { format!("users/{}/docs", user_id) } + +pub fn rtf_to_html(rtf: &str) -> String { + let mut html = String::new(); + let mut in_group = 0; + let mut bold = false; + let mut italic = false; + let mut underline = false; + let mut skip_chars = 0; + let chars: Vec = rtf.chars().collect(); + let mut i = 0; + + html.push_str("

"); + + while i < chars.len() { + if skip_chars > 0 { + skip_chars -= 1; + i += 1; + continue; + } + + let ch = chars[i]; + + match ch { + '{' => in_group += 1, + '}' => in_group -= 1, + '\\' => { + let mut cmd = String::new(); + i += 1; + while i < chars.len() && chars[i].is_ascii_alphabetic() { + cmd.push(chars[i]); + i += 1; + } + + match cmd.as_str() { + "b" => { + if !bold { + html.push_str(""); + bold = true; + } + } + "b0" => { + if bold { + html.push_str(""); + bold = false; + } + } + "i" => { + if !italic { + html.push_str(""); + italic = true; + } + } + "i0" => { + if italic { + html.push_str(""); + italic = false; + } + } + "ul" => { + if !underline { + html.push_str(""); + underline = true; + } + } + "ulnone" => { + if underline { + html.push_str(""); + underline = false; + } + } + "par" | "line" => html.push_str("
"), + "tab" => html.push_str("    "), + _ => {} + } + + if i < chars.len() && chars[i] == ' ' { + i += 1; + } + continue; + } + '\n' | '\r' => {} + _ => { + if in_group <= 1 { + html.push(ch); + } + } + } + i += 1; + } + + if underline { + html.push_str(""); + } + if italic { + html.push_str(""); + } + if bold { + html.push_str(""); + } + + html.push_str("
"); + html +} + +pub fn html_to_rtf(html: &str) -> String { + let mut rtf = String::from("{\\rtf1\\ansi\\deff0\n"); + rtf.push_str("{\\fonttbl{\\f0 Arial;}}\n"); + rtf.push_str("\\f0\\fs24\n"); + + let plain = strip_html(html); + + let mut result = html.to_string(); + result = result.replace("", "\\b "); + result = result.replace("", "\\b0 "); + result = result.replace("", "\\b "); + result = result.replace("", "\\b0 "); + result = result.replace("", "\\i "); + result = result.replace("", "\\i0 "); + result = result.replace("", "\\i "); + result = result.replace("", "\\i0 "); + result = result.replace("", "\\ul "); + result = result.replace("", "\\ulnone "); + result = result.replace("
", "\\par\n"); + result = result.replace("
", "\\par\n"); + result = result.replace("
", "\\par\n"); + result = result.replace("

", ""); + result = result.replace("

", "\\par\\par\n"); + result = result.replace("

", "\\fs48\\b "); + result = result.replace("

", "\\b0\\fs24\\par\n"); + result = result.replace("

", "\\fs36\\b "); + result = result.replace("

", "\\b0\\fs24\\par\n"); + result = result.replace("

", "\\fs28\\b "); + result = result.replace("

", "\\b0\\fs24\\par\n"); + + let stripped = strip_html(&result); + rtf.push_str(&stripped); + rtf.push('}'); + rtf +} + +pub fn odt_content_to_html(odt_xml: &str) -> String { + let mut html = String::from("
"); + + let mut in_text = false; + let mut in_span = false; + let mut current_text = String::new(); + let chars: Vec = odt_xml.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '<' { + let mut tag = String::new(); + i += 1; + while i < chars.len() && chars[i] != '>' { + tag.push(chars[i]); + i += 1; + } + + if tag.starts_with("text:p") { + if !current_text.is_empty() { + html.push_str(¤t_text); + current_text.clear(); + } + html.push_str("

"); + in_text = true; + } else if tag == "/text:p" { + html.push_str(¤t_text); + current_text.clear(); + html.push_str("

"); + in_text = false; + } else if tag.starts_with("text:span") { + if tag.contains("Bold") { + html.push_str(""); + } else if tag.contains("Italic") { + html.push_str(""); + } + in_span = true; + } else if tag == "/text:span" { + html.push_str(¤t_text); + current_text.clear(); + if in_span { + html.push_str(""); + } + in_span = false; + } else if tag.starts_with("text:h") { + let level = tag.chars() + .find(|c| c.is_ascii_digit()) + .unwrap_or('1'); + html.push_str(&format!("")); + in_text = true; + } else if tag.starts_with("/text:h") { + html.push_str(¤t_text); + current_text.clear(); + html.push_str(""); + in_text = false; + } else if tag == "text:line-break" || tag == "text:line-break/" { + current_text.push_str("
"); + } else if tag == "text:tab" || tag == "text:tab/" { + current_text.push_str("    "); + } + } else if in_text { + current_text.push(chars[i]); + } + i += 1; + } + + if !current_text.is_empty() { + html.push_str(¤t_text); + } + + html.push_str("
"); + html +} + +pub fn html_to_odt_content(html: &str) -> String { + let mut odt = String::from(r#" + + + +"#); + + let mut result = html.to_string(); + result = result.replace("

", ""); + result = result.replace("

", "\n"); + result = result.replace("
", ""); + result = result.replace("
", ""); + result = result.replace("
", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("", ""); + result = result.replace("

", ""); + result = result.replace("

", "\n"); + result = result.replace("

", ""); + result = result.replace("

", "\n"); + result = result.replace("

", ""); + result = result.replace("

", "\n"); + + let stripped = strip_html(&result); + let paragraphs: Vec<&str> = stripped.lines().collect(); + for para in paragraphs { + if !para.trim().is_empty() { + odt.push_str(&format!("{}\n", para.trim())); + } + } + + odt.push_str("
\n
\n
"); + odt +} + +pub fn detect_document_format(content: &[u8]) -> &'static str { + if content.len() >= 4 { + if &content[0..4] == b"PK\x03\x04" { + if content.len() > 30 { + let content_str = String::from_utf8_lossy(&content[0..100.min(content.len())]); + if content_str.contains("word/") { + return "docx"; + } else if content_str.contains("content.xml") { + return "odt"; + } + } + return "zip"; + } + if &content[0..4] == b"{\\rt" { + return "rtf"; + } + if content[0] == 0xD0 && content[1] == 0xCF { + return "doc"; + } + } + + let text = String::from_utf8_lossy(content); + if text.trim_start().starts_with(" Result { + let format = detect_document_format(content); + let text = String::from_utf8_lossy(content).to_string(); + + match format { + "rtf" => Ok(rtf_to_html(&text)), + "html" => Ok(text), + "markdown" => Ok(markdown_to_html(&text)), + "txt" => Ok(format!("

{}

", html_escape(&text).replace('\n', "

"))), + _ => Err(format!("Unsupported format: {format}")), + } +} diff --git a/src/sheet/export.rs b/src/sheet/export.rs index 9347a0bbb..c38d47d3e 100644 --- a/src/sheet/export.rs +++ b/src/sheet/export.rs @@ -1,6 +1,7 @@ use base64::Engine; use crate::sheet::types::{CellStyle, Spreadsheet}; use rust_xlsxwriter::{Color, Format, FormatAlign, Workbook}; +use std::io::Cursor; pub fn export_to_xlsx(sheet: &Spreadsheet) -> Result { let mut workbook = Workbook::new(); @@ -160,3 +161,230 @@ pub fn export_to_csv(sheet: &Spreadsheet) -> String { pub fn export_to_json(sheet: &Spreadsheet) -> String { serde_json::to_string_pretty(sheet).unwrap_or_default() } + +pub fn export_to_html(sheet: &Spreadsheet) -> String { + let mut html = String::from(r#" + + + + + "#); + html.push_str(&sheet.name); + html.push_str(r#" + + + +"#); + + for (ws_idx, ws) in sheet.worksheets.iter().enumerate() { + html.push_str(&format!("

{}

\n", ws.name)); + html.push_str("\n"); + + let mut max_row: u32 = 0; + let mut max_col: u32 = 0; + for key in ws.data.keys() { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() == 2 { + if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) { + max_row = max_row.max(row); + max_col = max_col.max(col); + } + } + } + + html.push_str(""); + for col in 0..=max_col { + let col_letter = column_to_letter(col); + html.push_str(&format!("")); + } + html.push_str("\n\n"); + + for row in 0..=max_row { + html.push_str(&format!("", row + 1)); + for col in 0..=max_col { + let key = format!("{row},{col}"); + let cell = ws.data.get(&key); + let value = cell.and_then(|c| c.value.clone()).unwrap_or_default(); + let style = cell.and_then(|c| c.style.as_ref()); + + let mut style_str = String::new(); + if let Some(s) = style { + if let Some(ref bg) = s.background { + style_str.push_str(&format!("background-color:{bg};")); + } + if let Some(ref color) = s.color { + style_str.push_str(&format!("color:{color};")); + } + if let Some(ref weight) = s.font_weight { + style_str.push_str(&format!("font-weight:{weight};")); + } + if let Some(ref align) = s.text_align { + style_str.push_str(&format!("text-align:{align};")); + } + } + + let escaped_value = html_escape(&value); + if style_str.is_empty() { + html.push_str(&format!("")); + } else { + html.push_str(&format!("")); + } + } + html.push_str("\n"); + } + html.push_str("
{col_letter}
{}{escaped_value}{escaped_value}
\n"); + } + + html.push_str(""); + html +} + +fn column_to_letter(col: u32) -> String { + let mut result = String::new(); + let mut n = col + 1; + while n > 0 { + n -= 1; + result.insert(0, (b'A' + (n % 26) as u8) as char); + n /= 26; + } + result +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn export_to_ods(sheet: &Spreadsheet) -> Result { + let mut xml = String::from(r#" + + + +"#); + + for ws in &sheet.worksheets { + xml.push_str(&format!("\n", ws.name)); + + let mut max_row: u32 = 0; + let mut max_col: u32 = 0; + for key in ws.data.keys() { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() == 2 { + if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) { + max_row = max_row.max(row); + max_col = max_col.max(col); + } + } + } + + for _ in 0..=max_col { + xml.push_str("\n"); + } + + for row in 0..=max_row { + xml.push_str("\n"); + for col in 0..=max_col { + let key = format!("{row},{col}"); + let value = ws.data.get(&key).and_then(|c| c.value.clone()).unwrap_or_default(); + let formula = ws.data.get(&key).and_then(|c| c.formula.clone()); + + if let Some(f) = formula { + xml.push_str(&format!( + "\n{}\n\n", + f, value + )); + } else if let Ok(num) = value.parse::() { + xml.push_str(&format!( + "\n{}\n\n", + num, value + )); + } else { + xml.push_str(&format!( + "\n{}\n\n", + value + )); + } + } + xml.push_str("\n"); + } + xml.push_str("\n"); + } + + xml.push_str("\n\n"); + Ok(xml) +} + +pub fn export_to_pdf_data(sheet: &Spreadsheet) -> Result, String> { + let html = export_to_html(sheet); + Ok(html.into_bytes()) +} + +pub fn export_to_markdown(sheet: &Spreadsheet) -> String { + let mut md = String::new(); + md.push_str(&format!("# {}\n\n", sheet.name)); + + for ws in &sheet.worksheets { + md.push_str(&format!("## {}\n\n", ws.name)); + + let mut max_row: u32 = 0; + let mut max_col: u32 = 0; + for key in ws.data.keys() { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() == 2 { + if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) { + max_row = max_row.max(row); + max_col = max_col.max(col); + } + } + } + + if max_col == 0 && max_row == 0 && ws.data.is_empty() { + md.push_str("*Empty worksheet*\n\n"); + continue; + } + + md.push('|'); + for col in 0..=max_col { + let col_letter = column_to_letter(col); + md.push_str(&format!(" {col_letter} |")); + } + md.push('\n'); + + md.push('|'); + for _ in 0..=max_col { + md.push_str(" --- |"); + } + md.push('\n'); + + for row in 0..=max_row { + md.push('|'); + for col in 0..=max_col { + let key = format!("{row},{col}"); + let value = ws.data.get(&key).and_then(|c| c.value.clone()).unwrap_or_default(); + let escaped = value.replace('|', "\\|"); + md.push_str(&format!(" {escaped} |")); + } + md.push('\n'); + } + md.push('\n'); + } + + md +} diff --git a/src/sheet/handlers.rs b/src/sheet/handlers.rs index f7fd7ca7b..c7473a8c5 100644 --- a/src/sheet/handlers.rs +++ b/src/sheet/handlers.rs @@ -1,11 +1,11 @@ use crate::shared::state::AppState; use crate::sheet::collaboration::broadcast_sheet_change; -use crate::sheet::export::{export_to_csv, export_to_json, export_to_xlsx}; +use crate::sheet::export::{export_to_csv, export_to_html, export_to_json, export_to_markdown, export_to_ods, export_to_xlsx}; use crate::sheet::formulas::evaluate_formula; use crate::sheet::storage::{ - create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, list_sheets_from_drive, - load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets, parse_excel_to_worksheets, - save_sheet_to_drive, + create_new_spreadsheet, delete_sheet_from_drive, get_current_user_id, import_spreadsheet_bytes, + list_sheets_from_drive, load_sheet_by_id, load_sheet_from_drive, parse_csv_to_worksheets, + parse_excel_to_worksheets, save_sheet_to_drive, }; use crate::sheet::types::{ AddCommentRequest, AddExternalLinkRequest, AddNoteRequest, ArrayFormula, ArrayFormulaRequest, @@ -472,6 +472,29 @@ pub async fn handle_export_sheet( let json = export_to_json(&sheet); Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json)) } + "html" => { + let html = export_to_html(&sheet); + Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html)) + } + "ods" => { + let ods = export_to_ods(&sheet).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + ) + })?; + Ok(( + [( + axum::http::header::CONTENT_TYPE, + "application/vnd.oasis.opendocument.spreadsheet", + )], + ods, + )) + } + "md" | "markdown" => { + let md = export_to_markdown(&sheet); + Ok(([(axum::http::header::CONTENT_TYPE, "text/markdown")], md)) + } _ => Err(( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Unsupported format" })), @@ -1158,10 +1181,46 @@ pub async fn handle_add_note( } pub async fn handle_import_sheet( - State(_state): State>, - mut _multipart: axum::extract::Multipart, + State(state): State>, + mut multipart: axum::extract::Multipart, ) -> Result, (StatusCode, Json)> { - Ok(Json(create_new_spreadsheet())) + let mut file_bytes: Option> = None; + let mut filename = "import.xlsx".to_string(); + + while let Ok(Some(field)) = multipart.next_field().await { + if field.name() == Some("file") { + filename = field.file_name().unwrap_or("import.xlsx").to_string(); + if let Ok(bytes) = field.bytes().await { + file_bytes = Some(bytes.to_vec()); + } + } + } + + let bytes = file_bytes.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No file uploaded" })), + ) + })?; + + let mut sheet = import_spreadsheet_bytes(&bytes, &filename).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": e })), + ) + })?; + + let user_id = get_current_user_id(); + sheet.owner_id = user_id.clone(); + + if let Err(e) = save_sheet_to_drive(&state, &user_id, &sheet).await { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )); + } + + Ok(Json(sheet)) } pub async fn handle_add_comment( diff --git a/src/sheet/storage.rs b/src/sheet/storage.rs index 44da4c78d..e59fba809 100644 --- a/src/sheet/storage.rs +++ b/src/sheet/storage.rs @@ -778,6 +778,9 @@ pub fn parse_csv_to_worksheets( style: None, format: None, note: None, + locked: None, + has_comment: None, + array_formula_id: None, }, ); } @@ -797,6 +800,9 @@ pub fn parse_csv_to_worksheets( validations: None, conditional_formats: None, charts: None, + comments: None, + protection: None, + array_formulas: None, }]) } @@ -835,6 +841,9 @@ pub fn parse_excel_to_worksheets(bytes: &[u8], ext: &str) -> Result Result Result Result, String> { + let content = String::from_utf8_lossy(bytes); + let mut worksheets = Vec::new(); + let mut current_sheet_name = "Sheet1".to_string(); + let mut data: HashMap = HashMap::new(); + let mut row_idx = 0u32; + + let mut in_table = false; + let mut in_row = false; + let mut col_idx = 0u32; + + let chars: Vec = content.chars().collect(); + let mut i = 0; + + while i < chars.len() { + if chars[i] == '<' { + let mut tag = String::new(); + i += 1; + while i < chars.len() && chars[i] != '>' { + tag.push(chars[i]); + i += 1; + } + + if tag.starts_with("table:table ") { + if let Some(name_start) = tag.find("table:name=\"") { + let name_part = &tag[name_start + 12..]; + if let Some(name_end) = name_part.find('"') { + current_sheet_name = name_part[..name_end].to_string(); + } + } + in_table = true; + data.clear(); + row_idx = 0; + } else if tag == "/table:table" { + if in_table && !data.is_empty() { + worksheets.push(Worksheet { + name: current_sheet_name.clone(), + data: data.clone(), + 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, + comments: None, + protection: None, + array_formulas: None, + }); + } + in_table = false; + } else if tag.starts_with("table:table-row") && !tag.ends_with('/') { + in_row = true; + col_idx = 0; + } else if tag == "/table:table-row" { + in_row = false; + row_idx += 1; + } else if tag.starts_with("table:table-cell") { + let mut cell_value = String::new(); + let mut has_formula = false; + let mut formula = String::new(); + + if tag.contains("table:formula=") { + has_formula = true; + if let Some(f_start) = tag.find("table:formula=\"") { + let f_part = &tag[f_start + 15..]; + if let Some(f_end) = f_part.find('"') { + formula = f_part[..f_end].to_string(); + } + } + } + + if tag.contains("office:value=") { + if let Some(v_start) = tag.find("office:value=\"") { + let v_part = &tag[v_start + 14..]; + if let Some(v_end) = v_part.find('"') { + cell_value = v_part[..v_end].to_string(); + } + } + } + + i += 1; + let mut text_depth = 0; + while i < chars.len() { + if chars[i] == '<' { + let mut inner_tag = String::new(); + i += 1; + while i < chars.len() && chars[i] != '>' { + inner_tag.push(chars[i]); + i += 1; + } + if inner_tag.starts_with("text:p") { + text_depth += 1; + } else if inner_tag == "/text:p" { + text_depth -= 1; + } else if inner_tag == "/table:table-cell" { + break; + } + } else if text_depth > 0 { + cell_value.push(chars[i]); + } + i += 1; + } + + if !cell_value.is_empty() || has_formula { + let key = format!("{row_idx},{col_idx}"); + data.insert(key, CellData { + value: if cell_value.is_empty() { None } else { Some(cell_value) }, + formula: if has_formula { Some(formula) } else { None }, + style: None, + format: None, + note: None, + locked: None, + has_comment: None, + array_formula_id: None, + }); + } + + col_idx += 1; + } + } + i += 1; + } + + if worksheets.is_empty() { + worksheets.push(Worksheet { + name: "Sheet1".to_string(), + data: HashMap::new(), + 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, + comments: None, + protection: None, + array_formulas: None, + }); + } + + Ok(worksheets) +} + +pub fn detect_spreadsheet_format(bytes: &[u8]) -> &'static str { + if bytes.len() >= 4 { + if &bytes[0..4] == b"PK\x03\x04" { + let content_str = String::from_utf8_lossy(&bytes[0..500.min(bytes.len())]); + if content_str.contains("xl/") || content_str.contains("[Content_Types].xml") { + return "xlsx"; + } + if content_str.contains("content.xml") || content_str.contains("mimetype") { + return "ods"; + } + return "zip"; + } + if bytes[0] == 0xD0 && bytes[1] == 0xCF { + return "xls"; + } + } + + let text = String::from_utf8_lossy(&bytes[0..100.min(bytes.len())]); + if text.contains('\t') && text.lines().count() > 1 { + return "tsv"; + } + if text.contains(',') && text.lines().count() > 1 { + return "csv"; + } + + "unknown" +} + +pub fn import_spreadsheet_bytes(bytes: &[u8], filename: &str) -> Result { + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + let detected = detect_spreadsheet_format(bytes); + + let worksheets = match detected { + "xlsx" | "xlsm" => parse_excel_to_worksheets(bytes, "xlsx")?, + "xls" => parse_excel_to_worksheets(bytes, "xls")?, + "ods" => parse_ods_to_worksheets(bytes)?, + "csv" => parse_csv_to_worksheets(bytes, b',', "Sheet1")?, + "tsv" => parse_csv_to_worksheets(bytes, b'\t', "Sheet1")?, + _ => { + if ext == "csv" { + parse_csv_to_worksheets(bytes, b',', "Sheet1")? + } else if ext == "tsv" || ext == "txt" { + parse_csv_to_worksheets(bytes, b'\t', "Sheet1")? + } else if ext == "ods" { + parse_ods_to_worksheets(bytes)? + } else { + return Err(format!("Unsupported format: {detected}")); + } + } + }; + + let name = filename.rsplit('/').next().unwrap_or(filename) + .trim_end_matches(&format!(".{ext}")) + .to_string(); + + Ok(Spreadsheet { + id: Uuid::new_v4().to_string(), + name, + owner_id: get_current_user_id(), + worksheets, + created_at: Utc::now(), + updated_at: Utc::now(), + named_ranges: None, + external_links: None, + }) +} + pub fn create_new_spreadsheet() -> Spreadsheet { Spreadsheet { id: Uuid::new_v4().to_string(), @@ -884,8 +1113,13 @@ pub fn create_new_spreadsheet() -> Spreadsheet { validations: None, conditional_formats: None, charts: None, + comments: None, + protection: None, + array_formulas: None, }], created_at: Utc::now(), updated_at: Utc::now(), + named_ranges: None, + external_links: None, } } diff --git a/src/slides/handlers.rs b/src/slides/handlers.rs index 2a31bd6d9..a5d3325b6 100644 --- a/src/slides/handlers.rs +++ b/src/slides/handlers.rs @@ -5,6 +5,7 @@ use crate::slides::storage::{ get_current_user_id, list_presentations_from_drive, load_presentation_by_id, load_presentation_from_drive, save_presentation_to_drive, }; +use crate::slides::utils::slides_from_markdown; use crate::slides::types::{ AddElementRequest, AddMediaRequest, AddSlideRequest, ApplyThemeRequest, ApplyTransitionToAllRequest, CollaborationCursor, CollaborationSelection, DeleteElementRequest, @@ -16,7 +17,7 @@ use crate::slides::types::{ SlidesAiResponse, StartPresenterRequest, UpdateCursorRequest, UpdateElementRequest, UpdateMediaRequest, UpdatePresenterRequest, UpdateSelectionRequest, UpdateSlideNotesRequest, }; -use crate::slides::utils::export_to_html; +use crate::slides::utils::{create_default_theme, export_to_html, export_to_json, export_to_markdown, export_to_odp_content, export_to_svg, slides_from_markdown}; use axum::{ extract::{Path, Query, State}, http::StatusCode, @@ -611,9 +612,35 @@ pub async fn handle_export_presentation( Ok(([(axum::http::header::CONTENT_TYPE, "text/html")], html)) } "json" => { - let json = serde_json::to_string_pretty(&presentation).unwrap_or_default(); + let json = export_to_json(&presentation); Ok(([(axum::http::header::CONTENT_TYPE, "application/json")], json)) } + "svg" => { + let slide_idx = 0; + if slide_idx < presentation.slides.len() { + let svg = export_to_svg(&presentation.slides[slide_idx], 960, 540); + Ok(([(axum::http::header::CONTENT_TYPE, "image/svg+xml")], svg)) + } else { + Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No slides to export" })), + )) + } + } + "md" | "markdown" => { + let md = export_to_markdown(&presentation); + Ok(([(axum::http::header::CONTENT_TYPE, "text/markdown")], md)) + } + "odp" => { + let odp = export_to_odp_content(&presentation); + Ok(( + [( + axum::http::header::CONTENT_TYPE, + "application/vnd.oasis.opendocument.presentation", + )], + odp, + )) + } "pptx" => { Ok(( [( @@ -1107,3 +1134,79 @@ pub async fn handle_get_presenter_notes( next_slide_thumbnail: None, })) } + +pub async fn handle_import_presentation( + State(state): State>, + mut multipart: axum::extract::Multipart, +) -> Result, (StatusCode, Json)> { + let mut file_bytes: Option> = None; + let mut filename = "import.pptx".to_string(); + + while let Ok(Some(field)) = multipart.next_field().await { + if field.name() == Some("file") { + filename = field.file_name().unwrap_or("import.pptx").to_string(); + if let Ok(bytes) = field.bytes().await { + file_bytes = Some(bytes.to_vec()); + } + } + } + + let bytes = file_bytes.ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "No file uploaded" })), + ) + })?; + + let ext = filename.rsplit('.').next().unwrap_or("").to_lowercase(); + let theme = create_default_theme(); + + let slides = match ext.as_str() { + "md" | "markdown" => { + let content = String::from_utf8_lossy(&bytes); + slides_from_markdown(&content) + } + "json" => { + let pres: Result = serde_json::from_slice(&bytes); + match pres { + Ok(p) => p.slides, + Err(e) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Invalid JSON: {}", e) })), + )) + } + } + } + _ => { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": format!("Unsupported format: {}", ext) })), + )) + } + }; + + let name = filename.rsplit('/').next().unwrap_or(&filename) + .rsplit('.').last().unwrap_or(&filename) + .to_string(); + + let user_id = get_current_user_id(); + let presentation = Presentation { + id: Uuid::new_v4().to_string(), + name, + owner_id: user_id.clone(), + slides, + theme, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + + if let Err(e) = save_presentation_to_drive(&state, &user_id, &presentation).await { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )); + } + + Ok(Json(presentation)) +} diff --git a/src/slides/mod.rs b/src/slides/mod.rs index cf6e83002..35e0f1e93 100644 --- a/src/slides/mod.rs +++ b/src/slides/mod.rs @@ -18,7 +18,7 @@ pub use handlers::{ handle_apply_transition_to_all, handle_delete_element, handle_delete_media, handle_delete_presentation, handle_delete_slide, handle_duplicate_slide, handle_end_presenter, handle_export_presentation, handle_get_presentation_by_id, - handle_get_presenter_notes, handle_list_cursors, handle_list_media, + handle_get_presenter_notes, handle_import_presentation, handle_list_cursors, handle_list_media, handle_list_presentations, handle_list_selections, handle_load_presentation, handle_new_presentation, handle_remove_transition, handle_reorder_slides, handle_save_presentation, handle_search_presentations, handle_set_transition, @@ -56,6 +56,7 @@ pub fn configure_slides_routes() -> Router> { .route("/api/slides/element/delete", post(handle_delete_element)) .route("/api/slides/theme", post(handle_apply_theme)) .route("/api/slides/export", post(handle_export_presentation)) + .route("/api/slides/import", post(handle_import_presentation)) .route("/api/slides/cursor", post(handle_update_cursor)) .route("/api/slides/selection", post(handle_update_selection)) .route("/api/slides/cursors", get(handle_list_cursors)) diff --git a/src/slides/utils.rs b/src/slides/utils.rs index 193366f79..e06713ccd 100644 --- a/src/slides/utils.rs +++ b/src/slides/utils.rs @@ -1,7 +1,8 @@ use crate::slides::types::{ - ElementContent, ElementStyle, PresentationTheme, Slide, SlideBackground, SlideElement, - ThemeColors, ThemeFonts, + ElementContent, ElementStyle, Presentation, PresentationTheme, Slide, SlideBackground, + SlideElement, ThemeColors, ThemeFonts, }; +use base64::Engine; use uuid::Uuid; pub fn create_default_theme() -> PresentationTheme { @@ -312,3 +313,387 @@ pub fn sanitize_filename(name: &str) -> String { .trim_matches('_') .to_string() } + +pub fn export_to_svg(slide: &Slide, width: u32, height: u32) -> String { + let mut svg = format!( + r#" + +"#, + width, height, width, height + ); + + let bg_color = slide.background.color.as_deref().unwrap_or("#ffffff"); + svg.push_str(&format!( + r#" +"#, + bg_color + )); + + for element in &slide.elements { + match element.element_type.as_str() { + "text" => { + let text = element.content.text.as_deref().unwrap_or(""); + let font_size = element.style.font_size.unwrap_or(18.0); + let color = element.style.color.as_deref().unwrap_or("#000000"); + let font_family = element.style.font_family.as_deref().unwrap_or("Arial"); + let font_weight = element.style.font_weight.as_deref().unwrap_or("normal"); + + svg.push_str(&format!( + r#" {} +"#, + element.x, + element.y + font_size, + font_family, + font_size, + font_weight, + color, + xml_escape(text) + )); + } + "shape" => { + let shape_type = element.content.shape_type.as_deref().unwrap_or("rectangle"); + let fill = element.style.fill.as_deref().unwrap_or("#cccccc"); + let stroke = element.style.stroke.as_deref().unwrap_or("none"); + let stroke_width = element.style.stroke_width.unwrap_or(1.0); + + match shape_type { + "rectangle" | "rect" => { + let rx = element.style.border_radius.unwrap_or(0.0); + svg.push_str(&format!( + r#" +"#, + element.x, element.y, element.width, element.height, rx, fill, stroke, stroke_width + )); + } + "circle" | "ellipse" => { + let cx = element.x + element.width / 2.0; + let cy = element.y + element.height / 2.0; + let rx = element.width / 2.0; + let ry = element.height / 2.0; + svg.push_str(&format!( + r#" +"#, + cx, cy, rx, ry, fill, stroke, stroke_width + )); + } + "line" => { + svg.push_str(&format!( + r#" +"#, + element.x, + element.y, + element.x + element.width, + element.y + element.height, + stroke, + stroke_width + )); + } + "triangle" => { + let x1 = element.x + element.width / 2.0; + let y1 = element.y; + let x2 = element.x; + let y2 = element.y + element.height; + let x3 = element.x + element.width; + let y3 = element.y + element.height; + svg.push_str(&format!( + r#" +"#, + x1, y1, x2, y2, x3, y3, fill, stroke, stroke_width + )); + } + _ => {} + } + } + "image" => { + if let Some(ref src) = element.content.src { + svg.push_str(&format!( + r#" +"#, + element.x, element.y, element.width, element.height, src + )); + } + } + _ => {} + } + } + + svg.push_str(""); + svg +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub fn export_slide_to_png_placeholder(slide: &Slide, width: u32, height: u32) -> Vec { + let svg = export_to_svg(slide, width, height); + svg.into_bytes() +} + +pub fn export_to_odp_content(presentation: &Presentation) -> String { + let mut xml = String::from(r#" + + + +"#); + + for (idx, slide) in presentation.slides.iter().enumerate() { + xml.push_str(&format!( + "\n", + idx + 1 + )); + + for element in &slide.elements { + match element.element_type.as_str() { + "text" => { + let text = element.content.text.as_deref().unwrap_or(""); + xml.push_str(&format!( + r#" + + {} + + +"#, + element.x, element.y, element.width, element.height, xml_escape(text) + )); + } + "shape" => { + let shape_type = element.content.shape_type.as_deref().unwrap_or("rectangle"); + let fill = element.style.fill.as_deref().unwrap_or("#cccccc"); + + match shape_type { + "rectangle" | "rect" => { + xml.push_str(&format!( + r#" +"#, + element.x, element.y, element.width, element.height, fill + )); + } + "circle" | "ellipse" => { + xml.push_str(&format!( + r#" +"#, + element.x, element.y, element.width, element.height, fill + )); + } + _ => {} + } + } + "image" => { + if let Some(ref src) = element.content.src { + xml.push_str(&format!( + r#" + + +"#, + element.x, element.y, element.width, element.height, src + )); + } + } + _ => {} + } + } + + xml.push_str("\n"); + } + + xml.push_str("\n\n"); + xml +} + +pub fn export_to_json(presentation: &Presentation) -> String { + serde_json::to_string_pretty(presentation).unwrap_or_default() +} + +pub fn export_to_markdown(presentation: &Presentation) -> String { + let mut md = format!("# {}\n\n", presentation.name); + + for (idx, slide) in presentation.slides.iter().enumerate() { + md.push_str(&format!("---\n\n## Slide {}\n\n", idx + 1)); + + for element in &slide.elements { + if element.element_type == "text" { + if let Some(ref text) = element.content.text { + let font_size = element.style.font_size.unwrap_or(18.0); + if font_size >= 32.0 { + md.push_str(&format!("### {}\n\n", text)); + } else { + md.push_str(&format!("{}\n\n", text)); + } + } + } else if element.element_type == "image" { + if let Some(ref src) = element.content.src { + md.push_str(&format!("![Image]({})\n\n", src)); + } + } + } + + if let Some(ref notes) = slide.notes { + md.push_str(&format!("**Speaker Notes:**\n{}\n\n", notes)); + } + } + + md +} + +pub fn slides_from_markdown(md: &str) -> Vec { + let theme = create_default_theme(); + let mut slides = Vec::new(); + let sections: Vec<&str> = md.split("\n---\n").collect(); + + for section in sections { + let lines: Vec<&str> = section.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + continue; + } + + let mut slide = create_blank_slide(&theme); + let mut y_offset = 50.0; + + for line in lines { + let trimmed = line.trim(); + if trimmed.starts_with("# ") { + slide.elements.push(create_text_element( + &trimmed[2..], + 50.0, + y_offset, + 860.0, + 60.0, + 44.0, + true, + &theme, + )); + y_offset += 80.0; + } else if trimmed.starts_with("## ") { + slide.elements.push(create_text_element( + &trimmed[3..], + 50.0, + y_offset, + 860.0, + 50.0, + 32.0, + true, + &theme, + )); + y_offset += 60.0; + } else if trimmed.starts_with("### ") { + slide.elements.push(create_text_element( + &trimmed[4..], + 50.0, + y_offset, + 860.0, + 40.0, + 24.0, + true, + &theme, + )); + y_offset += 50.0; + } else if trimmed.starts_with("![") { + if let Some(start) = trimmed.find('(') { + if let Some(end) = trimmed.find(')') { + let src = &trimmed[start + 1..end]; + slide.elements.push(SlideElement { + id: Uuid::new_v4().to_string(), + element_type: "image".to_string(), + x: 50.0, + y: y_offset, + width: 400.0, + height: 300.0, + rotation: 0.0, + content: ElementContent { + text: None, + html: None, + src: Some(src.to_string()), + shape_type: None, + chart_data: None, + table_data: None, + }, + style: ElementStyle::default(), + animations: vec![], + z_index: slide.elements.len() as i32, + locked: false, + }); + y_offset += 320.0; + } + } + } else if !trimmed.is_empty() { + slide.elements.push(create_text_element( + trimmed, + 50.0, + y_offset, + 860.0, + 30.0, + 18.0, + false, + &theme, + )); + y_offset += 40.0; + } + } + + slides.push(slide); + } + + if slides.is_empty() { + slides.push(create_title_slide(&theme)); + } + + slides +} + +fn create_text_element( + text: &str, + x: f64, + y: f64, + width: f64, + height: f64, + font_size: f64, + bold: bool, + theme: &PresentationTheme, +) -> SlideElement { + SlideElement { + id: Uuid::new_v4().to_string(), + element_type: "text".to_string(), + x, + y, + width, + height, + rotation: 0.0, + content: ElementContent { + text: Some(text.to_string()), + html: Some(format!("

{}

", text)), + src: None, + shape_type: None, + chart_data: None, + table_data: None, + }, + style: ElementStyle { + fill: None, + stroke: None, + stroke_width: None, + opacity: None, + shadow: None, + font_family: Some(theme.fonts.body.clone()), + font_size: Some(font_size), + font_weight: if bold { Some("bold".to_string()) } else { None }, + font_style: None, + text_align: Some("left".to_string()), + vertical_align: Some("top".to_string()), + color: Some(theme.colors.text.clone()), + line_height: None, + border_radius: None, + }, + animations: vec![], + z_index: 0, + locked: false, + } +}