diff options
| author | 魏曹先生 <1992414357@qq.com> | 2026-02-05 22:35:05 +0800 |
|---|---|---|
| committer | 魏曹先生 <1992414357@qq.com> | 2026-02-05 22:35:05 +0800 |
| commit | 27f6414ad1ff451feb0044af62f37dc2a6255ffa (patch) | |
| tree | cb5693bc014cc8579dcf02a730fd4d2a5dfcf1a5 /legacy_data/src/data/local | |
| parent | ade2fcb9302a4ab759795820dbde3b2b269490ee (diff) | |
Remove examples and legacy code, update .gitignore
- Delete examples directory and its example action system
- Rename actions/ to legacy_actions/ and data/ to legacy_data/
- Update Cargo.toml license file reference
- Move setup scripts to scripts/dev/ directory
- Add todo.txt patterns to .gitignore
Diffstat (limited to 'legacy_data/src/data/local')
| -rw-r--r-- | legacy_data/src/data/local/align_tasks.rs | 110 | ||||
| -rw-r--r-- | legacy_data/src/data/local/cached_sheet.rs | 94 | ||||
| -rw-r--r-- | legacy_data/src/data/local/latest_file_data.rs | 103 | ||||
| -rw-r--r-- | legacy_data/src/data/local/latest_info.rs | 81 | ||||
| -rw-r--r-- | legacy_data/src/data/local/local_files.rs | 148 | ||||
| -rw-r--r-- | legacy_data/src/data/local/local_sheet.rs | 439 | ||||
| -rw-r--r-- | legacy_data/src/data/local/modified_status.rs | 30 | ||||
| -rw-r--r-- | legacy_data/src/data/local/workspace_analyzer.rs | 359 | ||||
| -rw-r--r-- | legacy_data/src/data/local/workspace_config.rs | 374 |
9 files changed, 1738 insertions, 0 deletions
diff --git a/legacy_data/src/data/local/align_tasks.rs b/legacy_data/src/data/local/align_tasks.rs new file mode 100644 index 0000000..b72804c --- /dev/null +++ b/legacy_data/src/data/local/align_tasks.rs @@ -0,0 +1,110 @@ +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; + +use data_struct::data_sort::quick_sort_with_cmp; + +use crate::data::local::workspace_analyzer::AnalyzeResult; + +pub type AlignTaskName = String; +pub type AlignPathBuf = PathBuf; +pub type AlignLostPathBuf = PathBuf; +pub type AlignCreatedPathBuf = PathBuf; + +pub struct AlignTasks { + pub created: Vec<(AlignTaskName, AlignPathBuf)>, + pub lost: Vec<(AlignTaskName, AlignPathBuf)>, + pub moved: Vec<(AlignTaskName, (AlignLostPathBuf, AlignCreatedPathBuf))>, + pub erased: Vec<(AlignTaskName, AlignPathBuf)>, +} + +impl AlignTasks { + pub fn clone_from_analyze_result(result: &AnalyzeResult) -> Self { + AlignTasks { + created: path_hash_set_sort_helper(result.created.clone(), "created"), + lost: path_hash_set_sort_helper(result.lost.clone(), "lost"), + moved: path_hash_map_sort_helper(result.moved.clone(), "moved"), + erased: path_hash_set_sort_helper(result.erased.clone(), "erased"), + } + } + + pub fn from_analyze_result(result: AnalyzeResult) -> Self { + AlignTasks { + created: path_hash_set_sort_helper(result.created, "created"), + lost: path_hash_set_sort_helper(result.lost, "lost"), + moved: path_hash_map_sort_helper(result.moved, "moved"), + erased: path_hash_set_sort_helper(result.erased, "erased"), + } + } +} + +fn path_hash_set_sort_helper( + hash_set: HashSet<PathBuf>, + prefix: impl Into<String>, +) -> Vec<(String, PathBuf)> { + let prefix_str = prefix.into(); + let mut vec: Vec<(String, PathBuf)> = hash_set + .into_iter() + .map(|path| { + let hash = sha1_hash::calc_sha1_string(path.to_string_lossy()); + let hash_prefix: String = hash.chars().take(8).collect(); + let name = format!("{}:{}", prefix_str, hash_prefix); + (name, path) + }) + .collect(); + + quick_sort_with_cmp(&mut vec, false, |a, b| { + // Compare by path depth first + let a_depth = a.1.components().count(); + let b_depth = b.1.components().count(); + + if a_depth != b_depth { + return if a_depth < b_depth { -1 } else { 1 }; + } + + // If same depth, compare lexicographically + match a.1.cmp(&b.1) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } + }); + + vec +} + +fn path_hash_map_sort_helper( + hash_map: HashMap<String, (PathBuf, PathBuf)>, + prefix: impl Into<String>, +) -> Vec<(String, (PathBuf, PathBuf))> { + let prefix_str = prefix.into(); + let mut vec: Vec<(String, (PathBuf, PathBuf))> = hash_map + .into_values() + .map(|(path1, path2)| { + let hash = sha1_hash::calc_sha1_string(path1.to_string_lossy()); + let hash_prefix: String = hash.chars().take(8).collect(); + let name = format!("{}:{}", prefix_str, hash_prefix); + (name, (path1, path2)) + }) + .collect(); + + quick_sort_with_cmp(&mut vec, false, |a, b| { + // Compare by first PathBuf's path depth first + let a_depth = a.1.0.components().count(); + let b_depth = b.1.0.components().count(); + + if a_depth != b_depth { + return if a_depth < b_depth { -1 } else { 1 }; + } + + // If same depth, compare lexicographically by first PathBuf + match a.1.0.cmp(&b.1.0) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } + }); + + vec +} diff --git a/legacy_data/src/data/local/cached_sheet.rs b/legacy_data/src/data/local/cached_sheet.rs new file mode 100644 index 0000000..46b390f --- /dev/null +++ b/legacy_data/src/data/local/cached_sheet.rs @@ -0,0 +1,94 @@ +use std::{io::Error, path::PathBuf}; + +use cfg_file::config::ConfigFile; +use string_proc::{format_path::format_path, snake_case}; +use tokio::fs; + +use crate::{ + constants::{ + CLIENT_FILE_CACHED_SHEET, CLIENT_PATH_CACHED_SHEET, CLIENT_SUFFIX_CACHED_SHEET_FILE, + KEY_SHEET_NAME, + }, + data::sheet::{SheetData, SheetName}, + env::current_local_path, +}; + +pub type CachedSheetPathBuf = PathBuf; + +/// # Cached Sheet +/// The cached sheet is a read-only version cloned from the upstream repository to the local environment, +/// automatically generated during update operations, +/// which records the latest Sheet information stored locally to accelerate data access and reduce network requests. +pub struct CachedSheet; + +impl CachedSheet { + /// Read the cached sheet data. + pub async fn cached_sheet_data(sheet_name: &SheetName) -> Result<SheetData, std::io::Error> { + let sheet_name = snake_case!(sheet_name.clone()); + + let Some(path) = Self::cached_sheet_path(sheet_name) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Local workspace not found!", + )); + }; + let data = SheetData::read_from(path).await?; + Ok(data) + } + + /// Get the path to the cached sheet file. + pub fn cached_sheet_path(sheet_name: SheetName) -> Option<PathBuf> { + let current_workspace = current_local_path()?; + Some( + current_workspace + .join(CLIENT_FILE_CACHED_SHEET.replace(KEY_SHEET_NAME, &sheet_name.to_string())), + ) + } + + /// Get all cached sheet names + pub async fn cached_sheet_names() -> Result<Vec<SheetName>, std::io::Error> { + let mut dir = fs::read_dir(CLIENT_PATH_CACHED_SHEET).await?; + let mut sheet_names = Vec::new(); + + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + + if path.is_file() + && let Some(file_name) = path.file_name().and_then(|n| n.to_str()) + && file_name.ends_with(CLIENT_SUFFIX_CACHED_SHEET_FILE) + { + let name_without_ext = file_name + .trim_end_matches(CLIENT_SUFFIX_CACHED_SHEET_FILE) + .to_string(); + sheet_names.push(name_without_ext); + } + } + + Ok(sheet_names) + } + + /// Get all cached sheet paths + pub async fn cached_sheet_paths() -> Result<Vec<CachedSheetPathBuf>, std::io::Error> { + let mut dir = fs::read_dir(CLIENT_PATH_CACHED_SHEET).await?; + let mut sheet_paths = Vec::new(); + let Some(workspace_path) = current_local_path() else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Local workspace not found!", + )); + }; + + while let Some(entry) = dir.next_entry().await? { + let path = entry.path(); + + if path.is_file() + && let Some(file_name) = path.file_name().and_then(|n| n.to_str()) + && file_name.ends_with(CLIENT_SUFFIX_CACHED_SHEET_FILE) + { + sheet_paths.push(format_path(workspace_path.join(path))?); + } + } + + Ok(sheet_paths) + } +} diff --git a/legacy_data/src/data/local/latest_file_data.rs b/legacy_data/src/data/local/latest_file_data.rs new file mode 100644 index 0000000..f9b3aeb --- /dev/null +++ b/legacy_data/src/data/local/latest_file_data.rs @@ -0,0 +1,103 @@ +use std::{collections::HashMap, io::Error, path::PathBuf}; + +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; + +use crate::{ + constants::{CLIENT_FILE_LATEST_DATA, CLIENT_FILE_MEMBER_HELD_NOSET, KEY_ACCOUNT}, + data::{ + member::MemberId, + vault::virtual_file::{VirtualFileId, VirtualFileVersion, VirtualFileVersionDescription}, + }, + env::current_local_path, +}; + +/// # Latest file data +/// Records the file holder and the latest version for permission and update checks +#[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigFile)] +#[cfg_file(path = CLIENT_FILE_MEMBER_HELD_NOSET)] +pub struct LatestFileData { + /// File holding status + #[serde(rename = "held")] + held_status: HashMap<VirtualFileId, HeldStatus>, + + /// File version + #[serde(rename = "ver")] + versions: HashMap<VirtualFileId, VirtualFileVersion>, + + /// File histories and descriptions + #[serde(rename = "his")] + histories: HashMap<VirtualFileId, Vec<(VirtualFileVersion, VirtualFileVersionDescription)>>, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub enum HeldStatus { + #[serde(rename = "Hold")] + HeldWith(MemberId), // Held, status changes are sync to the client + + #[serde(rename = "None")] + NotHeld, // Not held, status changes are sync to the client + + #[default] + #[serde(rename = "Unknown")] + WantedToKnow, // Holding status is unknown, notify server must inform client +} + +impl LatestFileData { + /// Get the path to the file holding the held status information for the given member. + pub fn data_path(account: &MemberId) -> Result<PathBuf, std::io::Error> { + let Some(local_path) = current_local_path() else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Workspace not found.", + )); + }; + Ok(local_path.join(CLIENT_FILE_LATEST_DATA.replace(KEY_ACCOUNT, account))) + } + + /// Get the member who holds the file with the given ID. + pub fn file_holder(&self, vfid: &VirtualFileId) -> Option<&MemberId> { + self.held_status.get(vfid).and_then(|status| match status { + HeldStatus::HeldWith(id) => Some(id), + _ => None, + }) + } + + /// Get the version of the file with the given ID. + pub fn file_version(&self, vfid: &VirtualFileId) -> Option<&VirtualFileVersion> { + self.versions.get(vfid) + } + + /// Get the version of the file with the given ID. + pub fn file_histories( + &self, + vfid: &VirtualFileId, + ) -> Option<&Vec<(VirtualFileVersion, VirtualFileVersionDescription)>> { + self.histories.get(vfid) + } + + /// Update the held status of the files. + pub fn update_info( + &mut self, + map: HashMap< + VirtualFileId, + ( + Option<MemberId>, + VirtualFileVersion, + Vec<(VirtualFileVersion, VirtualFileVersionDescription)>, + ), + >, + ) { + for (vfid, (member_id, version, desc)) in map { + self.held_status.insert( + vfid.clone(), + match member_id { + Some(member_id) => HeldStatus::HeldWith(member_id), + None => HeldStatus::NotHeld, + }, + ); + self.versions.insert(vfid.clone(), version); + self.histories.insert(vfid, desc); + } + } +} diff --git a/legacy_data/src/data/local/latest_info.rs b/legacy_data/src/data/local/latest_info.rs new file mode 100644 index 0000000..5748793 --- /dev/null +++ b/legacy_data/src/data/local/latest_info.rs @@ -0,0 +1,81 @@ +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use cfg_file::ConfigFile; +use serde::{Deserialize, Serialize}; + +use crate::{ + constants::{CLIENT_FILE_LATEST_INFO, CLIENT_FILE_LATEST_INFO_NOSET, KEY_ACCOUNT}, + data::{ + member::{Member, MemberId}, + sheet::{SheetData, SheetName, SheetPathBuf}, + vault::{ + mapping_share::{Share, SheetShareId}, + virtual_file::VirtualFileId, + }, + }, +}; + +/// # Latest Info +/// Locally cached latest information, +/// used to cache personal information from upstream for querying and quickly retrieving member information. +#[derive(Default, Serialize, Deserialize, ConfigFile)] +#[cfg_file(path = CLIENT_FILE_LATEST_INFO_NOSET)] +pub struct LatestInfo { + // Sheets + /// Visible sheets, + /// indicating which sheets I can edit + #[serde(rename = "my")] + pub visible_sheets: Vec<SheetName>, + + /// Invisible sheets, + /// indicating which sheets I can export files to (these sheets are not readable to me) + #[serde(rename = "others")] + pub invisible_sheets: Vec<SheetInfo>, + + /// Reference sheets, + /// indicating sheets owned by the host, visible to everyone, + /// but only the host can modify or add mappings within them + #[serde(rename = "refsheets")] + pub reference_sheets: HashSet<SheetName>, + + /// Reference sheet data, indicating what files I can get from the reference sheet + #[serde(rename = "ref")] + pub ref_sheet_content: SheetData, + + /// Reverse mapping from virtual file IDs to actual paths in reference sheets + #[serde(rename = "ref_vfs")] + pub ref_sheet_vfs_mapping: HashMap<VirtualFileId, SheetPathBuf>, + + /// Shares in my sheets, indicating which external merge requests have entries that I can view + #[serde(rename = "shares")] + pub shares_in_my_sheets: HashMap<SheetName, HashMap<SheetShareId, Share>>, + + /// Update instant + #[serde(rename = "update")] + pub update_instant: Option<SystemTime>, + + // Members + /// All member information of the vault, allowing me to contact them more conveniently + #[serde(rename = "members")] + pub vault_members: Vec<Member>, +} + +impl LatestInfo { + /// Get the path to the latest info file for a given workspace and member ID + pub fn latest_info_path(local_workspace_path: &Path, member_id: &MemberId) -> PathBuf { + local_workspace_path.join(CLIENT_FILE_LATEST_INFO.replace(KEY_ACCOUNT, member_id)) + } +} + +#[derive(Default, Serialize, Deserialize)] +pub struct SheetInfo { + #[serde(rename = "name")] + pub sheet_name: SheetName, + + #[serde(rename = "holder")] + pub holder_name: Option<MemberId>, +} diff --git a/legacy_data/src/data/local/local_files.rs b/legacy_data/src/data/local/local_files.rs new file mode 100644 index 0000000..9cc244f --- /dev/null +++ b/legacy_data/src/data/local/local_files.rs @@ -0,0 +1,148 @@ +use std::path::{Path, PathBuf}; + +use string_proc::format_path::format_path; +use tokio::fs; + +use crate::constants::CLIENT_FOLDER_WORKSPACE_ROOT_NAME; + +pub struct RelativeFiles { + pub(crate) files: Vec<PathBuf>, +} + +impl IntoIterator for RelativeFiles { + type Item = PathBuf; + type IntoIter = std::vec::IntoIter<Self::Item>; + + fn into_iter(self) -> Self::IntoIter { + self.files.into_iter() + } +} + +impl RelativeFiles { + pub fn iter(&self) -> std::slice::Iter<'_, PathBuf> { + self.files.iter() + } +} + +/// Read the relative paths within the project from the input file list +pub async fn get_relative_paths(local_path: &PathBuf, paths: &[PathBuf]) -> Option<RelativeFiles> { + // Get Relative Paths + let Ok(paths) = format_input_paths_and_ignore_outside_paths(local_path, paths).await else { + return None; + }; + let files: Vec<PathBuf> = abs_paths_to_abs_files(paths).await; + let Ok(files) = parse_to_relative(local_path, files) else { + return None; + }; + Some(RelativeFiles { files }) +} + +/// Normalize the input paths +async fn format_input_paths( + local_path: &Path, + track_files: &[PathBuf], +) -> Result<Vec<PathBuf>, std::io::Error> { + let current_dir = local_path; + + let mut real_paths = Vec::new(); + for file in track_files { + let path = current_dir.join(file); + + // Skip paths that contain .jv directories + if path.components().any(|component| { + if let std::path::Component::Normal(name) = component { + name.to_str() == Some(CLIENT_FOLDER_WORKSPACE_ROOT_NAME) + } else { + false + } + }) { + continue; + } + + match format_path(path) { + Ok(path) => real_paths.push(path), + Err(e) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("Failed to format path: {}", e), + )); + } + } + } + + Ok(real_paths) +} + +/// Ignore files outside the workspace +async fn format_input_paths_and_ignore_outside_paths( + local_path: &PathBuf, + files: &[PathBuf], +) -> Result<Vec<PathBuf>, std::io::Error> { + let result = format_input_paths(local_path, files).await?; + let result: Vec<PathBuf> = result + .into_iter() + .filter(|path| path.starts_with(local_path)) + .collect(); + Ok(result) +} + +/// Normalize the input paths to relative paths +fn parse_to_relative( + local_dir: &PathBuf, + files: Vec<PathBuf>, +) -> Result<Vec<PathBuf>, std::io::Error> { + let result: Result<Vec<PathBuf>, _> = files + .iter() + .map(|p| { + p.strip_prefix(local_dir) + .map(|relative| relative.to_path_buf()) + .map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Path prefix stripping failed", + ) + }) + }) + .collect(); + + result +} + +/// Convert absolute paths to absolute file paths, expanding directories to their contained files +async fn abs_paths_to_abs_files(paths: Vec<PathBuf>) -> Vec<PathBuf> { + let mut files = Vec::new(); + + for path in paths { + if !path.exists() { + continue; + } + + let metadata = match fs::metadata(&path).await { + Ok(meta) => meta, + Err(_) => continue, + }; + + if metadata.is_file() { + files.push(path); + } else if metadata.is_dir() { + let walker = walkdir::WalkDir::new(&path); + for entry in walker.into_iter().filter_map(|e| e.ok()) { + if entry.path().components().any(|component| { + if let std::path::Component::Normal(name) = component { + name == CLIENT_FOLDER_WORKSPACE_ROOT_NAME + } else { + false + } + }) { + continue; + } + + if entry.file_type().is_file() { + files.push(entry.path().to_path_buf()); + } + } + } + } + + files +} diff --git a/legacy_data/src/data/local/local_sheet.rs b/legacy_data/src/data/local/local_sheet.rs new file mode 100644 index 0000000..b9c29f5 --- /dev/null +++ b/legacy_data/src/data/local/local_sheet.rs @@ -0,0 +1,439 @@ +use std::{collections::HashMap, io::Error, path::PathBuf, time::SystemTime}; + +use ::serde::{Deserialize, Serialize}; +use cfg_file::{ConfigFile, config::ConfigFile}; +use string_proc::format_path::format_path; + +use crate::{ + constants::CLIENT_FILE_LOCAL_SHEET_NOSET, + data::{ + local::LocalWorkspace, + member::MemberId, + sheet::SheetName, + vault::virtual_file::{VirtualFileId, VirtualFileVersion, VirtualFileVersionDescription}, + }, +}; + +pub type LocalFilePathBuf = PathBuf; +pub type LocalSheetPathBuf = PathBuf; + +/// # Local Sheet +/// Local sheet information, used to record metadata of actual local files, +/// to compare with upstream information for more optimized file submission, +/// and to determine whether files need to be updated or submitted. +pub struct LocalSheet<'a> { + pub(crate) local_workspace: &'a LocalWorkspace, + pub(crate) member: MemberId, + pub(crate) sheet_name: String, + pub(crate) data: LocalSheetData, +} + +impl<'a> LocalSheet<'a> { + /// Create a new LocalSheet instance + pub fn new( + local_workspace: &'a LocalWorkspace, + member: MemberId, + sheet_name: String, + data: LocalSheetData, + ) -> Self { + Self { + local_workspace, + member, + sheet_name, + data, + } + } +} + +#[derive(Debug, Default, Serialize, Deserialize, ConfigFile, Clone)] +#[cfg_file(path = CLIENT_FILE_LOCAL_SHEET_NOSET)] // Do not use LocalSheet::write or LocalSheet::read +pub struct LocalSheetData { + /// Local file path to metadata mapping. + #[serde(rename = "map")] + pub(crate) mapping: HashMap<LocalFilePathBuf, LocalMappingMetadata>, + + #[serde(rename = "vfs")] + pub(crate) vfs: HashMap<VirtualFileId, LocalFilePathBuf>, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LocalMappingMetadata { + /// Hash value generated immediately after the file is downloaded to the local workspace + #[serde(rename = "base_hash")] + pub(crate) hash_when_updated: String, + + /// Time when the file was downloaded to the local workspace + #[serde(rename = "time")] + pub(crate) time_when_updated: SystemTime, + + /// Size of the file when downloaded to the local workspace + #[serde(rename = "size")] + pub(crate) size_when_updated: u64, + + /// Version description when the file was downloaded to the local workspace + #[serde(rename = "desc")] + pub(crate) version_desc_when_updated: VirtualFileVersionDescription, + + /// Version when the file was downloaded to the local workspace + #[serde(rename = "ver")] + pub(crate) version_when_updated: VirtualFileVersion, + + /// Virtual file ID corresponding to the local path + #[serde(rename = "id")] + pub(crate) mapping_vfid: VirtualFileId, + + /// Latest modifiy check time + #[serde(rename = "check_time")] + pub(crate) last_modify_check_time: SystemTime, + + /// Latest modifiy check result + #[serde(rename = "modified")] + pub(crate) last_modify_check_result: bool, + + /// Latest modifiy check hash result + #[serde(rename = "current_hash")] + pub(crate) last_modify_check_hash: Option<String>, +} + +impl LocalSheetData { + /// Wrap LocalSheetData into LocalSheet with workspace, member, and sheet name + pub fn wrap_to_local_sheet<'a>( + self, + workspace: &'a LocalWorkspace, + member: MemberId, + sheet_name: SheetName, + ) -> LocalSheet<'a> { + LocalSheet { + local_workspace: workspace, + member, + sheet_name, + data: self, + } + } +} + +impl LocalMappingMetadata { + /// Create a new MappingMetaData instance + #[allow(clippy::too_many_arguments)] + pub fn new( + hash_when_updated: String, + time_when_updated: SystemTime, + size_when_updated: u64, + version_desc_when_updated: VirtualFileVersionDescription, + version_when_updated: VirtualFileVersion, + mapping_vfid: VirtualFileId, + last_modifiy_check_time: SystemTime, + last_modifiy_check_result: bool, + ) -> Self { + Self { + hash_when_updated, + time_when_updated, + size_when_updated, + version_desc_when_updated, + version_when_updated, + mapping_vfid, + last_modify_check_time: last_modifiy_check_time, + last_modify_check_result: last_modifiy_check_result, + last_modify_check_hash: None, + } + } + + /// Getter for hash_when_updated + pub fn hash_when_updated(&self) -> &String { + &self.hash_when_updated + } + + /// Setter for hash_when_updated + pub fn set_hash_when_updated(&mut self, hash: String) { + self.hash_when_updated = hash; + } + + /// Getter for date_when_updated + pub fn time_when_updated(&self) -> &SystemTime { + &self.time_when_updated + } + + /// Setter for time_when_updated + pub fn set_time_when_updated(&mut self, time: SystemTime) { + self.time_when_updated = time; + } + + /// Getter for size_when_updated + pub fn size_when_updated(&self) -> u64 { + self.size_when_updated + } + + /// Setter for size_when_updated + pub fn set_size_when_updated(&mut self, size: u64) { + self.size_when_updated = size; + } + + /// Getter for version_desc_when_updated + pub fn version_desc_when_updated(&self) -> &VirtualFileVersionDescription { + &self.version_desc_when_updated + } + + /// Setter for version_desc_when_updated + pub fn set_version_desc_when_updated(&mut self, version_desc: VirtualFileVersionDescription) { + self.version_desc_when_updated = version_desc; + } + + /// Getter for version_when_updated + pub fn version_when_updated(&self) -> &VirtualFileVersion { + &self.version_when_updated + } + + /// Setter for version_when_updated + pub fn set_version_when_updated(&mut self, version: VirtualFileVersion) { + self.version_when_updated = version; + } + + /// Getter for mapping_vfid + pub fn mapping_vfid(&self) -> &VirtualFileId { + &self.mapping_vfid + } + + /// Setter for mapping_vfid + pub fn set_mapping_vfid(&mut self, vfid: VirtualFileId) { + self.mapping_vfid = vfid; + } + + /// Getter for last_modifiy_check_time + pub fn last_modifiy_check_time(&self) -> &SystemTime { + &self.last_modify_check_time + } + + /// Setter for last_modifiy_check_time + pub fn set_last_modifiy_check_time(&mut self, time: SystemTime) { + self.last_modify_check_time = time; + } + + /// Getter for last_modifiy_check_result + pub fn last_modifiy_check_result(&self) -> bool { + self.last_modify_check_result + } + + /// Setter for last_modifiy_check_result + pub fn set_last_modifiy_check_result(&mut self, result: bool) { + self.last_modify_check_result = result; + } + + /// Getter for last_modifiy_check_hash + pub fn last_modifiy_check_hash(&self) -> &Option<String> { + &self.last_modify_check_hash + } + + /// Setter for last_modifiy_check_hash + pub fn set_last_modifiy_check_hash(&mut self, hash: Option<String>) { + self.last_modify_check_hash = hash; + } +} + +impl Default for LocalMappingMetadata { + fn default() -> Self { + Self { + hash_when_updated: Default::default(), + time_when_updated: SystemTime::now(), + size_when_updated: Default::default(), + version_desc_when_updated: Default::default(), + version_when_updated: Default::default(), + mapping_vfid: Default::default(), + last_modify_check_time: SystemTime::now(), + last_modify_check_result: false, + last_modify_check_hash: None, + } + } +} + +mod instant_serde { + use serde::{self, Deserialize, Deserializer, Serializer}; + use tokio::time::Instant; + + pub fn serialize<S>(instant: &Instant, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_u64(instant.elapsed().as_secs()) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<Instant, D::Error> + where + D: Deserializer<'de>, + { + let secs = u64::deserialize(deserializer)?; + Ok(Instant::now() - std::time::Duration::from_secs(secs)) + } +} + +impl<'a> From<&'a LocalSheet<'a>> for &'a LocalSheetData { + fn from(sheet: &'a LocalSheet<'a>) -> Self { + &sheet.data + } +} + +impl LocalSheetData { + /// Add mapping to local sheet data + pub fn add_mapping( + &mut self, + path: &LocalFilePathBuf, + mapping: LocalMappingMetadata, + ) -> Result<(), std::io::Error> { + let path = format_path(path)?; + if self.mapping.contains_key(&path) || self.vfs.contains_key(&mapping.mapping_vfid) { + return Err(Error::new( + std::io::ErrorKind::AlreadyExists, + "Mapping already exists", + )); + } + + self.mapping.insert(path.clone(), mapping.clone()); + self.vfs.insert(mapping.mapping_vfid.clone(), path); + Ok(()) + } + + /// Move mapping to other path + pub fn move_mapping( + &mut self, + from: &LocalFilePathBuf, + to: &LocalFilePathBuf, + ) -> Result<(), std::io::Error> { + let from = format_path(from)?; + let to = format_path(to)?; + if self.mapping.contains_key(&to) { + return Err(Error::new( + std::io::ErrorKind::AlreadyExists, + "To path already exists.", + )); + } + + let Some(old_value) = self.mapping.remove(&from) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "From path is not found.", + )); + }; + + // Update vfs mapping + self.vfs.insert(old_value.mapping_vfid.clone(), to.clone()); + self.mapping.insert(to, old_value); + + Ok(()) + } + + /// Remove mapping from local sheet + pub fn remove_mapping( + &mut self, + path: &LocalFilePathBuf, + ) -> Result<LocalMappingMetadata, std::io::Error> { + let path = format_path(path)?; + match self.mapping.remove(&path) { + Some(mapping) => { + self.vfs.remove(&mapping.mapping_vfid); + Ok(mapping) + } + None => Err(Error::new( + std::io::ErrorKind::NotFound, + "Path is not found.", + )), + } + } + + /// Get immutable mapping data + pub fn mapping_data( + &self, + path: &LocalFilePathBuf, + ) -> Result<&LocalMappingMetadata, std::io::Error> { + let path = format_path(path)?; + let Some(data) = self.mapping.get(&path) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Path is not found.", + )); + }; + Ok(data) + } + + /// Get mutable mapping data + pub fn mapping_data_mut( + &mut self, + path: &LocalFilePathBuf, + ) -> Result<&mut LocalMappingMetadata, std::io::Error> { + let path = format_path(path)?; + let Some(data) = self.mapping.get_mut(&path) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Path is not found.", + )); + }; + Ok(data) + } + + /// Get path by VirtualFileId + pub fn path_by_id(&self, vfid: &VirtualFileId) -> Option<&PathBuf> { + self.vfs.get(vfid) + } +} + +impl<'a> LocalSheet<'a> { + /// Add mapping to local sheet data + pub fn add_mapping( + &mut self, + path: &LocalFilePathBuf, + mapping: LocalMappingMetadata, + ) -> Result<(), std::io::Error> { + self.data.add_mapping(path, mapping) + } + + /// Move mapping to other path + pub fn move_mapping( + &mut self, + from: &LocalFilePathBuf, + to: &LocalFilePathBuf, + ) -> Result<(), std::io::Error> { + self.data.move_mapping(from, to) + } + + /// Remove mapping from local sheet + pub fn remove_mapping( + &mut self, + path: &LocalFilePathBuf, + ) -> Result<LocalMappingMetadata, std::io::Error> { + self.data.remove_mapping(path) + } + + /// Get immutable mapping data + pub fn mapping_data( + &self, + path: &LocalFilePathBuf, + ) -> Result<&LocalMappingMetadata, std::io::Error> { + self.data.mapping_data(path) + } + + /// Get mutable mapping data + pub fn mapping_data_mut( + &mut self, + path: &LocalFilePathBuf, + ) -> Result<&mut LocalMappingMetadata, std::io::Error> { + self.data.mapping_data_mut(path) + } + + /// Write the sheet to disk + pub async fn write(&mut self) -> Result<(), std::io::Error> { + let path = self + .local_workspace + .local_sheet_path(&self.member, &self.sheet_name); + self.write_to_path(path).await + } + + /// Write the sheet to custom path + pub async fn write_to_path(&mut self, path: impl Into<PathBuf>) -> Result<(), std::io::Error> { + let path = path.into(); + LocalSheetData::write_to(&self.data, path).await?; + Ok(()) + } + + /// Get path by VirtualFileId + pub fn path_by_id(&self, vfid: &VirtualFileId) -> Option<&PathBuf> { + self.data.path_by_id(vfid) + } +} diff --git a/legacy_data/src/data/local/modified_status.rs b/legacy_data/src/data/local/modified_status.rs new file mode 100644 index 0000000..e0e6dd5 --- /dev/null +++ b/legacy_data/src/data/local/modified_status.rs @@ -0,0 +1,30 @@ +use crate::{constants::CLIENT_FILE_VAULT_MODIFIED, env::current_local_path}; + +pub async fn check_vault_modified() -> bool { + let Some(current_dir) = current_local_path() else { + return false; + }; + + let record_file = current_dir.join(CLIENT_FILE_VAULT_MODIFIED); + if !record_file.exists() { + return false; + } + + let Ok(contents) = tokio::fs::read_to_string(&record_file).await else { + return false; + }; + + matches!(contents.trim().to_lowercase().as_str(), "true") +} + +pub async fn sign_vault_modified(modified: bool) { + let Some(current_dir) = current_local_path() else { + return; + }; + + let record_file = current_dir.join(CLIENT_FILE_VAULT_MODIFIED); + + let contents = if modified { "true" } else { "false" }; + + let _ = tokio::fs::write(&record_file, contents).await; +} diff --git a/legacy_data/src/data/local/workspace_analyzer.rs b/legacy_data/src/data/local/workspace_analyzer.rs new file mode 100644 index 0000000..5d73e03 --- /dev/null +++ b/legacy_data/src/data/local/workspace_analyzer.rs @@ -0,0 +1,359 @@ +use std::{ + collections::{HashMap, HashSet}, + io::Error, + path::PathBuf, +}; + +use serde::Serialize; +use sha1_hash::calc_sha1_multi; +use string_proc::format_path::format_path; +use walkdir::WalkDir; + +use crate::data::{ + local::{LocalWorkspace, cached_sheet::CachedSheet, local_sheet::LocalSheet}, + member::MemberId, + sheet::{SheetData, SheetName}, + vault::virtual_file::VirtualFileId, +}; + +pub type FromRelativePathBuf = PathBuf; +pub type ToRelativePathBuf = PathBuf; +pub type CreatedRelativePathBuf = PathBuf; +pub type LostRelativePathBuf = PathBuf; +pub type ModifiedRelativePathBuf = PathBuf; + +pub struct AnalyzeResult<'a> { + local_workspace: &'a LocalWorkspace, + + /// Moved local files + pub moved: HashMap<VirtualFileId, (FromRelativePathBuf, ToRelativePathBuf)>, + + /// Newly created local files + pub created: HashSet<CreatedRelativePathBuf>, + + /// Lost local files + pub lost: HashSet<LostRelativePathBuf>, + + /// Erased local files + pub erased: HashSet<LostRelativePathBuf>, + + /// Modified local files (excluding moved files) + /// For files that were both moved and modified, changes can only be detected after LocalSheet mapping is aligned with actual files + pub modified: HashSet<ModifiedRelativePathBuf>, +} + +#[derive(Serialize, Default)] +pub struct AnalyzeResultPure { + /// Moved local files + pub moved: HashMap<VirtualFileId, (FromRelativePathBuf, ToRelativePathBuf)>, + + /// Newly created local files + pub created: HashSet<CreatedRelativePathBuf>, + + /// Lost local files + pub lost: HashSet<LostRelativePathBuf>, + + /// Erased local files + pub erased: HashSet<LostRelativePathBuf>, + + /// Modified local files (excluding moved files) + /// For files that were both moved and modified, changes can only be detected after LocalSheet mapping is aligned with actual files + pub modified: HashSet<ModifiedRelativePathBuf>, +} + +impl<'a> From<AnalyzeResult<'a>> for AnalyzeResultPure { + fn from(result: AnalyzeResult<'a>) -> Self { + AnalyzeResultPure { + moved: result.moved, + created: result.created, + lost: result.lost, + erased: result.erased, + modified: result.modified, + } + } +} + +struct AnalyzeContext<'a> { + member: MemberId, + sheet_name: SheetName, + local_sheet: Option<LocalSheet<'a>>, + cached_sheet_data: Option<SheetData>, +} + +impl<'a> AnalyzeResult<'a> { + /// Analyze all files, calculate the file information provided + pub async fn analyze_local_status( + local_workspace: &'a LocalWorkspace, + ) -> Result<AnalyzeResult<'a>, std::io::Error> { + // Workspace + let workspace = local_workspace; + + // Current member, sheet + let (member, sheet_name) = { + let mut_workspace = workspace.config.lock().await; + let member = mut_workspace.current_account(); + let Some(sheet) = mut_workspace.sheet_in_use().clone() else { + return Err(Error::new(std::io::ErrorKind::NotFound, "Sheet not found")); + }; + (member, sheet) + }; + + // Local files (RelativePaths) + let local_path = workspace.local_path(); + let file_relative_paths = { + let mut paths = HashSet::new(); + for entry in WalkDir::new(local_path) { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + // Skip entries that contain ".jv" in their path + if entry.path().to_string_lossy().contains(".jv") { + continue; + } + + if entry.file_type().is_file() + && let Ok(relative_path) = entry.path().strip_prefix(local_path) + { + let format = format_path(relative_path.to_path_buf()); + let Ok(format) = format else { + continue; + }; + paths.insert(format); + } + } + + paths + }; + + // Read local sheet + let local_sheet = (workspace.local_sheet(&member, &sheet_name).await).ok(); + + // Read cached sheet + let cached_sheet_data = match CachedSheet::cached_sheet_data(&sheet_name).await { + Ok(v) => Some(v), + Err(_) => { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Cached sheet not found", + )); + } + }; + + // Create new result + let mut result = Self::none_result(workspace); + + // Analyze entry + let mut analyze_ctx = AnalyzeContext { + member, + sheet_name, + local_sheet, + cached_sheet_data, + }; + Self::analyze_moved(&mut result, &file_relative_paths, &analyze_ctx, workspace).await?; + Self::analyze_modified( + &mut result, + &file_relative_paths, + &mut analyze_ctx, + workspace, + ) + .await?; + + Ok(result) + } + + /// Track file moves by comparing recorded SHA1 hashes with actual file SHA1 hashes + /// For files that cannot be directly matched, continue searching using fuzzy matching algorithms + async fn analyze_moved( + result: &mut AnalyzeResult<'_>, + file_relative_paths: &HashSet<PathBuf>, + analyze_ctx: &AnalyzeContext<'a>, + workspace: &LocalWorkspace, + ) -> Result<(), std::io::Error> { + let local_sheet_paths: HashSet<&PathBuf> = match &analyze_ctx.local_sheet { + Some(local_sheet) => local_sheet.data.mapping.keys().collect(), + None => HashSet::new(), + }; + let file_relative_paths_ref: HashSet<&PathBuf> = file_relative_paths.iter().collect(); + + // Files that exist locally but not in remote + let mut erased_files: HashSet<PathBuf> = HashSet::new(); + + if let Some(cached_data) = &analyze_ctx.cached_sheet_data { + if let Some(local_sheet) = &analyze_ctx.local_sheet { + let cached_sheet_mapping = cached_data.mapping(); + let local_sheet_mapping = &local_sheet.data.mapping; + + // Find paths that exist in local sheet but not in cached sheet + for local_path in local_sheet_mapping.keys() { + if !cached_sheet_mapping.contains_key(local_path) { + erased_files.insert(local_path.clone()); + } + } + } + } + + // Files that exist in the local sheet but not in reality are considered lost + let mut lost_files: HashSet<&PathBuf> = local_sheet_paths + .difference(&file_relative_paths_ref) + .filter(|&&path| !erased_files.contains(path)) + .cloned() + .collect(); + + // Files that exist in reality but not in the local sheet are recorded as newly created + let mut new_files: HashSet<&PathBuf> = file_relative_paths_ref + .difference(&local_sheet_paths) + .cloned() + .collect(); + + // Calculate hashes for new files + let new_files_for_hash: Vec<PathBuf> = new_files + .iter() + .map(|p| workspace.local_path.join(p)) + .collect(); + let file_hashes: HashSet<(PathBuf, String)> = + match calc_sha1_multi::<PathBuf, Vec<PathBuf>>(new_files_for_hash, 8192).await { + Ok(hash) => hash, + Err(e) => return Err(Error::other(e)), + } + .iter() + .map(|r| (r.file_path.clone(), r.hash.to_string())) + .collect(); + + // Build hash mapping table for lost files + let mut lost_files_hash_mapping: HashMap<String, FromRelativePathBuf> = + match &analyze_ctx.local_sheet { + Some(local_sheet) => lost_files + .iter() + .filter_map(|f| { + local_sheet.mapping_data(f).ok().map(|mapping_data| { + ( + // Using the most recently recorded Hash can more accurately identify moved items, + // but if it doesn't exist, fall back to the initially recorded Hash + mapping_data + .last_modify_check_hash + .as_ref() + .cloned() + .unwrap_or(mapping_data.hash_when_updated.clone()), + (*f).clone(), + ) + }) + }) + .collect(), + None => HashMap::new(), + }; + + // If these hashes correspond to the hashes of missing files, then this pair of new and lost items will be merged into moved items + let mut moved_files: HashSet<(FromRelativePathBuf, ToRelativePathBuf)> = HashSet::new(); + for (new_path, new_hash) in file_hashes { + let new_path = new_path + .strip_prefix(&workspace.local_path) + .map(|p| p.to_path_buf()) + .unwrap_or(new_path); + + // If the new hash value hits the mapping, add a moved item + if let Some(lost_path) = lost_files_hash_mapping.remove(&new_hash) { + // Remove this new item and lost item + lost_files.remove(&lost_path); + new_files.remove(&new_path); + + // Create moved item + moved_files.insert((lost_path.clone(), new_path)); + } + } + + // Enter fuzzy matching to match other potentially moved items that haven't been matched + // If the total number of new and lost files is divisible by 2, it indicates there might still be files that have been moved, consider trying fuzzy matching + if new_files.len() + lost_files.len() % 2 == 0 { + // Try fuzzy matching + // ... + } + + // Collect results and set the result + result.created = new_files.iter().map(|p| (*p).clone()).collect(); + result.lost = lost_files.iter().map(|p| (*p).clone()).collect(); + result.moved = moved_files + .iter() + .filter_map(|(from, to)| { + let vfid = analyze_ctx + .local_sheet + .as_ref() + .and_then(|local_sheet| local_sheet.mapping_data(from).ok()) + .map(|mapping_data| mapping_data.mapping_vfid.clone()); + vfid.map(|vfid| (vfid, (from.clone(), to.clone()))) + }) + .collect(); + result.erased = erased_files; + + Ok(()) + } + + /// Compare using file modification time and SHA1 hash values. + /// Note: For files that have been both moved and modified, they can only be recognized as modified after their location is matched. + async fn analyze_modified( + result: &mut AnalyzeResult<'_>, + file_relative_paths: &HashSet<PathBuf>, + analyze_ctx: &mut AnalyzeContext<'a>, + workspace: &LocalWorkspace, + ) -> Result<(), std::io::Error> { + let local_sheet = &mut analyze_ctx.local_sheet.as_mut().unwrap(); + let local_path = local_sheet.local_workspace.local_path().clone(); + + for path in file_relative_paths { + // Get mapping data + let Ok(mapping_data) = local_sheet.mapping_data_mut(path) else { + continue; + }; + + // If modified time not changed, skip + let modified_time = std::fs::metadata(local_path.join(path))?.modified()?; + if &modified_time == mapping_data.last_modifiy_check_time() { + if mapping_data.last_modifiy_check_result() { + result.modified.insert(path.clone()); + } + continue; + } + + // Calculate hash + let hash_calc = match sha1_hash::calc_sha1(workspace.local_path.join(path), 2048).await + { + Ok(hash) => hash, + Err(e) => return Err(Error::other(e)), + }; + + // If hash not match, mark as modified + if &hash_calc.hash != mapping_data.hash_when_updated() { + result.modified.insert(path.clone()); + + // Update last modified check time to modified time + mapping_data.last_modify_check_time = modified_time; + mapping_data.last_modify_check_result = true; + } else { + // Update last modified check time to modified time + mapping_data.last_modify_check_time = modified_time; + mapping_data.last_modify_check_result = false; + } + + // Record latest hash + mapping_data.last_modify_check_hash = Some(hash_calc.hash) + } + + // Persist the local sheet data + LocalSheet::write(local_sheet).await?; + + Ok(()) + } + + /// Generate a empty AnalyzeResult + fn none_result(local_workspace: &'a LocalWorkspace) -> AnalyzeResult<'a> { + AnalyzeResult { + local_workspace, + moved: HashMap::new(), + created: HashSet::new(), + lost: HashSet::new(), + modified: HashSet::new(), + erased: HashSet::new(), + } + } +} diff --git a/legacy_data/src/data/local/workspace_config.rs b/legacy_data/src/data/local/workspace_config.rs new file mode 100644 index 0000000..f97d049 --- /dev/null +++ b/legacy_data/src/data/local/workspace_config.rs @@ -0,0 +1,374 @@ +use cfg_file::ConfigFile; +use cfg_file::config::ConfigFile; +use serde::{Deserialize, Serialize}; +use std::io::Error; +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use string_proc::snake_case; + +use crate::constants::CLIENT_FILE_WORKSPACE; +use crate::constants::CLIENT_FOLDER_WORKSPACE_ROOT_NAME; +use crate::constants::CLIENT_PATH_LOCAL_DRAFT; +use crate::constants::CLIENT_PATH_WORKSPACE_ROOT; +use crate::constants::KEY_ACCOUNT; +use crate::constants::KEY_SHEET_NAME; +use crate::constants::PORT; +use crate::data::local::latest_info::LatestInfo; +use crate::data::member::MemberId; +use crate::data::sheet::SheetName; +use crate::data::vault::vault_config::VaultUuid; +use crate::env::current_local_path; + +#[derive(Serialize, Deserialize, ConfigFile, Clone)] +#[cfg_file(path = CLIENT_FILE_WORKSPACE)] +pub struct LocalConfig { + /// The upstream address, representing the upstream address of the local workspace, + /// to facilitate timely retrieval of new updates from the upstream source. + #[serde(rename = "addr")] + upstream_addr: SocketAddr, + + /// The member ID used by the current local workspace. + /// This ID will be used to verify access permissions when connecting to the upstream server. + #[serde(rename = "as")] + using_account: MemberId, + + /// Whether the current member is interacting as a host. + /// In host mode, full Vault operation permissions are available except for adding new content. + #[serde(rename = "host")] + using_host_mode: bool, + + /// Whether the local workspace is stained. + /// + /// If stained, it can only set an upstream server with the same identifier. + /// + /// If the value is None, it means not stained; + /// otherwise, it contains the stain identifier (i.e., the upstream vault's unique ID) + #[serde(rename = "up_uid")] + stained_uuid: Option<VaultUuid>, + + /// The name of the sheet currently in use. + #[serde(rename = "use")] + sheet_in_use: Option<SheetName>, +} + +impl Default for LocalConfig { + fn default() -> Self { + Self { + upstream_addr: SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::new(127, 0, 0, 1), + PORT, + )), + using_account: "unknown".to_string(), + using_host_mode: false, + stained_uuid: None, + sheet_in_use: None, + } + } +} + +impl LocalConfig { + /// Set the vault address. + pub fn set_vault_addr(&mut self, addr: SocketAddr) { + self.upstream_addr = addr; + } + + /// Get the vault address. + pub fn vault_addr(&self) -> SocketAddr { + self.upstream_addr + } + + /// Set the currently used account + pub fn set_current_account(&mut self, account: MemberId) -> Result<(), std::io::Error> { + if self.sheet_in_use().is_some() { + return Err(Error::new( + std::io::ErrorKind::DirectoryNotEmpty, + "Please exit the current sheet before switching accounts", + )); + } + self.using_account = account; + Ok(()) + } + + /// Set the host mode + pub fn set_host_mode(&mut self, host_mode: bool) { + self.using_host_mode = host_mode; + } + + /// Set the currently used sheet + pub async fn use_sheet(&mut self, sheet: SheetName) -> Result<(), std::io::Error> { + let sheet = snake_case!(sheet); + + // Check if the sheet is already in use + if self.sheet_in_use().is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "Sheet already in use", + )); + }; + + // Check if the local path exists + let local_path = self.get_local_path().await?; + + // Get latest info + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_path, + &self.current_account(), + )) + .await + else { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "No latest info found", + )); + }; + + // Check if the sheet exists + if !latest_info.visible_sheets.contains(&sheet) { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Sheet not found", + )); + } + + // Check if there are any files or folders other than .jv + self.check_local_path_empty(&local_path).await?; + + // Get the draft folder path + let draft_folder = self.draft_folder(&self.using_account, &sheet, &local_path); + + if draft_folder.exists() { + // Exists + // Move the contents of the draft folder to the local path with rollback support + self.move_draft_to_local(&draft_folder, &local_path).await?; + } + + self.sheet_in_use = Some(sheet); + LocalConfig::write(self).await?; + + Ok(()) + } + + /// Exit the currently used sheet + pub async fn exit_sheet(&mut self) -> Result<(), std::io::Error> { + // Check if the sheet is already in use + if self.sheet_in_use().is_none() { + return Ok(()); + } + + // Check if the local path exists + let local_path = self.get_local_path().await?; + + // Get the current sheet name + let sheet_name = self.sheet_in_use().as_ref().unwrap().clone(); + + // Get the draft folder path + let draft_folder = self.draft_folder(&self.using_account, &sheet_name, &local_path); + + // Create the draft folder if it doesn't exist + if !draft_folder.exists() { + std::fs::create_dir_all(&draft_folder).map_err(std::io::Error::other)?; + } + + // Move all files and folders (except .jv folder) to the draft folder with rollback support + self.move_local_to_draft(&local_path, &draft_folder).await?; + + // Clear the sheet in use + self.sheet_in_use = None; + LocalConfig::write(self).await?; + + Ok(()) + } + + /// Get local path or return error + async fn get_local_path(&self) -> Result<PathBuf, std::io::Error> { + current_local_path().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "Fail to get local path") + }) + } + + /// Check if local path is empty (except for .jv folder) + async fn check_local_path_empty(&self, local_path: &Path) -> Result<(), std::io::Error> { + let jv_folder = local_path.join(CLIENT_PATH_WORKSPACE_ROOT); + let mut entries = std::fs::read_dir(local_path).map_err(std::io::Error::other)?; + + if entries.any(|entry| { + if let Ok(entry) = entry { + let path = entry.path(); + path != jv_folder + && path.file_name().and_then(|s| s.to_str()) + != Some(CLIENT_FOLDER_WORKSPACE_ROOT_NAME) + } else { + false + } + }) { + return Err(std::io::Error::new( + std::io::ErrorKind::DirectoryNotEmpty, + "Local path is not empty!", + )); + } + + Ok(()) + } + + /// Move contents from draft folder to local path with rollback support + async fn move_draft_to_local( + &self, + draft_folder: &Path, + local_path: &Path, + ) -> Result<(), std::io::Error> { + let draft_entries: Vec<_> = std::fs::read_dir(draft_folder) + .map_err(std::io::Error::other)? + .collect::<Result<Vec<_>, _>>() + .map_err(std::io::Error::other)?; + + let mut moved_items: Vec<MovedItem> = Vec::new(); + + for entry in &draft_entries { + let entry_path = entry.path(); + let target_path = local_path.join(entry_path.file_name().unwrap()); + + // Move each file/directory from draft folder to local path + std::fs::rename(&entry_path, &target_path).map_err(|e| { + // Rollback all previously moved items + for moved_item in &moved_items { + let _ = std::fs::rename(&moved_item.target, &moved_item.source); + } + std::io::Error::other(e) + })?; + + moved_items.push(MovedItem { + source: entry_path.clone(), + target: target_path.clone(), + }); + } + + // Remove the now-empty draft folder + std::fs::remove_dir(draft_folder).map_err(|e| { + // Rollback all moved items if folder removal fails + for moved_item in &moved_items { + let _ = std::fs::rename(&moved_item.target, &moved_item.source); + } + std::io::Error::other(e) + })?; + + Ok(()) + } + + /// Move contents from local path to draft folder with rollback support (except .jv folder) + async fn move_local_to_draft( + &self, + local_path: &Path, + draft_folder: &Path, + ) -> Result<(), std::io::Error> { + let jv_folder = local_path.join(CLIENT_PATH_WORKSPACE_ROOT); + let entries: Vec<_> = std::fs::read_dir(local_path) + .map_err(std::io::Error::other)? + .collect::<Result<Vec<_>, _>>() + .map_err(std::io::Error::other)?; + + let mut moved_items: Vec<MovedItem> = Vec::new(); + + for entry in &entries { + let entry_path = entry.path(); + + // Skip the .jv folder + if entry_path == jv_folder + || entry_path.file_name().and_then(|s| s.to_str()) + == Some(CLIENT_FOLDER_WORKSPACE_ROOT_NAME) + { + continue; + } + + let target_path = draft_folder.join(entry_path.file_name().unwrap()); + + // Move each file/directory from local path to draft folder + std::fs::rename(&entry_path, &target_path).map_err(|e| { + // Rollback all previously moved items + for moved_item in &moved_items { + let _ = std::fs::rename(&moved_item.target, &moved_item.source); + } + std::io::Error::other(e) + })?; + + moved_items.push(MovedItem { + source: entry_path.clone(), + target: target_path.clone(), + }); + } + + Ok(()) + } + + /// Get the currently used account + pub fn current_account(&self) -> MemberId { + self.using_account.clone() + } + + /// Check if the current member is interacting as a host. + pub fn is_host_mode(&self) -> bool { + self.using_host_mode + } + + /// Check if the local workspace is stained. + pub fn stained(&self) -> bool { + self.stained_uuid.is_some() + } + + /// Get the UUID of the vault that the local workspace is stained with. + pub fn stained_uuid(&self) -> Option<VaultUuid> { + self.stained_uuid + } + + /// Stain the local workspace with the given UUID. + pub fn stain(&mut self, uuid: VaultUuid) { + self.stained_uuid = Some(uuid); + } + + /// Unstain the local workspace. + pub fn unstain(&mut self) { + self.stained_uuid = None; + } + + /// Get the upstream address. + pub fn upstream_addr(&self) -> SocketAddr { + self.upstream_addr + } + + /// Get the currently used sheet + pub fn sheet_in_use(&self) -> &Option<SheetName> { + &self.sheet_in_use + } + + /// Get draft folder + pub fn draft_folder( + &self, + account: &MemberId, + sheet_name: &SheetName, + local_workspace_path: impl Into<PathBuf>, + ) -> PathBuf { + let account_str = snake_case!(account.as_str()); + let sheet_name_str = snake_case!(sheet_name.as_str()); + let draft_path = CLIENT_PATH_LOCAL_DRAFT + .replace(KEY_ACCOUNT, &account_str) + .replace(KEY_SHEET_NAME, &sheet_name_str); + local_workspace_path.into().join(draft_path) + } + + /// Get current draft folder + pub fn current_draft_folder(&self) -> Option<PathBuf> { + let Some(sheet_name) = self.sheet_in_use() else { + return None; + }; + + let current_dir = current_local_path()?; + + Some(self.draft_folder(&self.using_account, sheet_name, current_dir)) + } +} + +#[derive(Clone)] +struct MovedItem { + source: PathBuf, + target: PathBuf, +} |
