From 0c030daa10120a53c9cc7283c6d5b08fd1623bae Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Thu, 25 Dec 2025 15:49:58 +0800 Subject: Add sheet sharing functionality - Add `rand` dependency for generating share IDs - Update share path to include sheet name subdirectory - Add mutable accessor for sheet ID mapping - Add sheet_share module to vault data structures --- crates/vcs_data/src/data/vault/sheet_share.rs | 414 ++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 crates/vcs_data/src/data/vault/sheet_share.rs (limited to 'crates/vcs_data/src/data/vault/sheet_share.rs') diff --git a/crates/vcs_data/src/data/vault/sheet_share.rs b/crates/vcs_data/src/data/vault/sheet_share.rs new file mode 100644 index 0000000..703d935 --- /dev/null +++ b/crates/vcs_data/src/data/vault/sheet_share.rs @@ -0,0 +1,414 @@ +use std::{collections::HashMap, io::Error, path::PathBuf}; + +use cfg_file::{ConfigFile, config::ConfigFile}; +use rand::{Rng, rng}; +use serde::{Deserialize, Serialize}; +use string_proc::{format_path, snake_case}; +use tokio::fs; + +use crate::{ + constants::{ + SERVER_FILE_SHEET_SHARE, SERVER_PATH_SHARES, SERVER_SUFFIX_SHEET_FILE, + SERVER_SUFFIX_SHEET_FILE_NO_DOT, + }, + data::{ + member::MemberId, + sheet::{Sheet, SheetMappingMetadata, SheetName, SheetPathBuf}, + vault::Vault, + }, +}; + +pub type SheetShareId = String; + +const SHEET_NAME: &str = "{sheet_name}"; +const SHARE_ID: &str = "{share_id}"; + +#[derive(Default, Serialize, Deserialize, ConfigFile, Clone, Debug)] +pub struct Share { + /// Sharer: the member who created this share item + pub sharer: MemberId, + + /// Description of the share item + pub description: String, + + /// Metadata path + #[serde(skip)] + pub path: Option, + + /// From: which sheet the member exported the file from + pub from_sheet: SheetName, + + /// Mappings: the sheet mappings contained in the share item + pub mappings: HashMap, +} + +#[derive(Default, Serialize, Deserialize, ConfigFile, Clone, PartialEq, Eq)] +pub enum ShareMergeMode { + /// If a path or file already exists during merge, prioritize the incoming share + /// Path conflict: replace the mapping content at the local path with the incoming content + /// File conflict: delete the original file mapping and create a new one + Overwrite, + + /// If a path or file already exists during merge, skip overwriting this entry + Skip, + + /// Pre-check for conflicts, prohibit merging if any conflicts are found + #[default] + Safe, +} + +#[derive(Default, Serialize, Deserialize, ConfigFile, Clone)] +pub struct ShareMergeConflict { + /// Duplicate mappings exist + pub duplicate_mapping: Vec, + + /// Duplicate files exist + pub duplicate_file: Vec, +} + +impl ShareMergeConflict { + /// Check if there are no conflicts + pub fn ok(&self) -> bool { + self.duplicate_mapping.is_empty() && self.duplicate_file.is_empty() + } +} + +impl Vault { + /// Get the path of a share item in a sheet + pub fn share_file_path(&self, sheet_name: &SheetName, share_id: &SheetShareId) -> PathBuf { + let sheet_name = snake_case!(sheet_name.clone()); + let share_id = snake_case!(share_id.clone()); + + // Format the path to remove "./" prefix and normalize it + let path_str = SERVER_FILE_SHEET_SHARE + .replace(SHEET_NAME, &sheet_name) + .replace(SHARE_ID, &share_id); + + // Use format_path to normalize the path + match format_path::format_path_str(&path_str) { + Ok(normalized_path) => self.vault_path().join(normalized_path), + Err(_) => { + // Fallback to original behavior if formatting fails + self.vault_path().join(path_str) + } + } + } + + /// Get the actual paths of all share items in a sheet + pub async fn share_file_paths(&self, sheet_name: &SheetName) -> Vec { + let sheet_name = snake_case!(sheet_name.clone()); + let shares_dir = self + .vault_path() + .join(SERVER_PATH_SHARES.replace(SHEET_NAME, &sheet_name)); + + let mut result = Vec::new(); + if let Ok(mut entries) = fs::read_dir(shares_dir).await { + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.is_file() + && path.extension().and_then(|s| s.to_str()) + == Some(SERVER_SUFFIX_SHEET_FILE_NO_DOT) + { + result.push(path); + } + } + } + result + } +} + +impl<'a> Sheet<'a> { + /// Get the shares of a sheet + pub async fn get_shares(&self) -> Result, std::io::Error> { + let paths = self.vault_reference.share_file_paths(&self.name).await; + let mut shares = Vec::new(); + + for path in paths { + match Share::read_from(&path).await { + Ok(mut share) => { + share.path = Some(path); + shares.push(share); + } + Err(e) => return Err(e), + } + } + + Ok(shares) + } + + /// Get a share of a sheet + pub async fn get_share(&self, share_id: &SheetShareId) -> Result { + let path = self.vault_reference.share_file_path(&self.name, share_id); + let mut share = Share::read_from(&path).await?; + share.path = Some(path); + Ok(share) + } + + /// Import a share of a sheet by its ID + pub async fn merge_share_by_id( + self, + share_id: &SheetShareId, + share_merge_mode: ShareMergeMode, + ) -> Result<(), std::io::Error> { + let share = self.get_share(share_id).await?; + self.merge_share(share, share_merge_mode).await + } + + /// Import a share of a sheet + pub async fn merge_share( + mut self, + share: Share, + share_merge_mode: ShareMergeMode, + ) -> Result<(), std::io::Error> { + // Backup original data and edit based on this backup + let mut copy_share = share.clone(); + let mut copy_sheet = self.clone_data(); + + // Pre-check + let precheck = self.precheck(©_share); + + match share_merge_mode { + // Safe mode: conflicts are not allowed + ShareMergeMode::Safe => { + // Conflicts found + if !precheck.ok() { + // Do nothing, return Error + return Err(Error::new( + std::io::ErrorKind::AlreadyExists, + "Mappings or files already exist!", + )); + } + } + // Overwrite mode: when conflicts occur, prioritize the share item + ShareMergeMode::Overwrite => { + // Handle duplicate mappings + for path in precheck.duplicate_mapping { + // Get the share data + let Some(share_value) = copy_share.mappings.remove(&path) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Share value `{}` not found!", &path.display()), + )); + }; + // Overwrite + copy_sheet.mapping_mut().insert(path, share_value); + } + + // Handle duplicate IDs + for path in precheck.duplicate_file { + // Get the share data + let Some(share_value) = copy_share.mappings.remove(&path) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Share value `{}` not found!", &path.display()), + )); + }; + + // Extract the file ID + let conflict_vfid = &share_value.id; + + // Through the sheet's ID mapping + let Some(id_mapping) = copy_sheet.id_mapping_mut() else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Id mapping not found!", + )); + }; + + // Get the original path from the ID mapping + let Some(raw_path) = id_mapping.remove(conflict_vfid) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("The path of virtual file `{}' not found!", conflict_vfid), + )); + }; + + // Remove the original path mapping + if copy_sheet.mapping_mut().remove(&raw_path).is_none() { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Remove mapping `{}` failed!", &raw_path.display()), + )); + } + // Insert the new item + copy_sheet.mapping_mut().insert(path, share_value); + } + } + // Skip mode: when conflicts occur, prioritize the local sheet + ShareMergeMode::Skip => { + // Directly remove conflicting items + for path in precheck.duplicate_mapping { + copy_share.mappings.remove(&path); + } + for path in precheck.duplicate_file { + copy_share.mappings.remove(&path); + } + } + } + + // Subsequent merging + copy_sheet + .mapping_mut() + .extend(copy_share.mappings.into_iter()); + + // Merge completed + self.data = copy_sheet; // Write the result + + // Merge completed, consume the sheet + self.persist().await.map_err(|err| { + Error::new( + std::io::ErrorKind::NotFound, + format!("Write sheet failed: {}", err), + ) + })?; + + // Persistence succeeded, continue to consume the share item + share.remove().await.map_err(|err| { + Error::new( + std::io::ErrorKind::NotFound, + format!("Remove share failed: {}", err.1), + ) + }) + } + + // Pre-check whether the share can be imported into the current sheet without conflicts + fn precheck(&self, share: &Share) -> ShareMergeConflict { + let mut conflicts = ShareMergeConflict::default(); + + for (mapping, metadata) in &share.mappings { + // Check for duplicate mappings + if self.mapping().contains_key(mapping.as_path()) { + conflicts.duplicate_mapping.push(mapping.clone()); + continue; + } + + // Check for duplicate IDs + if let Some(id_mapping) = self.id_mapping() { + if id_mapping.contains_key(&metadata.id) { + conflicts.duplicate_file.push(mapping.clone()); + continue; + } + } + } + + conflicts + } + + /// Share mappings with another sheet + pub async fn share_mappings( + &self, + other_sheet: &SheetName, + mappings: Vec, + sharer: &MemberId, + description: String, + ) -> Result { + let other_sheet = snake_case!(other_sheet.clone()); + let sharer = snake_case!(sharer.clone()); + + // Check if the sheet exists + let sheet_names = self.vault_reference.sheet_names()?; + if !sheet_names.contains(&other_sheet) { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Sheet `{}` not found!", &other_sheet), + )); + } + + // Check if the target file exists, regenerate ID if path already exists, up to 20 attempts + let target_path = { + let mut id; + let mut share_path; + let mut attempts = 0; + + loop { + id = Share::gen_share_id(&sharer); + share_path = self.vault_reference.share_file_path(&other_sheet, &id); + + if !share_path.exists() { + break share_path; + } + + attempts += 1; + if attempts >= 20 { + return Err(Error::new( + std::io::ErrorKind::AlreadyExists, + "Failed to generate unique share ID after 20 attempts!", + )); + } + } + }; + + // Validate that the share is valid + let mut share_mappings = HashMap::new(); + for mapping_path in &mappings { + if let Some(metadata) = self.mapping().get(mapping_path) { + share_mappings.insert(mapping_path.clone(), metadata.clone()); + } else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + format!("Mapping `{}` not found in sheet!", mapping_path.display()), + )); + } + } + + // Build share data + let share_data = Share { + sharer, + description, + path: None, // This is only needed during merging (reading), no need to serialize now + from_sheet: self.name.clone(), + mappings: share_mappings, + }; + + // Write data + Share::write_to(&share_data, target_path).await?; + + Ok(share_data) + } +} + +impl Share { + /// Generate a share ID for a given sharer + pub fn gen_share_id(sharer: &MemberId) -> String { + let sharer_snake = snake_case!(sharer.clone()); + let random_part: String = rng() + .sample_iter(&rand::distr::Alphanumeric) + .take(8) + .map(char::from) + .collect(); + format!( + "{}@{}{}", + sharer_snake, random_part, SERVER_SUFFIX_SHEET_FILE + ) + } + + /// Delete a share (reject or remove the share item) + /// If deletion succeeds, returns `Ok(())`; + /// If deletion fails, returns `Err((self, std::io::Error))`, containing the original share object and the error information. + pub async fn remove(self) -> Result<(), (Self, std::io::Error)> { + let Some(path) = &self.path else { + return Err(( + self, + Error::new(std::io::ErrorKind::NotFound, "No share path recorded!"), + )); + }; + + if !path.exists() { + return Err(( + self, + Error::new(std::io::ErrorKind::NotFound, "No share file exists!"), + )); + } + + match fs::remove_file(path).await { + Err(err) => Err(( + self, + Error::new( + std::io::ErrorKind::Other, + format!("Failed to delete share file: {}", err), + ), + )), + Ok(_) => Ok(()), + } + } +} -- cgit