use crate::core::shared::state::AppState; use crate::slides::ooxml::update_pptx_text; use crate::slides::types::{ ElementContent, ElementStyle, Presentation, PresentationMetadata, Slide, SlideBackground, SlideElement, }; use crate::slides::utils::{create_content_slide, create_default_theme, create_title_slide}; use chrono::{DateTime, Utc}; use std::collections::HashMap; use std::io::{Cursor, Read, Write}; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use zip::write::SimpleFileOptions; use zip::{ZipArchive, ZipWriter}; static PRESENTATION_CACHE: once_cell::sync::Lazy, DateTime)>>> = once_cell::sync::Lazy::new(|| RwLock::new(HashMap::new())); const CACHE_TTL_SECS: i64 = 3600; pub fn get_user_presentations_path(user_id: &str) -> String { format!("users/{user_id}/presentations") } pub fn get_current_user_id() -> String { "default-user".to_string() } pub fn generate_presentation_id() -> String { Uuid::new_v4().to_string() } pub async fn cache_presentation_bytes(pres_id: &str, bytes: Vec) { let mut cache = PRESENTATION_CACHE.write().await; cache.insert(pres_id.to_string(), (bytes, Utc::now())); let now = Utc::now(); cache.retain(|_, (_, modified)| (now - *modified).num_seconds() < CACHE_TTL_SECS); } pub async fn get_cached_presentation_bytes(pres_id: &str) -> Option> { let cache = PRESENTATION_CACHE.read().await; cache.get(pres_id).map(|(bytes, _)| bytes.clone()) } pub async fn remove_from_cache(pres_id: &str) { let mut cache = PRESENTATION_CACHE.write().await; cache.remove(pres_id); } fn extract_id_from_path(path: &str) -> String { let raw = path.split('/').last().unwrap_or_default(); raw.strip_suffix(".json") .or_else(|| raw.strip_suffix(".pptx")) .unwrap_or(raw) .to_string() } pub async fn save_presentation_to_drive( state: &Arc, user_id: &str, presentation: &Presentation, ) -> Result<(), String> { let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let path = format!( "{}/{}.json", get_user_presentations_path(user_id), presentation.id ); let content = serde_json::to_string_pretty(presentation) .map_err(|e| format!("Serialization error: {e}"))?; drive .put_object() .bucket("gbo") .key(&path) .body(content.into_bytes()) .content_type("application/json") .send() .await .map_err(|e| format!("Failed to save presentation: {e}"))?; Ok(()) } pub async fn save_presentation_as_pptx( state: &Arc, user_id: &str, presentation: &Presentation, ) -> Result, String> { let pptx_bytes = if let Some(original_bytes) = get_cached_presentation_bytes(&presentation.id).await { let slide_texts: Vec> = presentation.slides.iter().map(|slide| { slide.elements.iter().filter_map(|el| { el.content.text.clone() }).collect() }).collect(); update_pptx_text(&original_bytes, &slide_texts).unwrap_or_else(|_| { convert_to_pptx(presentation).unwrap_or_default() }) } else { convert_to_pptx(presentation)? }; let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let path = format!( "{}/{}.pptx", get_user_presentations_path(user_id), presentation.id ); drive .put_object() .bucket("gbo") .key(&path) .body(pptx_bytes.clone()) .content_type("application/vnd.openxmlformats-officedocument.presentationml.presentation") .send() .await .map_err(|e| format!("Failed to save PPTX: {e}"))?; Ok(pptx_bytes) } pub fn convert_to_pptx(presentation: &Presentation) -> Result, String> { let mut buf = Cursor::new(Vec::new()); { let mut zip = ZipWriter::new(&mut buf); let options = SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); zip.start_file("[Content_Types].xml", options) .map_err(|e| format!("Failed to create content types: {e}"))?; zip.write_all(generate_content_types_xml(presentation.slides.len()).as_bytes()) .map_err(|e| format!("Failed to write content types: {e}"))?; zip.start_file("_rels/.rels", options) .map_err(|e| format!("Failed to create rels: {e}"))?; zip.write_all(generate_rels_xml().as_bytes()) .map_err(|e| format!("Failed to write rels: {e}"))?; zip.start_file("ppt/presentation.xml", options) .map_err(|e| format!("Failed to create presentation.xml: {e}"))?; zip.write_all(generate_presentation_xml(presentation).as_bytes()) .map_err(|e| format!("Failed to write presentation.xml: {e}"))?; zip.start_file("ppt/_rels/presentation.xml.rels", options) .map_err(|e| format!("Failed to create presentation rels: {e}"))?; zip.write_all(generate_presentation_rels_xml(presentation.slides.len()).as_bytes()) .map_err(|e| format!("Failed to write presentation rels: {e}"))?; for (idx, slide) in presentation.slides.iter().enumerate() { let slide_num = idx + 1; zip.start_file(format!("ppt/slides/slide{slide_num}.xml"), options) .map_err(|e| format!("Failed to create slide{slide_num}.xml: {e}"))?; zip.write_all(generate_slide_xml(slide, slide_num).as_bytes()) .map_err(|e| format!("Failed to write slide{slide_num}.xml: {e}"))?; zip.start_file(format!("ppt/slides/_rels/slide{slide_num}.xml.rels"), options) .map_err(|e| format!("Failed to create slide{slide_num} rels: {e}"))?; zip.write_all(generate_slide_rels_xml().as_bytes()) .map_err(|e| format!("Failed to write slide{slide_num} rels: {e}"))?; } zip.start_file("ppt/slideLayouts/slideLayout1.xml", options) .map_err(|e| format!("Failed to create slideLayout1.xml: {e}"))?; zip.write_all(generate_slide_layout_xml().as_bytes()) .map_err(|e| format!("Failed to write slideLayout1.xml: {e}"))?; zip.start_file("ppt/slideLayouts/_rels/slideLayout1.xml.rels", options) .map_err(|e| format!("Failed to create slideLayout1 rels: {e}"))?; zip.write_all(generate_slide_layout_rels_xml().as_bytes()) .map_err(|e| format!("Failed to write slideLayout1 rels: {e}"))?; zip.start_file("ppt/slideMasters/slideMaster1.xml", options) .map_err(|e| format!("Failed to create slideMaster1.xml: {e}"))?; zip.write_all(generate_slide_master_xml().as_bytes()) .map_err(|e| format!("Failed to write slideMaster1.xml: {e}"))?; zip.start_file("ppt/slideMasters/_rels/slideMaster1.xml.rels", options) .map_err(|e| format!("Failed to create slideMaster1 rels: {e}"))?; zip.write_all(generate_slide_master_rels_xml().as_bytes()) .map_err(|e| format!("Failed to write slideMaster1 rels: {e}"))?; zip.start_file("ppt/theme/theme1.xml", options) .map_err(|e| format!("Failed to create theme1.xml: {e}"))?; zip.write_all(generate_theme_xml(presentation).as_bytes()) .map_err(|e| format!("Failed to write theme1.xml: {e}"))?; zip.start_file("docProps/app.xml", options) .map_err(|e| format!("Failed to create app.xml: {e}"))?; zip.write_all(generate_app_xml(presentation).as_bytes()) .map_err(|e| format!("Failed to write app.xml: {e}"))?; zip.start_file("docProps/core.xml", options) .map_err(|e| format!("Failed to create core.xml: {e}"))?; zip.write_all(generate_core_xml(presentation).as_bytes()) .map_err(|e| format!("Failed to write core.xml: {e}"))?; zip.finish().map_err(|e| format!("Failed to finish ZIP: {e}"))?; } Ok(buf.into_inner()) } fn generate_content_types_xml(slide_count: usize) -> String { let mut slides_types = String::new(); for i in 1..=slide_count { slides_types.push_str(&format!( r#""# )); } format!( r#" {slides_types} "# ) } fn generate_rels_xml() -> String { r#" "#.to_string() } fn generate_presentation_xml(presentation: &Presentation) -> String { let mut slide_ids = String::new(); for (idx, _) in presentation.slides.iter().enumerate() { let id = 256 + idx as u32; let rid = format!("rId{}", idx + 2); slide_ids.push_str(&format!(r#""#)); } format!( r#" {slide_ids} "# ) } fn generate_presentation_rels_xml(slide_count: usize) -> String { let mut rels = String::new(); rels.push_str(r#""#); for i in 1..=slide_count { let rid = format!("rId{}", i + 1); rels.push_str(&format!( r#""# )); } let theme_rid = format!("rId{}", slide_count + 2); rels.push_str(&format!( r#""# )); format!( r#" {rels} "# ) } fn generate_slide_xml(slide: &Slide, _slide_num: usize) -> String { let mut shapes = String::new(); let mut shape_id = 2u32; for element in &slide.elements { let x = (element.x * 9144.0) as i64; let y = (element.y * 9144.0) as i64; let cx = (element.width * 9144.0) as i64; let cy = (element.height * 9144.0) as i64; if let Some(ref text) = element.content.text { let font_size = element.style.font_size.unwrap_or(18.0); let font_size_emu = (font_size * 100.0) as i32; let escaped_text = escape_xml(text); let bold_attr = if element.style.font_weight.as_deref() == Some("bold") { r#" b="1""# } else { "" }; let italic_attr = if element.style.font_style.as_deref() == Some("italic") { r#" i="1""# } else { "" }; shapes.push_str(&format!( r#" {escaped_text} "# )); shape_id += 1; } else if let Some(ref shape_type) = element.content.shape_type { let preset = match shape_type.as_str() { "rectangle" => "rect", "ellipse" | "circle" => "ellipse", "triangle" => "triangle", "diamond" => "diamond", "star" => "star5", "arrow" => "rightArrow", _ => "rect", }; let fill_color = element .style .fill .as_ref() .map(|c| c.trim_start_matches('#').to_uppercase()) .unwrap_or_else(|| "4472C4".to_string()); shapes.push_str(&format!( r#" "# )); shape_id += 1; } else if let Some(ref src) = element.content.src { shapes.push_str(&format!( r#" "#, escape_xml(src) )); shape_id += 1; } } let bg_fill = if slide.background.bg_type == "solid" { let color_hex = slide.background.color.as_ref() .map(|c| c.trim_start_matches('#').to_uppercase()) .unwrap_or_else(|| "FFFFFF".to_string()); format!(r#""#) } else { String::new() }; format!( r#" {bg_fill}{shapes} "# ) } fn generate_slide_rels_xml() -> String { r#" "#.to_string() } fn generate_slide_layout_xml() -> String { r#" "#.to_string() } fn generate_slide_layout_rels_xml() -> String { r#" "#.to_string() } fn generate_slide_master_xml() -> String { r#" "#.to_string() } fn generate_slide_master_rels_xml() -> String { r#" "#.to_string() } fn generate_theme_xml(presentation: &Presentation) -> String { let accent1 = presentation .theme .colors .accent .trim_start_matches('#') .to_uppercase(); format!( r#" "# ) } fn generate_app_xml(presentation: &Presentation) -> String { let slide_count = presentation.slides.len(); format!( r#" General Bots Suite {slide_count} General Bots "# ) } fn generate_core_xml(presentation: &Presentation) -> String { let title = escape_xml(&presentation.name); let created = presentation.created_at.to_rfc3339(); let modified = presentation.updated_at.to_rfc3339(); format!( r#" {title} {} {created} {modified} "#, escape_xml(&presentation.owner_id) ) } fn escape_xml(text: &str) -> String { text.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } pub async fn load_pptx_from_drive( state: &Arc, user_id: &str, file_path: &str, ) -> Result { let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let result = drive .get_object() .bucket("gbo") .key(file_path) .send() .await .map_err(|e| format!("Failed to load PPTX: {e}"))?; let bytes = result .body .collect() .await .map_err(|e| format!("Failed to read PPTX: {e}"))? .into_bytes() .to_vec(); load_pptx_from_bytes(&bytes, user_id, file_path).await } pub async fn load_pptx_from_bytes( bytes: &[u8], user_id: &str, file_path: &str, ) -> Result { let cursor = Cursor::new(bytes); let mut archive = ZipArchive::new(cursor) .map_err(|e| format!("Failed to open PPTX archive: {e}"))?; let raw_name = file_path .split('/') .last() .unwrap_or("Untitled"); let file_name = raw_name .strip_suffix(".pptx") .or_else(|| raw_name.strip_suffix(".ppt")) .unwrap_or(raw_name); let pres_id = generate_presentation_id(); cache_presentation_bytes(&pres_id, bytes.to_vec()).await; let mut slides = Vec::new(); let mut slide_num = 1; loop { let slide_path = format!("ppt/slides/slide{slide_num}.xml"); match archive.by_name(&slide_path) { Ok(mut file) => { let mut content = String::new(); if file.read_to_string(&mut content).is_ok() { let slide = parse_slide_xml(&content, slide_num); slides.push(slide); } slide_num += 1; } Err(_) => break, } } if slides.is_empty() { slides.push(create_title_slide(&create_default_theme())); } Ok(Presentation { id: pres_id, name: file_name.to_string(), owner_id: user_id.to_string(), slides, theme: create_default_theme(), created_at: Utc::now(), updated_at: Utc::now(), }) } fn parse_slide_xml(xml_content: &str, slide_num: usize) -> Slide { let mut elements = Vec::new(); let mut element_id = 1; let mut in_sp = false; let mut current_text = String::new(); let mut x: f64 = 100.0; let mut y: f64 = 100.0; let mut cx: f64 = 200.0; let mut cy: f64 = 50.0; for line in xml_content.lines() { if line.contains("") || line.contains("() { x = val / 9144.0; } } } if let Some(start) = line.find("y=\"") { if let Some(end) = line[start + 3..].find('"') { if let Ok(val) = line[start + 3..start + 3 + end].parse::() { y = val / 9144.0; } } } if let Some(start) = line.find("cx=\"") { if let Some(end) = line[start + 4..].find('"') { if let Ok(val) = line[start + 4..start + 4 + end].parse::() { cx = val / 9144.0; } } } if let Some(start) = line.find("cy=\"") { if let Some(end) = line[start + 4..].find('"') { if let Ok(val) = line[start + 4..start + 4 + end].parse::() { cy = val / 9144.0; } } } if let Some(start) = line.find("") { if let Some(end) = line.find("") { let text = &line[start + 5..end]; current_text.push_str(text); } } } if line.contains("") && in_sp { in_sp = false; if !current_text.is_empty() { elements.push(SlideElement { id: format!("elem_{slide_num}_{element_id}"), element_type: "text".to_string(), x, y, width: cx.max(100.0), height: cy.max(30.0), rotation: 0.0, z_index: element_id as i32, locked: false, content: ElementContent { text: Some(current_text.clone()), html: None, src: None, shape_type: None, chart_data: None, table_data: None, }, style: ElementStyle { font_family: Some("Calibri".to_string()), font_size: Some(18.0), font_weight: None, font_style: None, color: Some("#000000".to_string()), fill: None, stroke: None, stroke_width: None, opacity: Some(1.0), shadow: None, border_radius: None, text_align: None, vertical_align: None, line_height: None, }, animations: Vec::new(), }); element_id += 1; } current_text.clear(); } } Slide { id: format!("slide_{slide_num}"), layout: "blank".to_string(), elements, background: SlideBackground { bg_type: "solid".to_string(), color: Some("#FFFFFF".to_string()), gradient: None, image_url: None, image_fit: None, }, notes: None, transition: None, transition_config: None, media: None, } } pub async fn load_presentation_from_drive( state: &Arc, user_id: &str, presentation_id: &Option, ) -> Result { let presentation_id = presentation_id .as_ref() .ok_or_else(|| "Presentation ID is required".to_string())?; load_presentation_by_id(state, user_id, presentation_id).await } pub async fn load_presentation_by_id( state: &Arc, user_id: &str, presentation_id: &str, ) -> Result { let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let path = format!( "{}/{}.json", get_user_presentations_path(user_id), presentation_id ); let result = drive .get_object() .bucket("gbo") .key(&path) .send() .await .map_err(|e| format!("Failed to load presentation: {e}"))?; let bytes = result .body .collect() .await .map_err(|e| format!("Failed to read presentation: {e}"))? .into_bytes(); let presentation: Presentation = serde_json::from_slice(&bytes).map_err(|e| format!("Failed to parse presentation: {e}"))?; Ok(presentation) } pub async fn list_presentations_from_drive( state: &Arc, user_id: &str, ) -> Result, String> { let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let prefix = format!("{}/", get_user_presentations_path(user_id)); let result = drive .list_objects_v2() .bucket("gbo") .prefix(&prefix) .send() .await .map_err(|e| format!("Failed to list presentations: {e}"))?; let mut presentations = Vec::new(); for obj in &result.contents { let key = &obj.key; if key.ends_with(".json") { let id = extract_id_from_path(key); if let Ok(presentation) = load_presentation_by_id(state, user_id, &id).await { presentations.push(PresentationMetadata { id: presentation.id, name: presentation.name, owner_id: presentation.owner_id, slide_count: presentation.slides.len(), created_at: presentation.created_at, updated_at: presentation.updated_at, }); } } } presentations.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); Ok(presentations) } pub async fn delete_presentation_from_drive( state: &Arc, user_id: &str, presentation_id: &Option, ) -> Result<(), String> { let presentation_id = presentation_id .as_ref() .ok_or_else(|| "Presentation ID is required".to_string())?; let drive = state .drive .as_ref() .ok_or_else(|| "Drive not available".to_string())?; let json_path = format!( "{}/{}.json", get_user_presentations_path(user_id), presentation_id ); let pptx_path = format!( "{}/{}.pptx", get_user_presentations_path(user_id), presentation_id ); let _ = drive .delete_object() .bucket("gbo") .key(&json_path) .send() .await; let _ = drive .delete_object() .bucket("gbo") .key(&pptx_path) .send() .await; Ok(()) } pub fn create_new_presentation() -> Presentation { let theme = create_default_theme(); let id = generate_presentation_id(); Presentation { id, name: "Untitled Presentation".to_string(), owner_id: get_current_user_id(), slides: vec![create_title_slide(&theme)], theme, created_at: Utc::now(), updated_at: Utc::now(), } } pub fn create_slide_with_layout(layout: &str, theme: &crate::slides::types::PresentationTheme) -> Slide { match layout { "title" => create_title_slide(theme), "content" => create_content_slide(theme), "blank" => crate::slides::utils::create_blank_slide(theme), _ => create_content_slide(theme), } }