- Add rust_xlsxwriter for Excel export with formatting support - Add docx-rs for Word document import/export with HTML conversion - Add PPTX export support with slides, shapes, and text elements - Refactor sheet module into 7 files (types, formulas, handlers, etc) - Refactor docs module into 6 files (types, handlers, storage, etc) - Refactor slides module into 6 files (types, handlers, storage, etc) - Fix collaboration modules (borrow issues, rand compatibility) - Add ooxmlsdk dependency for future Office 2021 features - Fix type mismatches in slides storage - Update security protection API router type Features: - Excel: Read xlsx/xlsm/xls, write xlsx with styles - Word: Read/write docx with formatting preservation - PowerPoint: Write pptx with slides, shapes, text - Real-time collaboration via WebSocket (already working) - Theme-aware UI with --sentient-* CSS variables
182 lines
5.5 KiB
Rust
182 lines
5.5 KiB
Rust
use crate::shared::state::AppState;
|
|
use crate::sheet::types::CollabMessage;
|
|
use axum::{
|
|
extract::{
|
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
Path, State,
|
|
},
|
|
response::IntoResponse,
|
|
Json,
|
|
};
|
|
use chrono::Utc;
|
|
use futures_util::{SinkExt, StreamExt};
|
|
use log::{error, info};
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use tokio::sync::broadcast;
|
|
|
|
pub type CollaborationChannels =
|
|
Arc<tokio::sync::RwLock<HashMap<String, broadcast::Sender<CollabMessage>>>>;
|
|
|
|
static COLLAB_CHANNELS: std::sync::OnceLock<CollaborationChannels> = std::sync::OnceLock::new();
|
|
|
|
pub fn get_collab_channels() -> &'static CollaborationChannels {
|
|
COLLAB_CHANNELS.get_or_init(|| Arc::new(tokio::sync::RwLock::new(HashMap::new())))
|
|
}
|
|
|
|
pub async fn handle_get_collaborators(
|
|
Path(sheet_id): Path<String>,
|
|
) -> impl IntoResponse {
|
|
let channels = get_collab_channels().read().await;
|
|
let count = channels.get(&sheet_id).map(|s| s.receiver_count()).unwrap_or(0);
|
|
Json(serde_json::json!({ "count": count }))
|
|
}
|
|
|
|
pub async fn handle_sheet_websocket(
|
|
ws: WebSocketUpgrade,
|
|
Path(sheet_id): Path<String>,
|
|
State(_state): State<Arc<AppState>>,
|
|
) -> impl IntoResponse {
|
|
ws.on_upgrade(move |socket| handle_sheet_connection(socket, sheet_id))
|
|
}
|
|
|
|
async fn handle_sheet_connection(socket: WebSocket, sheet_id: String) {
|
|
let (mut sender, mut receiver) = socket.split();
|
|
|
|
let channels = get_collab_channels();
|
|
let broadcast_tx = {
|
|
let mut channels_write = channels.write().await;
|
|
channels_write
|
|
.entry(sheet_id.clone())
|
|
.or_insert_with(|| broadcast::channel(100).0)
|
|
.clone()
|
|
};
|
|
|
|
let mut broadcast_rx = broadcast_tx.subscribe();
|
|
|
|
let user_id = uuid::Uuid::new_v4().to_string();
|
|
let user_id_for_send = user_id.clone();
|
|
let user_name = format!("User {}", &user_id[..8]);
|
|
let user_color = get_random_color();
|
|
|
|
let join_msg = CollabMessage {
|
|
msg_type: "join".to_string(),
|
|
sheet_id: sheet_id.clone(),
|
|
user_id: user_id.clone(),
|
|
user_name: user_name.clone(),
|
|
user_color: user_color.clone(),
|
|
row: None,
|
|
col: None,
|
|
value: None,
|
|
worksheet_index: None,
|
|
timestamp: Utc::now(),
|
|
};
|
|
|
|
if let Err(e) = broadcast_tx.send(join_msg) {
|
|
error!("Failed to broadcast join: {}", e);
|
|
}
|
|
|
|
let broadcast_tx_clone = broadcast_tx.clone();
|
|
let user_id_clone = user_id.clone();
|
|
let sheet_id_clone = sheet_id.clone();
|
|
let user_name_clone = user_name.clone();
|
|
let user_color_clone = user_color.clone();
|
|
|
|
let receive_task = tokio::spawn(async move {
|
|
while let Some(msg) = receiver.next().await {
|
|
match msg {
|
|
Ok(Message::Text(text)) => {
|
|
if let Ok(mut collab_msg) = serde_json::from_str::<CollabMessage>(&text) {
|
|
collab_msg.user_id = user_id_clone.clone();
|
|
collab_msg.user_name = user_name_clone.clone();
|
|
collab_msg.user_color = user_color_clone.clone();
|
|
collab_msg.sheet_id = sheet_id_clone.clone();
|
|
collab_msg.timestamp = Utc::now();
|
|
|
|
if let Err(e) = broadcast_tx_clone.send(collab_msg) {
|
|
error!("Failed to broadcast message: {}", e);
|
|
}
|
|
}
|
|
}
|
|
Ok(Message::Close(_)) => break,
|
|
Err(e) => {
|
|
error!("WebSocket error: {}", e);
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
let send_task = tokio::spawn(async move {
|
|
while let Ok(msg) = broadcast_rx.recv().await {
|
|
if msg.user_id == user_id_for_send {
|
|
continue;
|
|
}
|
|
if let Ok(json) = serde_json::to_string(&msg) {
|
|
if sender.send(Message::Text(json.into())).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let leave_msg = CollabMessage {
|
|
msg_type: "leave".to_string(),
|
|
sheet_id: sheet_id.clone(),
|
|
user_id: user_id.clone(),
|
|
user_name,
|
|
user_color,
|
|
row: None,
|
|
col: None,
|
|
value: None,
|
|
worksheet_index: None,
|
|
timestamp: Utc::now(),
|
|
};
|
|
|
|
tokio::select! {
|
|
_ = receive_task => {}
|
|
_ = send_task => {}
|
|
}
|
|
|
|
if let Err(e) = broadcast_tx.send(leave_msg) {
|
|
info!("User left (broadcast may have no receivers): {}", e);
|
|
}
|
|
}
|
|
|
|
pub async fn broadcast_sheet_change(
|
|
sheet_id: &str,
|
|
user_id: &str,
|
|
user_name: &str,
|
|
row: u32,
|
|
col: u32,
|
|
value: &str,
|
|
worksheet_index: usize,
|
|
) {
|
|
let channels = get_collab_channels().read().await;
|
|
if let Some(tx) = channels.get(sheet_id) {
|
|
let msg = CollabMessage {
|
|
msg_type: "cell_update".to_string(),
|
|
sheet_id: sheet_id.to_string(),
|
|
user_id: user_id.to_string(),
|
|
user_name: user_name.to_string(),
|
|
user_color: get_random_color(),
|
|
row: Some(row),
|
|
col: Some(col),
|
|
value: Some(value.to_string()),
|
|
worksheet_index: Some(worksheet_index),
|
|
timestamp: Utc::now(),
|
|
};
|
|
let _ = tx.send(msg);
|
|
}
|
|
}
|
|
|
|
fn get_random_color() -> String {
|
|
use rand::Rng;
|
|
let colors = [
|
|
"#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F",
|
|
"#BB8FCE", "#85C1E9",
|
|
];
|
|
let idx = rand::rng().random_range(0..colors.len());
|
|
colors[idx].to_string()
|
|
}
|