feat(office): Add Phase 3 advanced features - Comments, Track Changes, TOC, Footnotes, Styles, Presenter View, Transitions, Media

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-11 12:13:10 -03:00
parent 1850564e62
commit 840c7789f3
9 changed files with 3428 additions and 36 deletions

File diff suppressed because it is too large Load diff

View file

@ -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<Arc<AppState>> {
@ -52,5 +61,31 @@ pub fn configure_docs_routes() -> Router<Arc<AppState>> {
.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))
}

View file

@ -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<Utc>,
pub original_text: Option<String>,
pub new_text: Option<String>,
pub position: usize,
pub length: usize,
pub accepted: Option<bool>,
}
#[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<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub replies: Vec<CommentReply>,
#[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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableOfContents {
pub id: String,
pub title: String,
pub entries: Vec<TocEntry>,
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<u32>,
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<String>,
pub next_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font_family: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font_size: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font_weight: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub font_style: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub line_spacing: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_before: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub space_after: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_align: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_left: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_right: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indent_first_line: Option<f64>,
}
#[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<Utc>,
pub differences: Vec<DocumentDiff>,
pub summary: ComparisonSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DocumentDiff {
pub diff_type: String,
pub position: usize,
pub original_text: Option<String>,
pub modified_text: Option<String>,
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<String>,
#[serde(default)]
pub version: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub track_changes: Option<Vec<TrackChange>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comments: Option<Vec<DocumentComment>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub footnotes: Option<Vec<Footnote>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub endnotes: Option<Vec<Endnote>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub styles: Option<Vec<DocumentStyle>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub toc: Option<TableOfContents>,
#[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<DocumentComment>,
}
#[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<TrackChange>,
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<Footnote>,
}
#[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<Endnote>,
}
#[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<DocumentStyle>,
}
#[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<OutlineItem>,
}
#[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,
}

View file

@ -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<Json<Spreadsheet>, (StatusCode, Json<serde_json::Value>)> {
Ok(Json(create_new_spreadsheet()))
}
pub async fn handle_add_comment(
State(state): State<Arc<AppState>>,
Json(req): Json<AddCommentRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<ReplyCommentRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<ResolveCommentRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<DeleteCommentRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<ListCommentsRequest>,
) -> Result<Json<ListCommentsResponse>, (StatusCode, Json<serde_json::Value>)> {
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::<u32>(), parts[1].parse::<u32>()) {
comments_list.push(CommentWithLocation {
row,
col,
comment: comment.clone(),
});
}
}
}
}
Ok(Json(ListCommentsResponse { comments: comments_list }))
}
pub async fn handle_protect_sheet(
State(state): State<Arc<AppState>>,
Json(req): Json<ProtectSheetRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<UnprotectSheetRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<LockCellsRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<AddExternalLinkRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<RefreshExternalLinkRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<RemoveExternalLinkRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListExternalLinksResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<ArrayFormulaRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<DeleteArrayFormulaRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<CreateNamedRangeRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<UpdateNamedRangeRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<DeleteNamedRangeRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListNamedRangesResponse>, (StatusCode, Json<serde_json::Value>)> {
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 }))
}

View file

@ -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<Arc<AppState>> {
.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))
}

View file

@ -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<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub replies: Vec<CommentReply>,
#[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<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SheetProtection {
pub protected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_hash: Option<String>,
#[serde(default)]
pub locked_cells: Vec<String>,
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_range: Option<String>,
pub status: String,
pub last_updated: DateTime<Utc>,
}
#[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<usize>,
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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollabMessage {
pub msg_type: String,
@ -38,6 +136,10 @@ pub struct Spreadsheet {
pub worksheets: Vec<Worksheet>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub named_ranges: Option<Vec<NamedRange>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_links: Option<Vec<ExternalLink>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -64,6 +166,12 @@ pub struct Worksheet {
pub conditional_formats: Option<Vec<ConditionalFormatRule>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub charts: Option<Vec<ChartConfig>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comments: Option<HashMap<String, CellComment>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub protection: Option<SheetProtection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub array_formulas: Option<Vec<ArrayFormula>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -78,6 +186,12 @@ pub struct CellData {
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub locked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub has_comment: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub array_formula_id: Option<String>,
}
#[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<String>,
}
#[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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_range: Option<String>,
}
#[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<usize>,
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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_row: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_col: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_row: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_col: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[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<NamedRange>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListExternalLinksResponse {
pub links: Vec<ExternalLink>,
}
#[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<CommentWithLocation>,
}
#[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,

View file

@ -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<RwLock<HashMap<String, Vec<CollaborationCursor>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static SELECTIONS: LazyLock<RwLock<HashMap<String, Vec<CollaborationSelection>>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
static PRESENTER_SESSIONS: LazyLock<RwLock<HashMap<String, PresenterSession>>> =
LazyLock::new(|| RwLock::new(HashMap::new()));
pub async fn handle_update_cursor(
State(_state): State<Arc<AppState>>,
Json(req): Json<UpdateCursorRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<UpdateSelectionRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListCursorsResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListSelectionsResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<SetTransitionRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<ApplyTransitionToAllRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<RemoveTransitionRequest>,
) -> Result<Json<SaveResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<AddMediaRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<UpdateMediaRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<DeleteMediaRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<ListMediaResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<StartPresenterRequest>,
) -> Result<Json<PresenterSessionResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<UpdatePresenterRequest>,
) -> Result<Json<PresenterSessionResponse>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Json(req): Json<EndPresenterRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
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<Arc<AppState>>,
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<PresenterNotesResponse>, (StatusCode, Json<serde_json::Value>)> {
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,
}))
}

View file

@ -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<Arc<AppState>> {
@ -49,5 +56,20 @@ pub fn configure_slides_routes() -> Router<Arc<AppState>> {
.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))
}

View file

@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
pub last_activity: DateTime<Utc>,
}
#[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<String>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub easing: Option<String>,
#[serde(default)]
pub advance_on_click: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub advance_after_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sound: Option<TransitionSound>,
}
#[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<String>,
#[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<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<f64>,
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<f64>,
#[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<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub elapsed_time: Option<u64>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transition: Option<SlideTransition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub transition_config: Option<TransitionConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media: Option<Vec<MediaElement>>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub y: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSelectionRequest {
pub presentation_id: String,
pub slide_index: usize,
pub element_ids: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListCursorsResponse {
pub cursors: Vec<CollaborationCursor>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSelectionsResponse {
pub selections: Vec<CollaborationSelection>,
}
#[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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub loop_playback: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub muted: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub volume: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end_time: Option<f64>,
}
#[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<MediaElement>,
}
#[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<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_paused: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<PresenterViewSettings>,
}
#[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<String>,
pub next_slide_notes: Option<String>,
pub next_slide_thumbnail: Option<String>,
}