From 840c7789f39bf9d1331e4ef9e8ba44b179835beb Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Sun, 11 Jan 2026 12:13:10 -0300 Subject: [PATCH] feat(office): Add Phase 3 advanced features - Comments, Track Changes, TOC, Footnotes, Styles, Presenter View, Transitions, Media --- src/docs/handlers.rs | 1133 ++++++++++++++++++++++++++++++++++++++++ src/docs/mod.rs | 49 +- src/docs/types.rs | 338 ++++++++++++ src/sheet/handlers.rs | 853 +++++++++++++++++++++++++++++- src/sheet/mod.rs | 40 +- src/sheet/types.rs | 293 +++++++++++ src/slides/handlers.rs | 494 +++++++++++++++++- src/slides/mod.rs | 40 +- src/slides/types.rs | 224 ++++++++ 9 files changed, 3428 insertions(+), 36 deletions(-) diff --git a/src/docs/handlers.rs b/src/docs/handlers.rs index 5403e1102..35cf7935e 100644 --- a/src/docs/handlers.rs +++ b/src/docs/handlers.rs @@ -7,6 +7,17 @@ use crate::docs::types::{ SearchQuery, TemplateResponse, }; use crate::docs::utils::{html_to_markdown, strip_html}; +use crate::docs::types::{ + AcceptRejectAllRequest, AcceptRejectChangeRequest, AddCommentRequest, AddEndnoteRequest, + AddFootnoteRequest, ApplyStyleRequest, CompareDocumentsRequest, CompareDocumentsResponse, + CommentReply, ComparisonSummary, CreateStyleRequest, DeleteCommentRequest, DeleteEndnoteRequest, + DeleteFootnoteRequest, DeleteStyleRequest, DocumentComment, DocumentComparison, DocumentDiff, + DocumentStyle, EnableTrackChangesRequest, Endnote, Footnote, GenerateTocRequest, + GetOutlineRequest, ListCommentsResponse, ListEndnotesResponse, ListFootnotesResponse, + ListStylesResponse, ListTrackChangesResponse, OutlineItem, OutlineResponse, ReplyCommentRequest, + ResolveCommentRequest, TableOfContents, TocEntry, TocResponse, TrackChange, UpdateEndnoteRequest, + UpdateFootnoteRequest, UpdateStyleRequest, UpdateTocRequest, +}; use crate::shared::state::AppState; use axum::{ extract::{Path, Query, State}, @@ -551,3 +562,1125 @@ pub async fn handle_export_txt( Ok(([(axum::http::header::CONTENT_TYPE, "text/plain")], plain_text)) } + +pub async fn handle_add_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let comment = DocumentComment { + id: uuid::Uuid::new_v4().to_string(), + author_id: user_id.clone(), + author_name: "User".to_string(), + content: req.content, + position: req.position, + length: req.length, + created_at: Utc::now(), + updated_at: Utc::now(), + replies: vec![], + resolved: false, + }; + + let comments = doc.comments.get_or_insert_with(Vec::new); + comments.push(comment.clone()); + doc.updated_at = Utc::now(); + + 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(serde_json::json!({ "success": true, "comment": comment }))) +} + +pub async fn handle_reply_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(comments) = &mut doc.comments { + for comment in comments.iter_mut() { + if comment.id == req.comment_id { + let reply = CommentReply { + id: uuid::Uuid::new_v4().to_string(), + author_id: user_id.clone(), + author_name: "User".to_string(), + content: req.content.clone(), + created_at: Utc::now(), + }; + comment.replies.push(reply); + comment.updated_at = Utc::now(); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_resolve_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(comments) = &mut doc.comments { + for comment in comments.iter_mut() { + if comment.id == req.comment_id { + comment.resolved = req.resolved; + comment.updated_at = Utc::now(); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_delete_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(comments) = &mut doc.comments { + comments.retain(|c| c.id != req.comment_id); + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_comments( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let doc_id = params.get("doc_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let comments = doc.comments.unwrap_or_default(); + Ok(Json(ListCommentsResponse { comments })) +} + +pub async fn handle_enable_track_changes( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + doc.track_changes_enabled = req.enabled; + doc.updated_at = Utc::now(); + + 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(serde_json::json!({ "success": true, "enabled": req.enabled }))) +} + +pub async fn handle_accept_reject_change( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(changes) = &mut doc.track_changes { + for change in changes.iter_mut() { + if change.id == req.change_id { + change.accepted = Some(req.accept); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_accept_reject_all( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(changes) = &mut doc.track_changes { + for change in changes.iter_mut() { + change.accepted = Some(req.accept); + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_track_changes( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let doc_id = params.get("doc_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let changes = doc.track_changes.unwrap_or_default(); + Ok(Json(ListTrackChangesResponse { + changes, + enabled: doc.track_changes_enabled, + })) +} + +pub async fn handle_generate_toc( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let mut entries = Vec::new(); + let content = &doc.content; + let mut position = 0; + + for level in 1..=req.max_level { + let tag = format!(""); + let end_tag = format!(""); + let mut search_pos = 0; + + while let Some(start) = content[search_pos..].find(&tag) { + let abs_start = search_pos + start; + if let Some(end) = content[abs_start..].find(&end_tag) { + let text_start = abs_start + tag.len(); + let text_end = abs_start + end; + let text = strip_html(&content[text_start..text_end]); + + entries.push(TocEntry { + id: uuid::Uuid::new_v4().to_string(), + text, + level, + page_number: None, + position: abs_start, + }); + search_pos = text_end + end_tag.len(); + } else { + break; + } + } + position = search_pos; + } + + entries.sort_by_key(|e| e.position); + + let toc = TableOfContents { + id: uuid::Uuid::new_v4().to_string(), + title: "Table of Contents".to_string(), + entries, + max_level: req.max_level, + show_page_numbers: req.show_page_numbers, + use_hyperlinks: req.use_hyperlinks, + }; + + doc.toc = Some(toc.clone()); + doc.updated_at = Utc::now(); + + 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(TocResponse { toc })) +} + +pub async fn handle_update_toc( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let existing_toc = doc.toc.unwrap_or_else(|| TableOfContents { + id: uuid::Uuid::new_v4().to_string(), + title: "Table of Contents".to_string(), + entries: vec![], + max_level: 3, + show_page_numbers: true, + use_hyperlinks: true, + }); + + let gen_req = GenerateTocRequest { + doc_id: req.doc_id, + max_level: existing_toc.max_level, + show_page_numbers: existing_toc.show_page_numbers, + use_hyperlinks: existing_toc.use_hyperlinks, + }; + + handle_generate_toc(State(state), Json(gen_req)).await +} + +pub async fn handle_add_footnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let footnotes = doc.footnotes.get_or_insert_with(Vec::new); + let reference_mark = format!("{}", footnotes.len() + 1); + + let footnote = Footnote { + id: uuid::Uuid::new_v4().to_string(), + reference_mark, + content: req.content, + position: req.position, + }; + + footnotes.push(footnote.clone()); + doc.updated_at = Utc::now(); + + 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(serde_json::json!({ "success": true, "footnote": footnote }))) +} + +pub async fn handle_update_footnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(footnotes) = &mut doc.footnotes { + for footnote in footnotes.iter_mut() { + if footnote.id == req.footnote_id { + footnote.content = req.content.clone(); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_delete_footnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(footnotes) = &mut doc.footnotes { + footnotes.retain(|f| f.id != req.footnote_id); + for (i, footnote) in footnotes.iter_mut().enumerate() { + footnote.reference_mark = format!("{}", i + 1); + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_footnotes( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let doc_id = params.get("doc_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let footnotes = doc.footnotes.unwrap_or_default(); + Ok(Json(ListFootnotesResponse { footnotes })) +} + +pub async fn handle_add_endnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let endnotes = doc.endnotes.get_or_insert_with(Vec::new); + let reference_mark = to_roman_numeral(endnotes.len() + 1); + + let endnote = Endnote { + id: uuid::Uuid::new_v4().to_string(), + reference_mark, + content: req.content, + position: req.position, + }; + + endnotes.push(endnote.clone()); + doc.updated_at = Utc::now(); + + 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(serde_json::json!({ "success": true, "endnote": endnote }))) +} + +fn to_roman_numeral(num: usize) -> String { + let numerals = [ + (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), + (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), + (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), + ]; + let mut result = String::new(); + let mut n = num; + for (value, numeral) in numerals { + while n >= value { + result.push_str(numeral); + n -= value; + } + } + result +} + +pub async fn handle_update_endnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(endnotes) = &mut doc.endnotes { + for endnote in endnotes.iter_mut() { + if endnote.id == req.endnote_id { + endnote.content = req.content.clone(); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_delete_endnote( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(endnotes) = &mut doc.endnotes { + endnotes.retain(|e| e.id != req.endnote_id); + for (i, endnote) in endnotes.iter_mut().enumerate() { + endnote.reference_mark = to_roman_numeral(i + 1); + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_endnotes( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let doc_id = params.get("doc_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let endnotes = doc.endnotes.unwrap_or_default(); + Ok(Json(ListEndnotesResponse { endnotes })) +} + +pub async fn handle_create_style( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let styles = doc.styles.get_or_insert_with(Vec::new); + styles.push(req.style.clone()); + doc.updated_at = Utc::now(); + + 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(serde_json::json!({ "success": true, "style": req.style }))) +} + +pub async fn handle_update_style( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(styles) = &mut doc.styles { + for style in styles.iter_mut() { + if style.id == req.style.id { + *style = req.style.clone(); + break; + } + } + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_delete_style( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(styles) = &mut doc.styles { + styles.retain(|s| s.id != req.style_id); + } + + doc.updated_at = Utc::now(); + 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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_styles( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let doc_id = params.get("doc_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let styles = doc.styles.unwrap_or_default(); + Ok(Json(ListStylesResponse { styles })) +} + +pub async fn handle_apply_style( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let style = doc.styles + .as_ref() + .and_then(|styles| styles.iter().find(|s| s.id == req.style_id)) + .cloned(); + + if style.is_none() { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Style not found" })), + )); + } + + Ok(Json(serde_json::json!({ + "success": true, + "style": style, + "position": req.position, + "length": req.length + }))) +} + +pub async fn handle_get_outline( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let doc = match load_document_from_drive(&state, &user_id, &req.doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let mut items = Vec::new(); + let content = &doc.content; + + for level in 1..=6u32 { + let tag = format!(""); + let end_tag = format!(""); + let mut search_pos = 0; + + while let Some(start) = content[search_pos..].find(&tag) { + let abs_start = search_pos + start; + if let Some(end) = content[abs_start..].find(&end_tag) { + let text_start = abs_start + tag.len(); + let text_end = abs_start + end; + let text = strip_html(&content[text_start..text_end]); + let length = text_end - text_start; + + items.push(OutlineItem { + id: uuid::Uuid::new_v4().to_string(), + text, + level, + position: abs_start, + length, + style_name: format!("Heading {level}"), + }); + search_pos = text_end + end_tag.len(); + } else { + break; + } + } + } + + items.sort_by_key(|i| i.position); + + Ok(Json(OutlineResponse { items })) +} + +pub async fn handle_compare_documents( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + + let original = match load_document_from_drive(&state, &user_id, &req.original_doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Original document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let modified = match load_document_from_drive(&state, &user_id, &req.modified_doc_id).await { + Ok(Some(d)) => d, + Ok(None) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Modified document not found" })), + )) + } + Err(e) => { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let original_text = strip_html(&original.content); + let modified_text = strip_html(&modified.content); + + let mut differences = Vec::new(); + let mut insertions = 0u32; + let mut deletions = 0u32; + let mut modifications = 0u32; + + let original_words: Vec<&str> = original_text.split_whitespace().collect(); + let modified_words: Vec<&str> = modified_text.split_whitespace().collect(); + + let mut i = 0; + let mut j = 0; + let mut position = 0; + + while i < original_words.len() || j < modified_words.len() { + if i >= original_words.len() { + differences.push(DocumentDiff { + diff_type: "insertion".to_string(), + position, + original_text: None, + modified_text: Some(modified_words[j].to_string()), + length: modified_words[j].len(), + }); + insertions += 1; + j += 1; + } else if j >= modified_words.len() { + differences.push(DocumentDiff { + diff_type: "deletion".to_string(), + position, + original_text: Some(original_words[i].to_string()), + modified_text: None, + length: original_words[i].len(), + }); + deletions += 1; + i += 1; + } else if original_words[i] == modified_words[j] { + position += original_words[i].len() + 1; + i += 1; + j += 1; + } else { + differences.push(DocumentDiff { + diff_type: "modification".to_string(), + position, + original_text: Some(original_words[i].to_string()), + modified_text: Some(modified_words[j].to_string()), + length: original_words[i].len().max(modified_words[j].len()), + }); + modifications += 1; + position += modified_words[j].len() + 1; + i += 1; + j += 1; + } + } + + let comparison = DocumentComparison { + id: uuid::Uuid::new_v4().to_string(), + original_doc_id: req.original_doc_id, + modified_doc_id: req.modified_doc_id, + created_at: Utc::now(), + differences, + summary: ComparisonSummary { + insertions, + deletions, + modifications, + total_changes: insertions + deletions + modifications, + }, + }; + + Ok(Json(CompareDocumentsResponse { comparison })) +} diff --git a/src/docs/mod.rs b/src/docs/mod.rs index 350590440..a8fc61865 100644 --- a/src/docs/mod.rs +++ b/src/docs/mod.rs @@ -14,16 +14,25 @@ use std::sync::Arc; pub use collaboration::handle_docs_websocket; pub use handlers::{ - handle_ai_custom, handle_ai_expand, handle_ai_improve, handle_ai_simplify, handle_ai_summarize, - handle_ai_translate, handle_autosave, handle_delete_document, handle_docs_ai, handle_docs_get_by_id, - handle_docs_save, handle_export_docx, handle_export_html, handle_export_md, handle_export_pdf, - handle_export_txt, handle_get_document, handle_list_documents, handle_new_document, + handle_accept_reject_all, handle_accept_reject_change, handle_add_comment, handle_add_endnote, + handle_add_footnote, handle_ai_custom, handle_ai_expand, handle_ai_improve, handle_ai_simplify, + handle_ai_summarize, handle_ai_translate, handle_apply_style, handle_autosave, + handle_compare_documents, handle_create_style, handle_delete_comment, handle_delete_document, + 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_template_meeting, handle_template_report, handle_update_endnote, handle_update_footnote, + handle_update_style, handle_update_toc, }; pub use types::{ - AiRequest, AiResponse, Collaborator, CollabMessage, Document, DocumentMetadata, SaveRequest, - SaveResponse, SearchQuery, + AiRequest, AiResponse, Collaborator, CollabMessage, CommentReply, ComparisonSummary, Document, + DocumentComment, DocumentComparison, DocumentDiff, DocumentMetadata, DocumentStyle, Endnote, + Footnote, OutlineItem, SaveRequest, SaveResponse, SearchQuery, TableOfContents, TocEntry, + TrackChange, }; pub fn configure_docs_routes() -> Router> { @@ -52,5 +61,31 @@ 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/comment", post(handle_add_comment)) + .route("/api/docs/comment/reply", post(handle_reply_comment)) + .route("/api/docs/comment/resolve", post(handle_resolve_comment)) + .route("/api/docs/comment/delete", post(handle_delete_comment)) + .route("/api/docs/comments", get(handle_list_comments)) + .route("/api/docs/track-changes/enable", post(handle_enable_track_changes)) + .route("/api/docs/track-changes/accept-reject", post(handle_accept_reject_change)) + .route("/api/docs/track-changes/accept-reject-all", post(handle_accept_reject_all)) + .route("/api/docs/track-changes", get(handle_list_track_changes)) + .route("/api/docs/toc/generate", post(handle_generate_toc)) + .route("/api/docs/toc/update", post(handle_update_toc)) + .route("/api/docs/footnote", post(handle_add_footnote)) + .route("/api/docs/footnote/update", post(handle_update_footnote)) + .route("/api/docs/footnote/delete", post(handle_delete_footnote)) + .route("/api/docs/footnotes", get(handle_list_footnotes)) + .route("/api/docs/endnote", post(handle_add_endnote)) + .route("/api/docs/endnote/update", post(handle_update_endnote)) + .route("/api/docs/endnote/delete", post(handle_delete_endnote)) + .route("/api/docs/endnotes", get(handle_list_endnotes)) + .route("/api/docs/style", post(handle_create_style)) + .route("/api/docs/style/update", post(handle_update_style)) + .route("/api/docs/style/delete", post(handle_delete_style)) + .route("/api/docs/style/apply", post(handle_apply_style)) + .route("/api/docs/styles", get(handle_list_styles)) + .route("/api/docs/outline", post(handle_get_outline)) + .route("/api/docs/compare", post(handle_compare_documents)) .route("/ws/docs/:doc_id", get(handle_docs_websocket)) } diff --git a/src/docs/types.rs b/src/docs/types.rs index 580d5baa7..920340b3a 100644 --- a/src/docs/types.rs +++ b/src/docs/types.rs @@ -1,6 +1,152 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TrackChange { + pub id: String, + pub change_type: String, + pub author_id: String, + pub author_name: String, + pub timestamp: DateTime, + pub original_text: Option, + pub new_text: Option, + pub position: usize, + pub length: usize, + pub accepted: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentComment { + pub id: String, + pub author_id: String, + pub author_name: String, + pub content: String, + pub position: usize, + pub length: usize, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub replies: Vec, + #[serde(default)] + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentReply { + pub id: String, + pub author_id: String, + pub author_name: String, + pub content: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TableOfContents { + pub id: String, + pub title: String, + pub entries: Vec, + pub max_level: u32, + pub show_page_numbers: bool, + pub use_hyperlinks: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TocEntry { + pub id: String, + pub text: String, + pub level: u32, + pub page_number: Option, + pub position: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Footnote { + pub id: String, + pub reference_mark: String, + pub content: String, + pub position: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Endnote { + pub id: String, + pub reference_mark: String, + pub content: String, + pub position: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentStyle { + pub id: String, + pub name: String, + pub style_type: String, + pub based_on: Option, + pub next_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_family: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_weight: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub font_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line_spacing: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub text_align: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_left: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_right: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub indent_first_line: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutlineItem { + pub id: String, + pub text: String, + pub level: u32, + pub position: usize, + pub length: usize, + pub style_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentComparison { + pub id: String, + pub original_doc_id: String, + pub modified_doc_id: String, + pub created_at: DateTime, + pub differences: Vec, + pub summary: ComparisonSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DocumentDiff { + pub diff_type: String, + pub position: usize, + pub original_text: Option, + pub modified_text: Option, + pub length: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComparisonSummary { + pub insertions: u32, + pub deletions: u32, + pub modifications: u32, + pub total_changes: u32, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollabMessage { pub msg_type: String, @@ -42,6 +188,20 @@ pub struct Document { pub collaborators: Vec, #[serde(default)] pub version: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub track_changes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub comments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub footnotes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub endnotes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub styles: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub toc: Option, + #[serde(default)] + pub track_changes_enabled: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -159,3 +319,181 @@ pub struct TemplateResponse { pub title: String, pub content: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddCommentRequest { + pub doc_id: String, + pub content: String, + pub position: usize, + pub length: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplyCommentRequest { + pub doc_id: String, + pub comment_id: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveCommentRequest { + pub doc_id: String, + pub comment_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteCommentRequest { + pub doc_id: String, + pub comment_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCommentsResponse { + pub comments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnableTrackChangesRequest { + pub doc_id: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptRejectChangeRequest { + pub doc_id: String, + pub change_id: String, + pub accept: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcceptRejectAllRequest { + pub doc_id: String, + pub accept: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListTrackChangesResponse { + pub changes: Vec, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GenerateTocRequest { + pub doc_id: String, + pub max_level: u32, + pub show_page_numbers: bool, + pub use_hyperlinks: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateTocRequest { + pub doc_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TocResponse { + pub toc: TableOfContents, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddFootnoteRequest { + pub doc_id: String, + pub content: String, + pub position: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateFootnoteRequest { + pub doc_id: String, + pub footnote_id: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteFootnoteRequest { + pub doc_id: String, + pub footnote_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListFootnotesResponse { + pub footnotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddEndnoteRequest { + pub doc_id: String, + pub content: String, + pub position: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateEndnoteRequest { + pub doc_id: String, + pub endnote_id: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteEndnoteRequest { + pub doc_id: String, + pub endnote_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListEndnotesResponse { + pub endnotes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateStyleRequest { + pub doc_id: String, + pub style: DocumentStyle, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateStyleRequest { + pub doc_id: String, + pub style: DocumentStyle, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteStyleRequest { + pub doc_id: String, + pub style_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListStylesResponse { + pub styles: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplyStyleRequest { + pub doc_id: String, + pub style_id: String, + pub position: usize, + pub length: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetOutlineRequest { + pub doc_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OutlineResponse { + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareDocumentsRequest { + pub original_doc_id: String, + pub modified_doc_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompareDocumentsResponse { + pub comparison: DocumentComparison, +} diff --git a/src/sheet/handlers.rs b/src/sheet/handlers.rs index 07d6504da..f7fd7ca7b 100644 --- a/src/sheet/handlers.rs +++ b/src/sheet/handlers.rs @@ -8,13 +8,19 @@ use crate::sheet::storage::{ save_sheet_to_drive, }; use crate::sheet::types::{ - AddNoteRequest, CellData, CellUpdateRequest, ChartConfig, ChartOptions, - ChartPosition, ChartRequest, ClearFilterRequest, ConditionalFormatRequest, - ConditionalFormatRule, DataValidationRequest, DeleteChartRequest, ExportRequest, FilterConfig, - FilterRequest, FormatRequest, FormulaRequest, FormulaResult, FreezePanesRequest, - LoadFromDriveRequest, LoadQuery, MergeCellsRequest, MergedCell, SaveRequest, SaveResponse, - SearchQuery, ShareRequest, SheetAiRequest, SheetAiResponse, SortRequest, Spreadsheet, - SpreadsheetMetadata, ValidateCellRequest, ValidationResult, ValidationRule, Worksheet, + AddCommentRequest, AddExternalLinkRequest, AddNoteRequest, ArrayFormula, ArrayFormulaRequest, + CellComment, CellData, CellUpdateRequest, ChartConfig, ChartOptions, ChartPosition, + ChartRequest, ClearFilterRequest, CommentReply, CommentWithLocation, ConditionalFormatRequest, + ConditionalFormatRule, CreateNamedRangeRequest, DataValidationRequest, DeleteArrayFormulaRequest, + DeleteChartRequest, DeleteCommentRequest, DeleteNamedRangeRequest, ExportRequest, ExternalLink, + FilterConfig, FilterRequest, FormatRequest, FormulaRequest, FormulaResult, FreezePanesRequest, + ListCommentsRequest, ListCommentsResponse, ListExternalLinksResponse, ListNamedRangesRequest, + ListNamedRangesResponse, LoadFromDriveRequest, LoadQuery, LockCellsRequest, MergeCellsRequest, + MergedCell, NamedRange, ProtectSheetRequest, RefreshExternalLinkRequest, RemoveExternalLinkRequest, + ReplyCommentRequest, ResolveCommentRequest, SaveRequest, SaveResponse, SearchQuery, ShareRequest, + SheetAiRequest, SheetAiResponse, SheetProtection, SortRequest, Spreadsheet, SpreadsheetMetadata, + UnprotectSheetRequest, UpdateNamedRangeRequest, ValidateCellRequest, ValidationResult, + ValidationRule, Worksheet, }; use axum::{ extract::{Path, Query, State}, @@ -1157,3 +1163,836 @@ pub async fn handle_import_sheet( ) -> Result, (StatusCode, Json)> { Ok(Json(create_new_spreadsheet())) } + +pub async fn handle_add_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + let key = format!("{},{}", req.row, req.col); + + let comment = CellComment { + id: Uuid::new_v4().to_string(), + author_id: user_id.clone(), + author_name: "User".to_string(), + content: req.content, + created_at: Utc::now(), + updated_at: Utc::now(), + replies: vec![], + resolved: false, + }; + + let comments = worksheet.comments.get_or_insert_with(HashMap::new); + comments.insert(key.clone(), comment); + + let cell = worksheet.data.entry(key).or_insert_with(|| CellData { + value: None, + formula: None, + style: None, + format: None, + note: None, + locked: None, + has_comment: None, + array_formula_id: None, + }); + cell.has_comment = Some(true); + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Comment added".to_string()), + })) +} + +pub async fn handle_reply_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + let key = format!("{},{}", req.row, req.col); + + if let Some(comments) = &mut worksheet.comments { + if let Some(comment) = comments.get_mut(&key) { + if comment.id == req.comment_id { + let reply = CommentReply { + id: Uuid::new_v4().to_string(), + author_id: user_id.clone(), + author_name: "User".to_string(), + content: req.content, + created_at: Utc::now(), + }; + comment.replies.push(reply); + comment.updated_at = Utc::now(); + } + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Reply added".to_string()), + })) +} + +pub async fn handle_resolve_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + let key = format!("{},{}", req.row, req.col); + + if let Some(comments) = &mut worksheet.comments { + if let Some(comment) = comments.get_mut(&key) { + if comment.id == req.comment_id { + comment.resolved = req.resolved; + comment.updated_at = Utc::now(); + } + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Comment resolved".to_string()), + })) +} + +pub async fn handle_delete_comment( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + let key = format!("{},{}", req.row, req.col); + + if let Some(comments) = &mut worksheet.comments { + comments.remove(&key); + } + + if let Some(cell) = worksheet.data.get_mut(&key) { + cell.has_comment = Some(false); + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Comment deleted".to_string()), + })) +} + +pub async fn handle_list_comments( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &sheet.worksheets[req.worksheet_index]; + let mut comments_list = vec![]; + + if let Some(comments) = &worksheet.comments { + for (key, comment) in comments { + let parts: Vec<&str> = key.split(',').collect(); + if parts.len() == 2 { + if let (Ok(row), Ok(col)) = (parts[0].parse::(), parts[1].parse::()) { + comments_list.push(CommentWithLocation { + row, + col, + comment: comment.clone(), + }); + } + } + } + } + + Ok(Json(ListCommentsResponse { comments: comments_list })) +} + +pub async fn handle_protect_sheet( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let mut protection = req.protection; + if let Some(password) = req.password { + protection.password_hash = Some(format!("{:x}", md5::compute(password.as_bytes()))); + } + + sheet.worksheets[req.worksheet_index].protection = Some(protection); + sheet.updated_at = Utc::now(); + + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Sheet protected".to_string()), + })) +} + +pub async fn handle_unprotect_sheet( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + if let Some(protection) = &worksheet.protection { + if let Some(hash) = &protection.password_hash { + if let Some(password) = &req.password { + let provided_hash = format!("{:x}", md5::compute(password.as_bytes())); + if &provided_hash != hash { + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "Invalid password" })), + )); + } + } else { + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "Password required" })), + )); + } + } + } + + worksheet.protection = None; + sheet.updated_at = Utc::now(); + + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Sheet unprotected".to_string()), + })) +} + +pub async fn handle_lock_cells( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + + for row in req.start_row..=req.end_row { + for col in req.start_col..=req.end_col { + let key = format!("{row},{col}"); + let cell = worksheet.data.entry(key).or_insert_with(|| CellData { + value: None, + formula: None, + style: None, + format: None, + note: None, + locked: None, + has_comment: None, + array_formula_id: None, + }); + cell.locked = Some(req.locked); + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some(if req.locked { "Cells locked" } else { "Cells unlocked" }.to_string()), + })) +} + +pub async fn handle_add_external_link( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let link = ExternalLink { + id: Uuid::new_v4().to_string(), + source_path: req.source_path, + link_type: req.link_type, + target_sheet: req.target_sheet, + target_range: req.target_range, + status: "active".to_string(), + last_updated: Utc::now(), + }; + + let links = sheet.external_links.get_or_insert_with(Vec::new); + links.push(link); + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("External link added".to_string()), + })) +} + +pub async fn handle_refresh_external_link( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(links) = &mut sheet.external_links { + for link in links.iter_mut() { + if link.id == req.link_id { + link.last_updated = Utc::now(); + link.status = "refreshed".to_string(); + } + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("External link refreshed".to_string()), + })) +} + +pub async fn handle_remove_external_link( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(links) = &mut sheet.external_links { + links.retain(|link| link.id != req.link_id); + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("External link removed".to_string()), + })) +} + +pub async fn handle_list_external_links( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let sheet_id = params.get("sheet_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let sheet = match load_sheet_by_id(&state, &user_id, &sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let links = sheet.external_links.unwrap_or_default(); + Ok(Json(ListExternalLinksResponse { links })) +} + +pub async fn handle_array_formula( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let array_formula_id = Uuid::new_v4().to_string(); + let array_formula = ArrayFormula { + id: array_formula_id.clone(), + formula: req.formula.clone(), + start_row: req.start_row, + start_col: req.start_col, + end_row: req.end_row, + end_col: req.end_col, + is_dynamic: req.formula.starts_with('=') && req.formula.contains('#'), + }; + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + let array_formulas = worksheet.array_formulas.get_or_insert_with(Vec::new); + array_formulas.push(array_formula); + + for row in req.start_row..=req.end_row { + for col in req.start_col..=req.end_col { + let key = format!("{row},{col}"); + let cell = worksheet.data.entry(key).or_insert_with(|| CellData { + value: None, + formula: None, + style: None, + format: None, + note: None, + locked: None, + has_comment: None, + array_formula_id: None, + }); + cell.array_formula_id = Some(array_formula_id.clone()); + if row == req.start_row && col == req.start_col { + cell.formula = Some(format!("{{{}}}", req.formula)); + } + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Array formula created".to_string()), + })) +} + +pub async fn handle_delete_array_formula( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.worksheet_index >= sheet.worksheets.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid worksheet index" })), + )); + } + + let worksheet = &mut sheet.worksheets[req.worksheet_index]; + + if let Some(array_formulas) = &mut worksheet.array_formulas { + array_formulas.retain(|af| af.id != req.array_formula_id); + } + + for cell in worksheet.data.values_mut() { + if cell.array_formula_id.as_ref() == Some(&req.array_formula_id) { + cell.array_formula_id = None; + cell.formula = None; + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Array formula deleted".to_string()), + })) +} + +pub async fn handle_create_named_range( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let named_range = NamedRange { + id: Uuid::new_v4().to_string(), + name: req.name, + scope: req.scope, + worksheet_index: req.worksheet_index, + start_row: req.start_row, + start_col: req.start_col, + end_row: req.end_row, + end_col: req.end_col, + comment: req.comment, + }; + + let named_ranges = sheet.named_ranges.get_or_insert_with(Vec::new); + named_ranges.push(named_range); + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Named range created".to_string()), + })) +} + +pub async fn handle_update_named_range( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(named_ranges) = &mut sheet.named_ranges { + for range in named_ranges.iter_mut() { + if range.id == req.range_id { + if let Some(name) = req.name { + range.name = name; + } + if let Some(start_row) = req.start_row { + range.start_row = start_row; + } + if let Some(start_col) = req.start_col { + range.start_col = start_col; + } + if let Some(end_row) = req.end_row { + range.end_row = end_row; + } + if let Some(end_col) = req.end_col { + range.end_col = end_col; + } + if let Some(comment) = req.comment { + range.comment = Some(comment); + } + } + } + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Named range updated".to_string()), + })) +} + +pub async fn handle_delete_named_range( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut sheet = match load_sheet_by_id(&state, &user_id, &req.sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if let Some(named_ranges) = &mut sheet.named_ranges { + named_ranges.retain(|r| r.id != req.range_id); + } + + sheet.updated_at = Utc::now(); + 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(SaveResponse { + id: req.sheet_id, + success: true, + message: Some("Named range deleted".to_string()), + })) +} + +pub async fn handle_list_named_ranges( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let sheet_id = params.get("sheet_id").cloned().unwrap_or_default(); + let user_id = get_current_user_id(); + let sheet = match load_sheet_by_id(&state, &user_id, &sheet_id).await { + Ok(s) => s, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let ranges = sheet.named_ranges.unwrap_or_default(); + Ok(Json(ListNamedRangesResponse { ranges })) +} diff --git a/src/sheet/mod.rs b/src/sheet/mod.rs index a6ba14bd2..9be488351 100644 --- a/src/sheet/mod.rs +++ b/src/sheet/mod.rs @@ -14,17 +14,23 @@ use std::sync::Arc; pub use collaboration::{handle_get_collaborators, handle_sheet_websocket}; pub use handlers::{ - handle_add_note, handle_clear_filter, handle_conditional_format, handle_create_chart, - handle_data_validation, handle_delete_chart, handle_delete_sheet, handle_evaluate_formula, - handle_export_sheet, handle_filter_data, handle_format_cells, handle_freeze_panes, - handle_get_sheet_by_id, handle_import_sheet, handle_list_sheets, handle_load_from_drive, - handle_load_sheet, handle_merge_cells, handle_new_sheet, handle_save_sheet, handle_search_sheets, - handle_share_sheet, handle_sheet_ai, handle_sort_range, handle_unmerge_cells, handle_update_cell, + handle_add_comment, handle_add_external_link, handle_add_note, handle_array_formula, + handle_clear_filter, handle_conditional_format, handle_create_chart, handle_create_named_range, + handle_data_validation, handle_delete_array_formula, handle_delete_chart, handle_delete_comment, + handle_delete_named_range, handle_delete_sheet, handle_evaluate_formula, handle_export_sheet, + handle_filter_data, handle_format_cells, handle_freeze_panes, handle_get_sheet_by_id, + handle_import_sheet, handle_list_comments, handle_list_external_links, handle_list_named_ranges, + handle_list_sheets, handle_load_from_drive, handle_load_sheet, handle_lock_cells, + handle_merge_cells, handle_new_sheet, handle_protect_sheet, handle_refresh_external_link, + handle_remove_external_link, handle_reply_comment, handle_resolve_comment, handle_save_sheet, + handle_search_sheets, handle_share_sheet, handle_sheet_ai, handle_sort_range, + handle_unmerge_cells, handle_unprotect_sheet, handle_update_cell, handle_update_named_range, handle_validate_cell, }; pub use types::{ - CellData, CellStyle, ChartConfig, ChartDataset, ChartOptions, ChartPosition, Collaborator, - CollabMessage, ConditionalFormatRule, FilterConfig, MergedCell, SaveResponse, Spreadsheet, + ArrayFormula, CellComment, CellData, CellStyle, ChartConfig, ChartDataset, ChartOptions, + ChartPosition, Collaborator, CollabMessage, CommentReply, ConditionalFormatRule, ExternalLink, + FilterConfig, MergedCell, NamedRange, SaveResponse, SheetProtection, Spreadsheet, SpreadsheetMetadata, ValidationRule, Worksheet, }; @@ -58,5 +64,23 @@ pub fn configure_sheet_routes() -> Router> { .route("/api/sheet/ai", post(handle_sheet_ai)) .route("/api/sheet/:id", get(handle_get_sheet_by_id)) .route("/api/sheet/:id/collaborators", get(handle_get_collaborators)) + .route("/api/sheet/comment", post(handle_add_comment)) + .route("/api/sheet/comment/reply", post(handle_reply_comment)) + .route("/api/sheet/comment/resolve", post(handle_resolve_comment)) + .route("/api/sheet/comment/delete", post(handle_delete_comment)) + .route("/api/sheet/comments", post(handle_list_comments)) + .route("/api/sheet/protect", post(handle_protect_sheet)) + .route("/api/sheet/unprotect", post(handle_unprotect_sheet)) + .route("/api/sheet/lock-cells", post(handle_lock_cells)) + .route("/api/sheet/external-link", post(handle_add_external_link)) + .route("/api/sheet/external-link/refresh", post(handle_refresh_external_link)) + .route("/api/sheet/external-link/remove", post(handle_remove_external_link)) + .route("/api/sheet/external-links", get(handle_list_external_links)) + .route("/api/sheet/array-formula", post(handle_array_formula)) + .route("/api/sheet/array-formula/delete", post(handle_delete_array_formula)) + .route("/api/sheet/named-range", post(handle_create_named_range)) + .route("/api/sheet/named-range/update", post(handle_update_named_range)) + .route("/api/sheet/named-range/delete", post(handle_delete_named_range)) + .route("/api/sheet/named-ranges", get(handle_list_named_ranges)) .route("/ws/sheet/:sheet_id", get(handle_sheet_websocket)) } diff --git a/src/sheet/types.rs b/src/sheet/types.rs index 9eaf575f1..2c8124ed3 100644 --- a/src/sheet/types.rs +++ b/src/sheet/types.rs @@ -2,6 +2,104 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CellComment { + pub id: String, + pub author_id: String, + pub author_name: String, + pub content: String, + pub created_at: DateTime, + pub updated_at: DateTime, + #[serde(default)] + pub replies: Vec, + #[serde(default)] + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentReply { + pub id: String, + pub author_id: String, + pub author_name: String, + pub content: String, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SheetProtection { + pub protected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_hash: Option, + #[serde(default)] + pub locked_cells: Vec, + #[serde(default)] + pub allow_select_locked: bool, + #[serde(default)] + pub allow_select_unlocked: bool, + #[serde(default)] + pub allow_format_cells: bool, + #[serde(default)] + pub allow_format_columns: bool, + #[serde(default)] + pub allow_format_rows: bool, + #[serde(default)] + pub allow_insert_columns: bool, + #[serde(default)] + pub allow_insert_rows: bool, + #[serde(default)] + pub allow_insert_hyperlinks: bool, + #[serde(default)] + pub allow_delete_columns: bool, + #[serde(default)] + pub allow_delete_rows: bool, + #[serde(default)] + pub allow_sort: bool, + #[serde(default)] + pub allow_filter: bool, + #[serde(default)] + pub allow_pivot_tables: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalLink { + pub id: String, + pub source_path: String, + pub link_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_sheet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_range: Option, + pub status: String, + pub last_updated: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayFormula { + pub id: String, + pub formula: String, + pub start_row: u32, + pub start_col: u32, + pub end_row: u32, + pub end_col: u32, + #[serde(default)] + pub is_dynamic: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamedRange { + pub id: String, + pub name: String, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worksheet_index: Option, + pub start_row: u32, + pub start_col: u32, + pub end_row: u32, + pub end_col: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollabMessage { pub msg_type: String, @@ -38,6 +136,10 @@ pub struct Spreadsheet { pub worksheets: Vec, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub named_ranges: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_links: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -64,6 +166,12 @@ pub struct Worksheet { pub conditional_formats: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub charts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub comments: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub protection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub array_formulas: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -78,6 +186,12 @@ pub struct CellData { pub format: Option, #[serde(skip_serializing_if = "Option::is_none")] pub note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub locked: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_comment: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub array_formula_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -423,6 +537,185 @@ pub struct AddNoteRequest { pub note: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddCommentRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub row: u32, + pub col: u32, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplyCommentRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub row: u32, + pub col: u32, + pub comment_id: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolveCommentRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub row: u32, + pub col: u32, + pub comment_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteCommentRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub row: u32, + pub col: u32, + pub comment_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtectSheetRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub protection: SheetProtection, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnprotectSheetRequest { + pub sheet_id: String, + pub worksheet_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockCellsRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub start_row: u32, + pub start_col: u32, + pub end_row: u32, + pub end_col: u32, + pub locked: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddExternalLinkRequest { + pub sheet_id: String, + pub source_path: String, + pub link_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_sheet: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_range: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RefreshExternalLinkRequest { + pub sheet_id: String, + pub link_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveExternalLinkRequest { + pub sheet_id: String, + pub link_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayFormulaRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub formula: String, + pub start_row: u32, + pub start_col: u32, + pub end_row: u32, + pub end_col: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteArrayFormulaRequest { + pub sheet_id: String, + pub worksheet_index: usize, + pub array_formula_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateNamedRangeRequest { + pub sheet_id: String, + pub name: String, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub worksheet_index: Option, + pub start_row: u32, + pub start_col: u32, + pub end_row: u32, + pub end_col: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateNamedRangeRequest { + pub sheet_id: String, + pub range_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_row: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_col: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_row: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_col: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteNamedRangeRequest { + pub sheet_id: String, + pub range_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListNamedRangesRequest { + pub sheet_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListNamedRangesResponse { + pub ranges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListExternalLinksResponse { + pub links: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCommentsRequest { + pub sheet_id: String, + pub worksheet_index: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCommentsResponse { + pub comments: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentWithLocation { + pub row: u32, + pub col: u32, + pub comment: CellComment, +} + #[derive(Debug, Deserialize)] pub struct SheetAiRequest { pub command: String, diff --git a/src/slides/handlers.rs b/src/slides/handlers.rs index da463870d..2a31bd6d9 100644 --- a/src/slides/handlers.rs +++ b/src/slides/handlers.rs @@ -6,10 +6,15 @@ use crate::slides::storage::{ load_presentation_from_drive, save_presentation_to_drive, }; use crate::slides::types::{ - AddElementRequest, AddSlideRequest, ApplyThemeRequest, DeleteElementRequest, - DeleteSlideRequest, DuplicateSlideRequest, ExportRequest, LoadQuery, Presentation, - PresentationMetadata, ReorderSlidesRequest, SavePresentationRequest, SaveResponse, SearchQuery, - SlidesAiRequest, SlidesAiResponse, UpdateElementRequest, UpdateSlideNotesRequest, + AddElementRequest, AddMediaRequest, AddSlideRequest, ApplyThemeRequest, + ApplyTransitionToAllRequest, CollaborationCursor, CollaborationSelection, DeleteElementRequest, + DeleteMediaRequest, DeleteSlideRequest, DuplicateSlideRequest, EndPresenterRequest, + ExportRequest, ListCursorsResponse, ListMediaResponse, ListSelectionsResponse, LoadQuery, + Presentation, PresentationMetadata, PresenterNotesResponse, PresenterSession, + PresenterSessionResponse, RemoveTransitionRequest, ReorderSlidesRequest, + SavePresentationRequest, SaveResponse, SearchQuery, SetTransitionRequest, SlidesAiRequest, + SlidesAiResponse, StartPresenterRequest, UpdateCursorRequest, UpdateElementRequest, + UpdateMediaRequest, UpdatePresenterRequest, UpdateSelectionRequest, UpdateSlideNotesRequest, }; use crate::slides::utils::export_to_html; use axum::{ @@ -20,7 +25,8 @@ use axum::{ }; use chrono::Utc; use log::error; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, RwLock}; use uuid::Uuid; pub async fn handle_slides_ai( @@ -623,3 +629,481 @@ pub async fn handle_export_presentation( )), } } + +static CURSORS: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +static SELECTIONS: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +static PRESENTER_SESSIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +pub async fn handle_update_cursor( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + + let cursor = CollaborationCursor { + user_id: user_id.clone(), + user_name: "User".to_string(), + user_color: "#4285f4".to_string(), + slide_index: req.slide_index, + element_id: req.element_id, + x: req.x, + y: req.y, + last_activity: Utc::now(), + }; + + if let Ok(mut cursors) = CURSORS.write() { + let presentation_cursors = cursors.entry(req.presentation_id.clone()).or_default(); + presentation_cursors.retain(|c| c.user_id != user_id); + presentation_cursors.push(cursor); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +pub async fn handle_update_selection( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + + let selection = CollaborationSelection { + user_id: user_id.clone(), + user_name: "User".to_string(), + user_color: "#4285f4".to_string(), + slide_index: req.slide_index, + element_ids: req.element_ids, + }; + + if let Ok(mut selections) = SELECTIONS.write() { + let presentation_selections = selections.entry(req.presentation_id.clone()).or_default(); + presentation_selections.retain(|s| s.user_id != user_id); + presentation_selections.push(selection); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_cursors( + State(_state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let presentation_id = params.get("presentation_id").cloned().unwrap_or_default(); + + let cursors = if let Ok(cursors_map) = CURSORS.read() { + cursors_map.get(&presentation_id).cloned().unwrap_or_default() + } else { + vec![] + }; + + Ok(Json(ListCursorsResponse { cursors })) +} + +pub async fn handle_list_selections( + State(_state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let presentation_id = params.get("presentation_id").cloned().unwrap_or_default(); + + let selections = if let Ok(selections_map) = SELECTIONS.read() { + selections_map.get(&presentation_id).cloned().unwrap_or_default() + } else { + vec![] + }; + + Ok(Json(ListSelectionsResponse { selections })) +} + +pub async fn handle_set_transition( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + presentation.slides[req.slide_index].transition_config = Some(req.transition); + presentation.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(SaveResponse { + id: req.presentation_id, + success: true, + message: Some("Transition set".to_string()), + })) +} + +pub async fn handle_apply_transition_to_all( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + for slide in presentation.slides.iter_mut() { + slide.transition_config = Some(req.transition.clone()); + } + presentation.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(SaveResponse { + id: req.presentation_id, + success: true, + message: Some("Transition applied to all slides".to_string()), + })) +} + +pub async fn handle_remove_transition( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + presentation.slides[req.slide_index].transition_config = None; + presentation.slides[req.slide_index].transition = None; + presentation.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(SaveResponse { + id: req.presentation_id, + success: true, + message: Some("Transition removed".to_string()), + })) +} + +pub async fn handle_add_media( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + let media_list = presentation.slides[req.slide_index].media.get_or_insert_with(Vec::new); + media_list.push(req.media.clone()); + presentation.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(serde_json::json!({ "success": true, "media": req.media }))) +} + +pub async fn handle_update_media( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + if let Some(media_list) = &mut presentation.slides[req.slide_index].media { + for media in media_list.iter_mut() { + if media.id == req.media_id { + if let Some(autoplay) = req.autoplay { + media.autoplay = autoplay; + } + if let Some(loop_playback) = req.loop_playback { + media.loop_playback = loop_playback; + } + if let Some(muted) = req.muted { + media.muted = muted; + } + if let Some(volume) = req.volume { + media.volume = Some(volume); + } + if let Some(start_time) = req.start_time { + media.start_time = Some(start_time); + } + if let Some(end_time) = req.end_time { + media.end_time = Some(end_time); + } + break; + } + } + } + + presentation.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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_delete_media( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + let mut presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if req.slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + if let Some(media_list) = &mut presentation.slides[req.slide_index].media { + media_list.retain(|m| m.id != req.media_id); + } + + presentation.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(serde_json::json!({ "success": true }))) +} + +pub async fn handle_list_media( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let presentation_id = params.get("presentation_id").cloned().unwrap_or_default(); + let slide_index: usize = params.get("slide_index").and_then(|s| s.parse().ok()).unwrap_or(0); + let user_id = get_current_user_id(); + + let presentation = match load_presentation_by_id(&state, &user_id, &presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + let media = presentation.slides[slide_index].media.clone().unwrap_or_default(); + Ok(Json(ListMediaResponse { media })) +} + +pub async fn handle_start_presenter( + State(state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let user_id = get_current_user_id(); + + let _presentation = match load_presentation_by_id(&state, &user_id, &req.presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + let session = PresenterSession { + id: uuid::Uuid::new_v4().to_string(), + presentation_id: req.presentation_id, + current_slide: 0, + started_at: Utc::now(), + elapsed_time: Some(0), + is_paused: false, + settings: req.settings, + }; + + if let Ok(mut sessions) = PRESENTER_SESSIONS.write() { + sessions.insert(session.id.clone(), session.clone()); + } + + Ok(Json(PresenterSessionResponse { session })) +} + +pub async fn handle_update_presenter( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + let session = if let Ok(mut sessions) = PRESENTER_SESSIONS.write() { + if let Some(session) = sessions.get_mut(&req.session_id) { + if let Some(current_slide) = req.current_slide { + session.current_slide = current_slide; + } + if let Some(is_paused) = req.is_paused { + session.is_paused = is_paused; + } + if let Some(settings) = req.settings { + session.settings = settings; + } + session.clone() + } else { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "Session not found" })), + )); + } + } else { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to access sessions" })), + )); + }; + + Ok(Json(PresenterSessionResponse { session })) +} + +pub async fn handle_end_presenter( + State(_state): State>, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if let Ok(mut sessions) = PRESENTER_SESSIONS.write() { + sessions.remove(&req.session_id); + } + + Ok(Json(serde_json::json!({ "success": true }))) +} + +pub async fn handle_get_presenter_notes( + State(state): State>, + Query(params): Query>, +) -> Result, (StatusCode, Json)> { + let presentation_id = params.get("presentation_id").cloned().unwrap_or_default(); + let slide_index: usize = params.get("slide_index").and_then(|s| s.parse().ok()).unwrap_or(0); + let user_id = get_current_user_id(); + + let presentation = match load_presentation_by_id(&state, &user_id, &presentation_id).await { + Ok(p) => p, + Err(e) => { + return Err(( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": e })), + )) + } + }; + + if slide_index >= presentation.slides.len() { + return Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "Invalid slide index" })), + )); + } + + let notes = presentation.slides[slide_index].notes.clone(); + let next_slide_notes = if slide_index + 1 < presentation.slides.len() { + presentation.slides[slide_index + 1].notes.clone() + } else { + None + }; + + Ok(Json(PresenterNotesResponse { + slide_index, + notes, + next_slide_notes, + next_slide_thumbnail: None, + })) +} diff --git a/src/slides/mod.rs b/src/slides/mod.rs index 38ba1f9e5..cf6e83002 100644 --- a/src/slides/mod.rs +++ b/src/slides/mod.rs @@ -14,18 +14,25 @@ use std::sync::Arc; pub use collaboration::{handle_get_collaborators, handle_slides_websocket}; pub use handlers::{ - handle_add_element, handle_add_slide, handle_apply_theme, handle_delete_element, + handle_add_element, handle_add_media, handle_add_slide, handle_apply_theme, + handle_apply_transition_to_all, handle_delete_element, handle_delete_media, handle_delete_presentation, handle_delete_slide, handle_duplicate_slide, - handle_export_presentation, handle_get_presentation_by_id, handle_list_presentations, - handle_load_presentation, handle_new_presentation, handle_reorder_slides, - handle_save_presentation, handle_search_presentations, handle_slides_ai, - handle_update_element, handle_update_slide_notes, + handle_end_presenter, handle_export_presentation, handle_get_presentation_by_id, + handle_get_presenter_notes, 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, + handle_slides_ai, handle_start_presenter, handle_update_cursor, handle_update_element, + handle_update_media, handle_update_presenter, handle_update_selection, + handle_update_slide_notes, }; pub use types::{ - Animation, ChartData, ChartDataset, Collaborator, ElementContent, ElementStyle, - GradientStop, GradientStyle, Presentation, PresentationMetadata, PresentationTheme, - SaveResponse, ShadowStyle, Slide, SlideBackground, SlideElement, SlideMessage, - SlideTransition, TableCell, TableData, ThemeColors, ThemeFonts, + Animation, ChartData, ChartDataset, Collaborator, CollaborationCursor, + CollaborationSelection, ElementContent, ElementStyle, GradientStop, GradientStyle, + MediaElement, Presentation, PresentationMetadata, PresentationTheme, PresenterSession, + PresenterViewSettings, SaveResponse, ShadowStyle, Slide, SlideBackground, SlideElement, + SlideMessage, SlideTransition, TableCell, TableData, ThemeColors, ThemeFonts, + TransitionConfig, TransitionSound, }; pub fn configure_slides_routes() -> Router> { @@ -49,5 +56,20 @@ 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/cursor", post(handle_update_cursor)) + .route("/api/slides/selection", post(handle_update_selection)) + .route("/api/slides/cursors", get(handle_list_cursors)) + .route("/api/slides/selections", get(handle_list_selections)) + .route("/api/slides/transition", post(handle_set_transition)) + .route("/api/slides/transition/all", post(handle_apply_transition_to_all)) + .route("/api/slides/transition/remove", post(handle_remove_transition)) + .route("/api/slides/media", post(handle_add_media)) + .route("/api/slides/media/update", post(handle_update_media)) + .route("/api/slides/media/delete", post(handle_delete_media)) + .route("/api/slides/media/list", get(handle_list_media)) + .route("/api/slides/presenter/start", post(handle_start_presenter)) + .route("/api/slides/presenter/update", post(handle_update_presenter)) + .route("/api/slides/presenter/end", post(handle_end_presenter)) + .route("/api/slides/presenter/notes", get(handle_get_presenter_notes)) .route("/ws/slides/:presentation_id", get(handle_slides_websocket)) } diff --git a/src/slides/types.rs b/src/slides/types.rs index 7977e7b2e..7f203a69d 100644 --- a/src/slides/types.rs +++ b/src/slides/types.rs @@ -1,6 +1,105 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollaborationCursor { + pub user_id: String, + pub user_name: String, + pub user_color: String, + pub slide_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub element_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub y: Option, + pub last_activity: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollaborationSelection { + pub user_id: String, + pub user_name: String, + pub user_color: String, + pub slide_index: usize, + pub element_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransitionConfig { + pub transition_type: String, + pub duration: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub direction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub easing: Option, + #[serde(default)] + pub advance_on_click: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub advance_after_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sound: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TransitionSound { + pub src: String, + pub name: String, + #[serde(default)] + pub loop_until_next: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaElement { + pub id: String, + pub media_type: String, + pub src: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub poster: Option, + #[serde(default)] + pub autoplay: bool, + #[serde(default)] + pub loop_playback: bool, + #[serde(default)] + pub muted: bool, + #[serde(default)] + pub controls: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresenterViewSettings { + pub show_notes: bool, + pub show_next_slide: bool, + pub show_timer: bool, + pub show_clock: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes_font_size: Option, + #[serde(default)] + pub zoom_level: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresenterSession { + pub id: String, + pub presentation_id: String, + pub current_slide: usize, + pub started_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub elapsed_time: Option, + pub is_paused: bool, + pub settings: PresenterViewSettings, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlideMessage { pub msg_type: String, @@ -47,6 +146,10 @@ pub struct Slide { pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub transition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transition_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub media: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -357,3 +460,124 @@ pub struct LoadFromDriveRequest { pub bucket: String, pub path: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCursorRequest { + pub presentation_id: String, + pub slide_index: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub element_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub x: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub y: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateSelectionRequest { + pub presentation_id: String, + pub slide_index: usize, + pub element_ids: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListCursorsResponse { + pub cursors: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListSelectionsResponse { + pub selections: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetTransitionRequest { + pub presentation_id: String, + pub slide_index: usize, + pub transition: TransitionConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplyTransitionToAllRequest { + pub presentation_id: String, + pub transition: TransitionConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveTransitionRequest { + pub presentation_id: String, + pub slide_index: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AddMediaRequest { + pub presentation_id: String, + pub slide_index: usize, + pub media: MediaElement, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateMediaRequest { + pub presentation_id: String, + pub slide_index: usize, + pub media_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub autoplay: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_playback: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub muted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub volume: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeleteMediaRequest { + pub presentation_id: String, + pub slide_index: usize, + pub media_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListMediaResponse { + pub media: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StartPresenterRequest { + pub presentation_id: String, + pub settings: PresenterViewSettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdatePresenterRequest { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_slide: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_paused: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndPresenterRequest { + pub session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresenterSessionResponse { + pub session: PresenterSession, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PresenterNotesResponse { + pub slide_index: usize, + pub notes: Option, + pub next_slide_notes: Option, + pub next_slide_thumbnail: Option, +}