From 7b97b52af021500d8085c875d20215e8dc0f53cc Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Mon, 17 Nov 2025 11:49:49 +0800 Subject: feat: Add file status tracking and SHA1 hash system - Implement SHA1 hash calculation module with async support - Add file status analysis for tracking moves, creates, and modifications - Enhance local file management with relative path handling - Update virtual file actions with improved tracking capabilities --- crates/vcs_data/src/data/local.rs | 61 +++++- crates/vcs_data/src/data/local/cached_sheet.rs | 84 ++++++-- crates/vcs_data/src/data/local/file_status.rs | 282 +++++++++++++++++++++++++ crates/vcs_data/src/data/local/latest_info.rs | 48 ++++- crates/vcs_data/src/data/local/local_files.rs | 152 +++++++++++++ crates/vcs_data/src/data/local/local_sheet.rs | 207 +++++++++++++++--- crates/vcs_data/src/data/local/member_held.rs | 41 +++- crates/vcs_data/src/data/sheet.rs | 109 ++++++++-- crates/vcs_data/src/data/vault/sheets.rs | 1 + crates/vcs_data/src/data/vault/virtual_file.rs | 32 ++- 10 files changed, 936 insertions(+), 81 deletions(-) create mode 100644 crates/vcs_data/src/data/local/file_status.rs create mode 100644 crates/vcs_data/src/data/local/local_files.rs (limited to 'crates/vcs_data/src/data') diff --git a/crates/vcs_data/src/data/local.rs b/crates/vcs_data/src/data/local.rs index cbf41ba..cbf5b73 100644 --- a/crates/vcs_data/src/data/local.rs +++ b/crates/vcs_data/src/data/local.rs @@ -1,16 +1,25 @@ -use std::{collections::HashMap, env::current_dir, path::PathBuf, sync::Arc}; +use std::{ + collections::HashMap, + env::current_dir, + path::{Path, PathBuf}, + sync::Arc, +}; use cfg_file::config::ConfigFile; +use string_proc::format_path::format_path; use tokio::{fs, sync::Mutex}; use vcs_docs::docs::READMES_LOCAL_WORKSPACE_TODOLIST; use crate::{ - constants::{CLIENT_FILE_LOCAL_SHEET, CLIENT_FILE_TODOLIST, CLIENT_FILE_WORKSPACE}, + constants::{ + CLIENT_FILE_LOCAL_SHEET, CLIENT_FILE_TODOLIST, CLIENT_FILE_WORKSPACE, + CLIENT_PATH_LOCAL_SHEET, + }, current::{current_local_path, find_local_path}, data::{ local::{ config::LocalConfig, - local_sheet::{LocalSheet, LocalSheetData}, + local_sheet::{LocalSheet, LocalSheetData, LocalSheetPathBuf}, }, member::MemberId, sheet::SheetName, @@ -19,7 +28,9 @@ use crate::{ pub mod cached_sheet; pub mod config; +pub mod file_status; pub mod latest_info; +pub mod local_files; pub mod local_sheet; pub mod member_held; @@ -116,6 +127,7 @@ impl LocalWorkspace { if !local_sheet_path.exists() { let sheet_data = LocalSheetData { mapping: HashMap::new(), + vfs: HashMap::new(), }; LocalSheetData::write_to(&sheet_data, local_sheet_path).await?; return Ok(LocalSheet { @@ -136,6 +148,40 @@ impl LocalWorkspace { Ok(local_sheet) } + + /// Collect all theet names + pub async fn local_sheet_paths(&self) -> Result, std::io::Error> { + let local_sheet_path = self.local_path.join(CLIENT_PATH_LOCAL_SHEET); + let mut sheet_paths = Vec::new(); + + async fn collect_sheet_paths( + dir: &Path, + suffix: &str, + paths: &mut Vec, + ) -> Result<(), std::io::Error> { + if dir.is_dir() { + let mut entries = fs::read_dir(dir).await?; + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + + if path.is_dir() { + Box::pin(collect_sheet_paths(&path, suffix, paths)).await?; + } else if path.is_file() { + if let Some(extension) = path.extension() { + if extension == suffix.trim_start_matches('.') { + let formatted_path = format_path(path)?; + paths.push(formatted_path); + } + } + } + } + } + Ok(()) + } + + collect_sheet_paths(&local_sheet_path, ".json", &mut sheet_paths).await?; + Ok(sheet_paths) + } } mod hide_folder { @@ -145,7 +191,7 @@ mod hide_folder { #[cfg(windows)] use std::os::windows::ffi::OsStrExt; #[cfg(windows)] - use winapi::um::fileapi::{GetFileAttributesW, SetFileAttributesW, INVALID_FILE_ATTRIBUTES}; + use winapi::um::fileapi::{GetFileAttributesW, INVALID_FILE_ATTRIBUTES, SetFileAttributesW}; pub fn hide_folder(path: &Path) -> io::Result<()> { if !path.is_dir() { @@ -175,10 +221,7 @@ mod hide_folder { #[cfg(windows)] fn hide_folder_impl(path: &Path) -> io::Result<()> { // Convert to Windows wide string format - let path_str: Vec = path.as_os_str() - .encode_wide() - .chain(Some(0)) - .collect(); + let path_str: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); // Get current attributes let attrs = unsafe { GetFileAttributesW(path_str.as_ptr()) }; @@ -210,4 +253,4 @@ mod hide_folder { "Unsupported operating system", )) } -} \ No newline at end of file +} diff --git a/crates/vcs_data/src/data/local/cached_sheet.rs b/crates/vcs_data/src/data/local/cached_sheet.rs index 0f4eee9..e617922 100644 --- a/crates/vcs_data/src/data/local/cached_sheet.rs +++ b/crates/vcs_data/src/data/local/cached_sheet.rs @@ -1,17 +1,19 @@ use std::{io::Error, path::PathBuf}; use cfg_file::config::ConfigFile; -use string_proc::snake_case; +use string_proc::{format_path::format_path, snake_case}; +use tokio::fs; use crate::{ - constants::CLIENT_FILE_CACHED_SHEET, - current::current_local_path, - data::{ - member::MemberId, - sheet::{SheetData, SheetName}, + constants::{ + CLIENT_FILE_CACHED_SHEET, CLIENT_PATH_CACHED_SHEET, CLIENT_SUFFIX_CACHED_SHEET_FILE, }, + current::current_local_path, + data::sheet::{SheetData, SheetName}, }; +pub type CachedSheetPathBuf = PathBuf; + const SHEET_NAME: &str = "{sheet_name}"; const ACCOUNT_NAME: &str = "{account}"; @@ -23,14 +25,10 @@ pub struct CachedSheet; impl CachedSheet { /// Read the cached sheet data. - pub async fn cached_sheet_data( - account_name: MemberId, - sheet_name: SheetName, - ) -> Result { - let account_name = snake_case!(account_name); - let sheet_name = snake_case!(sheet_name); - - let Some(path) = Self::cached_sheet_path(account_name, sheet_name) else { + pub async fn cached_sheet_data(sheet_name: &SheetName) -> Result { + 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!", @@ -41,14 +39,60 @@ impl CachedSheet { } /// Get the path to the cached sheet file. - pub fn cached_sheet_path(account_name: MemberId, sheet_name: SheetName) -> Option { + pub fn cached_sheet_path(sheet_name: SheetName) -> Option { let current_workspace = current_local_path()?; Some( - current_workspace.join( - CLIENT_FILE_CACHED_SHEET - .replace(SHEET_NAME, &sheet_name.to_string()) - .replace(ACCOUNT_NAME, &account_name.to_string()), - ), + current_workspace + .join(CLIENT_FILE_CACHED_SHEET.replace(SHEET_NAME, &sheet_name.to_string())), ) } + + /// Get all cached sheet names + pub async fn cached_sheet_names() -> Result, 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() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if 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, 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() { + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + if file_name.ends_with(CLIENT_SUFFIX_CACHED_SHEET_FILE) { + sheet_paths.push(format_path(workspace_path.join(path))?); + } + } + } + } + + Ok(sheet_paths) + } } diff --git a/crates/vcs_data/src/data/local/file_status.rs b/crates/vcs_data/src/data/local/file_status.rs new file mode 100644 index 0000000..c37c21b --- /dev/null +++ b/crates/vcs_data/src/data/local/file_status.rs @@ -0,0 +1,282 @@ +use std::{ + collections::{HashMap, HashSet}, + io::Error, + path::PathBuf, +}; + +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, + + /// Newly created local files + pub created: HashSet, + + /// Lost local files + pub lost: HashSet, + + /// 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, +} + +struct AnalyzeContext<'a> { + member: MemberId, + sheet_name: SheetName, + local_sheet: Option>, + cached_sheet_data: Option, +} + +impl<'a> AnalyzeResult<'a> { + /// Analyze all files, calculate the file information provided + pub async fn analyze_local_status( + local_workspace: &'a LocalWorkspace, + ) -> Result, std::io::Error> { + // Workspace + let workspace = local_workspace; + + // Current member, sheet + let (member, sheet_name) = { + let lock = workspace.config.lock().await; + let member = lock.current_account(); + let Some(sheet) = lock.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() { + if 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 = match workspace.local_sheet(&member, &sheet_name).await { + Ok(v) => Some(v), + Err(_) => None, + }; + + // 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).await?; + Self::analyze_modified(&mut result, &file_relative_paths, &mut analyze_ctx).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, + analyze_ctx: &AnalyzeContext<'a>, + ) -> 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(); + + // 在本地表存在但实际不存在的文件,为丢失 + let mut lost_files: HashSet<&PathBuf> = local_sheet_paths + .difference(&file_relative_paths_ref) + .cloned() + .collect(); + + // 在本地表不存在但实际存在的文件,记录为新建 + let mut new_files: HashSet<&PathBuf> = file_relative_paths_ref + .difference(&local_sheet_paths) + .cloned() + .collect(); + + // 计算新增的文件 Hash + let new_files_for_hash: Vec = new_files.iter().map(|p| (*p).clone()).collect(); + let file_hashes: HashSet<(PathBuf, String)> = + match calc_sha1_multi::>(new_files_for_hash, 8192).await { + Ok(hash) => hash, + Err(e) => return Err(Error::new(std::io::ErrorKind::Other, e)), + } + .iter() + .map(|r| (r.file_path.clone(), r.hash.to_string())) + .collect(); + + // 建立丢失文件的 Hash 映射表 + let mut lost_files_hash_mapping: HashMap = + match &analyze_ctx.local_sheet { + Some(local_sheet) => lost_files + .iter() + .filter_map(|f| { + local_sheet.mapping_data(f).ok().map(|mapping_data| { + (mapping_data.hash_when_updated.clone(), (*f).clone()) + }) + }) + .collect(), + None => HashMap::new(), + }; + + // 如果这些 Hash 能对应缺失文件的 Hash,那么这对新增和丢失项将被合并为移动项 + let mut moved_files: HashSet<(FromRelativePathBuf, ToRelativePathBuf)> = HashSet::new(); + for (new_path, new_hash) in file_hashes { + // 如果新的 Hash 值命中映射,则添加移动项 + if let Some(lost_path) = lost_files_hash_mapping.remove(&new_hash) { + // 移除该新增项和丢失项 + lost_files.remove(&lost_path); + new_files.remove(&new_path); + + // 建立移动项 + moved_files.insert((lost_path.clone(), new_path)); + } + } + + // 进入模糊匹配,将其他未匹配的可能移动项进行匹配 + // 如果 新增 和 缺失 数量总和能被 2 整除,则说明还存在文件被移动的可能,考虑尝试模糊匹配 + if new_files.len() + lost_files.len() % 2 == 0 { + // 尝试模糊匹配 + // ... + } + + // 将结果收集,并设置结果 + 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()); + if let Some(vfid) = vfid { + Some((vfid, (from.clone(), to.clone()))) + } else { + None + } + }) + .collect(); + + 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, + analyze_ctx: &mut AnalyzeContext<'a>, + ) -> 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(path, 2048).await { + Ok(hash) => hash, + Err(e) => return Err(Error::new(std::io::ErrorKind::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_modifiy_check_time = modified_time; + mapping_data.last_modifiy_check_result = true; + } else { + // Update last modified check time to modified time + mapping_data.last_modifiy_check_time = modified_time; + mapping_data.last_modifiy_check_result = false; + } + } + + // 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: local_workspace, + moved: HashMap::new(), + created: HashSet::new(), + lost: HashSet::new(), + modified: HashSet::new(), + } + } +} diff --git a/crates/vcs_data/src/data/local/latest_info.rs b/crates/vcs_data/src/data/local/latest_info.rs index e8fa641..e4f45b1 100644 --- a/crates/vcs_data/src/data/local/latest_info.rs +++ b/crates/vcs_data/src/data/local/latest_info.rs @@ -1,5 +1,8 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + use cfg_file::ConfigFile; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use tokio::time::Instant; use crate::{ constants::CLIENT_FILE_LATEST_INFO, @@ -23,6 +26,13 @@ pub struct LatestInfo { /// Reference sheet data, indicating what files I can get from the reference sheet pub ref_sheet_content: SheetData, + /// Update instant + #[serde( + serialize_with = "serialize_instant", + deserialize_with = "deserialize_instant" + )] + pub update_instant: Option, + // Members /// All member information of the vault, allowing me to contact them more conveniently pub vault_members: Vec, @@ -33,3 +43,39 @@ pub struct SheetInfo { pub sheet_name: SheetName, pub holder_name: Option, } + +fn serialize_instant(instant: &Option, serializer: S) -> Result +where + S: Serializer, +{ + let system_now = SystemTime::now(); + let instant_now = Instant::now(); + let duration_since_epoch = instant + .as_ref() + .and_then(|i| i.checked_duration_since(instant_now)) + .map(|d| system_now.checked_add(d)) + .unwrap_or(Some(system_now)) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .unwrap_or_else(|| SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + + serializer.serialize_u64(duration_since_epoch.as_millis() as u64) +} + +fn deserialize_instant<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let millis = u64::deserialize(deserializer)?; + let duration_since_epoch = std::time::Duration::from_millis(millis); + let system_time = UNIX_EPOCH + duration_since_epoch; + let now_system = SystemTime::now(); + let now_instant = Instant::now(); + + if let Ok(elapsed) = system_time.elapsed() { + Ok(Some(now_instant - elapsed)) + } else if let Ok(duration_until) = system_time.duration_since(now_system) { + Ok(Some(now_instant + duration_until)) + } else { + Ok(Some(now_instant)) + } +} diff --git a/crates/vcs_data/src/data/local/local_files.rs b/crates/vcs_data/src/data/local/local_files.rs new file mode 100644 index 0000000..1599e34 --- /dev/null +++ b/crates/vcs_data/src/data/local/local_files.rs @@ -0,0 +1,152 @@ +use std::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, +} + +impl IntoIterator for RelativeFiles { + type Item = PathBuf; + type IntoIter = std::vec::IntoIter; + + 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: Vec) -> Option { + // Get Relative Paths + let Ok(paths) = format_input_paths_and_ignore_outside_paths(&local_path, &paths).await else { + return None; + }; + let files: Vec = 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: &PathBuf, + track_files: &[PathBuf], +) -> Result, 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() + .map_or(false, |s| s == 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, std::io::Error> { + let result = format_input_paths(local_path, files).await?; + let result: Vec = 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, +) -> Result, std::io::Error> { + let result: Result, _> = 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(); + + match result { + Ok(paths) => Ok(paths), + Err(e) => Err(e), + } +} + +/// Convert absolute paths to absolute file paths, expanding directories to their contained files +async fn abs_paths_to_abs_files(paths: Vec) -> Vec { + 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/crates/vcs_data/src/data/local/local_sheet.rs b/crates/vcs_data/src/data/local/local_sheet.rs index bfe8d01..980fa49 100644 --- a/crates/vcs_data/src/data/local/local_sheet.rs +++ b/crates/vcs_data/src/data/local/local_sheet.rs @@ -1,8 +1,7 @@ -use std::{collections::HashMap, io::Error, path::PathBuf}; +use std::{collections::HashMap, io::Error, path::PathBuf, time::SystemTime}; use ::serde::{Deserialize, Serialize}; use cfg_file::{ConfigFile, config::ConfigFile}; -use chrono::NaiveDate; use string_proc::format_path::format_path; use crate::{ @@ -10,11 +9,13 @@ use crate::{ data::{ local::LocalWorkspace, member::MemberId, - vault::virtual_file::{VirtualFileId, VirtualFileVersionDescription}, + 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, @@ -27,54 +28,168 @@ pub struct LocalSheet<'a> { pub(crate) data: LocalSheetData, } -#[derive(Debug, Default, Serialize, Deserialize, ConfigFile)] +#[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 virtual file ID mapping. + /// Local file path to metadata mapping. #[serde(rename = "mapping")] - pub(crate) mapping: HashMap, // Path to VFID + pub(crate) mapping: HashMap, + + pub(crate) vfs: HashMap, } -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct MappingMetaData { +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct LocalMappingMetadata { /// Hash value generated immediately after the file is downloaded to the local workspace #[serde(rename = "hash")] pub(crate) hash_when_updated: String, /// Time when the file was downloaded to the local workspace - #[serde(rename = "date", with = "naive_date_serde")] - pub(crate) date_when_updated: NaiveDate, + 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 = "version")] + #[serde(rename = "version_desc")] pub(crate) version_desc_when_updated: VirtualFileVersionDescription, + /// Version when the file was downloaded to the local workspace + #[serde(rename = "version")] + 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 + pub(crate) last_modifiy_check_time: SystemTime, + + /// Latest modifiy check result + pub(crate) last_modifiy_check_result: bool, +} + +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 + 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_modifiy_check_time, + last_modifiy_check_result, + } + } + + /// Getter for hash_when_updated + pub fn hash_when_updated(&self) -> &String { + &self.hash_when_updated + } + + /// Getter for date_when_updated + pub fn time_when_updated(&self) -> &SystemTime { + &self.time_when_updated + } + + /// Getter for size_when_updated + pub fn size_when_updated(&self) -> u64 { + self.size_when_updated + } + + /// Getter for version_desc_when_updated + pub fn version_desc_when_updated(&self) -> &VirtualFileVersionDescription { + &self.version_desc_when_updated + } + + /// Getter for version_when_updated + pub fn version_when_updated(&self) -> &VirtualFileVersion { + &self.version_when_updated + } + + /// Getter for mapping_vfid + pub fn mapping_vfid(&self) -> &VirtualFileId { + &self.mapping_vfid + } + + /// Getter for last_modifiy_check_time + pub fn last_modifiy_check_time(&self) -> &SystemTime { + &self.last_modifiy_check_time + } + + /// Getter for last_modifiy_check_result + pub fn last_modifiy_check_result(&self) -> bool { + self.last_modifiy_check_result + } +} + +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_modifiy_check_time: SystemTime::now(), + last_modifiy_check_result: false, + } + } } -mod naive_date_serde { - use chrono::NaiveDate; +mod instant_serde { use serde::{self, Deserialize, Deserializer, Serializer}; + use tokio::time::Instant; - pub fn serialize(date: &NaiveDate, serializer: S) -> Result + pub fn serialize(instant: &Instant, serializer: S) -> Result where S: Serializer, { - serializer.serialize_str(&date.format("%Y-%m-%d").to_string()) + serializer.serialize_u64(instant.elapsed().as_secs()) } - pub fn deserialize<'de, D>(deserializer: D) -> Result + pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { - let s = String::deserialize(deserializer)?; - NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(serde::de::Error::custom) + 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 } } @@ -83,10 +198,12 @@ impl<'a> LocalSheet<'a> { pub fn add_mapping( &mut self, path: LocalFilePathBuf, - mapping: MappingMetaData, + mapping: LocalMappingMetadata, ) -> Result<(), std::io::Error> { let path = format_path(path)?; - if self.data.mapping.contains_key(&path) { + if self.data.mapping.contains_key(&path) + || self.data.vfs.contains_key(&mapping.mapping_vfid) + { return Err(Error::new( std::io::ErrorKind::AlreadyExists, "Mapping already exists", @@ -100,8 +217,8 @@ impl<'a> LocalSheet<'a> { /// Move mapping to other path pub fn move_mapping( &mut self, - from: LocalFilePathBuf, - to: LocalFilePathBuf, + from: &LocalFilePathBuf, + to: &LocalFilePathBuf, ) -> Result<(), std::io::Error> { let from = format_path(from)?; let to = format_path(to)?; @@ -124,11 +241,26 @@ impl<'a> LocalSheet<'a> { Ok(()) } + /// 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.data.mapping.get(&path) else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Path is not found.", + )); + }; + Ok(data) + } + /// Get muttable mapping data pub fn mapping_data_mut( &mut self, - path: LocalFilePathBuf, - ) -> Result<&mut MappingMetaData, std::io::Error> { + path: &LocalFilePathBuf, + ) -> Result<&mut LocalMappingMetadata, std::io::Error> { let path = format_path(path)?; let Some(data) = self.data.mapping.get_mut(&path) else { return Err(Error::new( @@ -139,12 +271,31 @@ impl<'a> LocalSheet<'a> { Ok(data) } - /// Persist the sheet to disk - pub async fn persist(&mut self) -> Result<(), std::io::Error> { - let _path = self + /// 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); - LocalSheetData::write_to(&self.data, &self.sheet_name).await?; + self.write_to_path(path).await + } + + /// Write the sheet to custom path + pub async fn write_to_path(&mut self, path: impl Into) -> Result<(), std::io::Error> { + let path = path.into(); + + self.data.vfs = HashMap::new(); + for (path, mapping) in self.data.mapping.iter() { + self.data + .vfs + .insert(mapping.mapping_vfid.clone(), path.clone()); + } + + LocalSheetData::write_to(&self.data, path).await?; Ok(()) } + + /// Get path by VirtualFileId + pub fn path_by_id(&self, vfid: &VirtualFileId) -> Option<&PathBuf> { + self.data.vfs.get(vfid) + } } diff --git a/crates/vcs_data/src/data/local/member_held.rs b/crates/vcs_data/src/data/local/member_held.rs index 37bc18e..3f07232 100644 --- a/crates/vcs_data/src/data/local/member_held.rs +++ b/crates/vcs_data/src/data/local/member_held.rs @@ -1,13 +1,16 @@ -use std::collections::HashMap; +use std::{collections::HashMap, io::Error, path::PathBuf}; use cfg_file::ConfigFile; use serde::{Deserialize, Serialize}; use crate::{ - constants::CLIENT_FILE_MEMBER_HELD_NOSET, + constants::{CLIENT_FILE_MEMBER_HELD, CLIENT_FILE_MEMBER_HELD_NOSET}, + current::current_local_path, data::{member::MemberId, vault::virtual_file::VirtualFileId}, }; +const ACCOUNT: &str = "{account}"; + /// # Member Held Information /// Records the files held by the member, used for permission validation #[derive(Debug, Default, Clone, Serialize, Deserialize, ConfigFile)] @@ -25,3 +28,37 @@ pub enum HeldStatus { #[default] WantedToKnow, // Holding status is unknown, notify server must inform client } + +impl MemberHeld { + /// Get the path to the file holding the held status information for the given member. + pub fn held_file_path(account: &MemberId) -> Result { + 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_MEMBER_HELD.replace(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, + }) + } + + /// Update the held status of the files. + pub fn update_held_status(&mut self, map: HashMap>) { + for (vfid, member_id) in map { + self.held_status.insert( + vfid, + match member_id { + Some(member_id) => HeldStatus::HeldWith(member_id), + None => HeldStatus::NotHeld, + }, + ); + } + } +} diff --git a/crates/vcs_data/src/data/sheet.rs b/crates/vcs_data/src/data/sheet.rs index 09271d5..9e3a1d7 100644 --- a/crates/vcs_data/src/data/sheet.rs +++ b/crates/vcs_data/src/data/sheet.rs @@ -8,7 +8,10 @@ use crate::{ constants::SERVER_FILE_SHEET, data::{ member::MemberId, - vault::{Vault, virtual_file::VirtualFileId}, + vault::{ + Vault, + virtual_file::{VirtualFileId, VirtualFileVersion}, + }, }, }; @@ -26,7 +29,7 @@ pub struct InputPackage { pub from: SheetName, /// Files in this input package with their relative paths and virtual file IDs - pub files: Vec<(InputRelativePathBuf, VirtualFileId)>, + pub files: Vec<(InputRelativePathBuf, SheetMappingMetadata)>, } impl PartialEq for InputPackage { @@ -60,7 +63,16 @@ pub struct SheetData { pub(crate) inputs: Vec, /// Mapping of sheet paths to virtual file IDs - pub(crate) mapping: HashMap, + pub(crate) mapping: HashMap, + + /// Mapping of virtual file Ids to sheet paths + pub(crate) id_mapping: Option>, +} + +#[derive(Debug, Default, Serialize, Deserialize, ConfigFile, Clone, Eq, PartialEq)] +pub struct SheetMappingMetadata { + pub id: VirtualFileId, + pub version: VirtualFileVersion, } impl<'a> Sheet<'a> { @@ -88,10 +100,15 @@ impl<'a> Sheet<'a> { } /// Get the mapping of this sheet - pub fn mapping(&self) -> &HashMap { + pub fn mapping(&self) -> &HashMap { &self.data.mapping } + /// Get the id_mapping of this sheet data + pub fn id_mapping(&self) -> &Option> { + &self.data.id_mapping + } + /// Get the write count of this sheet pub fn write_count(&self) -> i32 { self.data.write_count @@ -150,9 +167,13 @@ impl<'a> Sheet<'a> { }; // Insert to sheet - for (relative_path, virtual_file_id) in input.files { - self.add_mapping(insert_to.join(relative_path), virtual_file_id) - .await?; + for (relative_path, virtual_file_meta) in input.files { + self.add_mapping( + insert_to.join(relative_path), + virtual_file_meta.id, + virtual_file_meta.version, + ) + .await?; } Ok(()) @@ -172,11 +193,18 @@ impl<'a> Sheet<'a> { &mut self, sheet_path: SheetPathBuf, virtual_file_id: VirtualFileId, + version: VirtualFileVersion, ) -> Result<(), std::io::Error> { // Check if the virtual file exists in the vault if self.vault_reference.virtual_file(&virtual_file_id).is_err() { // Virtual file doesn't exist, add the mapping directly - self.data.mapping.insert(sheet_path, virtual_file_id); + self.data.mapping.insert( + sheet_path, + SheetMappingMetadata { + id: virtual_file_id, + version: version, + }, + ); return Ok(()); } @@ -194,16 +222,22 @@ impl<'a> Sheet<'a> { .has_virtual_file_edit_right(holder, &virtual_file_id) .await { - Ok(false) => { - // Holder doesn't have rights, add the mapping (member is giving up the file) - self.data.mapping.insert(sheet_path, virtual_file_id); + Ok(true) => { + // Holder has edit rights, add the mapping (member has permission to modify the file) + self.data.mapping.insert( + sheet_path, + SheetMappingMetadata { + id: virtual_file_id, + version, + }, + ); Ok(()) } - Ok(true) => { - // Holder has edit rights, don't allow modifying the mapping + Ok(false) => { + // Holder doesn't have edit rights, don't allow modifying the mapping Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, - "Member has edit rights to the virtual file, cannot modify mapping", + "Member doesn't have edit rights to the virtual file, cannot modify mapping", )) } Err(_) => { @@ -224,8 +258,11 @@ impl<'a> Sheet<'a> { /// 4. If member has no edit rights and the file exists, returns the removed virtual file ID /// /// Note: Full validation adds overhead - avoid frequent calls - pub async fn remove_mapping(&mut self, sheet_path: &SheetPathBuf) -> Option { - let virtual_file_id = match self.data.mapping.get(sheet_path) { + pub async fn remove_mapping( + &mut self, + sheet_path: &SheetPathBuf, + ) -> Option { + let virtual_file_meta = match self.data.mapping.get(sheet_path) { Some(id) => id, None => { // The mapping entry doesn't exist, nothing to remove @@ -234,7 +271,11 @@ impl<'a> Sheet<'a> { }; // Check if the virtual file exists in the vault - if self.vault_reference.virtual_file(virtual_file_id).is_err() { + if self + .vault_reference + .virtual_file(&virtual_file_meta.id) + .is_err() + { // Virtual file doesn't exist, remove the mapping and return None self.data.mapping.remove(sheet_path); return None; @@ -248,7 +289,7 @@ impl<'a> Sheet<'a> { // Check if the holder has edit rights to the virtual file match self .vault_reference - .has_virtual_file_edit_right(holder, virtual_file_id) + .has_virtual_file_edit_right(holder, &virtual_file_meta.id) .await { Ok(false) => { @@ -273,6 +314,18 @@ impl<'a> Sheet<'a> { /// If needed, please deserialize and reload it. pub async fn persist(mut self) -> Result<(), std::io::Error> { self.data.write_count += 1; + + // Update id mapping + self.data.id_mapping = Some(HashMap::new()); + for map in self.data.mapping.iter() { + self.data + .id_mapping + .as_mut() + .unwrap() + .insert(map.1.id.clone(), map.0.clone()); + } + + // Add write count if self.data.write_count > i32::MAX { self.data.write_count = 0; } @@ -402,4 +455,24 @@ impl SheetData { pub fn write_count(&self) -> i32 { self.write_count } + + /// Get the holder of this sheet data + pub fn holder(&self) -> Option<&MemberId> { + self.holder.as_ref() + } + + /// Get the inputs of this sheet data + pub fn inputs(&self) -> &Vec { + &self.inputs + } + + /// Get the mapping of this sheet data + pub fn mapping(&self) -> &HashMap { + &self.mapping + } + + /// Get the id_mapping of this sheet data + pub fn id_mapping(&self) -> &Option> { + &self.id_mapping + } } diff --git a/crates/vcs_data/src/data/vault/sheets.rs b/crates/vcs_data/src/data/vault/sheets.rs index ba021b5..cea7271 100644 --- a/crates/vcs_data/src/data/vault/sheets.rs +++ b/crates/vcs_data/src/data/vault/sheets.rs @@ -133,6 +133,7 @@ impl Vault { holder: Some(holder.clone()), inputs: Vec::new(), mapping: HashMap::new(), + id_mapping: None, write_count: 0, }; SheetData::write_to(&sheet_data, sheet_file_path).await?; diff --git a/crates/vcs_data/src/data/vault/virtual_file.rs b/crates/vcs_data/src/data/vault/virtual_file.rs index 221766f..6dd5208 100644 --- a/crates/vcs_data/src/data/vault/virtual_file.rs +++ b/crates/vcs_data/src/data/vault/virtual_file.rs @@ -22,7 +22,7 @@ use crate::{ pub type VirtualFileId = String; pub type VirtualFileVersion = String; -const VF_PREFIX: &str = "vf_"; +const VF_PREFIX: &str = "vf-"; const ID_PARAM: &str = "{vf_id}"; const ID_INDEX: &str = "{vf_index}"; const VERSION_PARAM: &str = "{vf_version}"; @@ -244,8 +244,6 @@ impl Vault { } fs::rename(receive_path, move_path).await?; - // - Ok(new_id) } Err(e) => { @@ -444,6 +442,13 @@ impl VirtualFileMeta { &self.histories } + /// Get the latest version of the virtual file + pub fn version_latest(&self) -> VirtualFileVersion { + // After creating a virtual file in `update_virtual_file_from_connection`, + // the Vec will never be empty, so unwrap is allowed here + self.histories.last().unwrap().clone() + } + /// Get the total number of versions for this virtual file pub fn version_len(&self) -> i32 { self.histories.len() as i32 @@ -470,4 +475,25 @@ impl VirtualFileMeta { pub fn version_name(&self, version_num: i32) -> Option { self.histories.get(version_num as usize).cloned() } + + /// Get the member who holds the edit right of the file + pub fn hold_member(&self) -> &MemberId { + &self.hold_member + } + + /// Get the version descriptions for all versions + pub fn version_descriptions( + &self, + ) -> &HashMap { + &self.version_description + } + + /// Get the version description for a given version + pub fn version_description( + &self, + version: VirtualFileVersion, + ) -> Option<&VirtualFileVersionDescription> { + let desc = self.version_descriptions(); + desc.get(&version) + } } -- cgit