From 4c369e76d8db8548f103c2b9401b953b48dfafb7 Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Thu, 22 Jan 2026 04:10:06 +0800 Subject: Rename jv binary to jv_legacy --- src/bin/jv_legacy.rs | 6185 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 6185 insertions(+) create mode 100644 src/bin/jv_legacy.rs (limited to 'src/bin/jv_legacy.rs') diff --git a/src/bin/jv_legacy.rs b/src/bin/jv_legacy.rs new file mode 100644 index 0000000..be6d0c1 --- /dev/null +++ b/src/bin/jv_legacy.rs @@ -0,0 +1,6185 @@ +use colored::Colorize; +use just_enough_vcs::{ + data::compile_info::CoreCompileInfo, + system::action_system::{action::ActionContext, action_pool::ActionPool}, + utils::{ + cfg_file::config::ConfigFile, + data_struct::data_sort::quick_sort_with_cmp, + sha1_hash, + string_proc::{ + self, + format_path::{format_path, format_path_str}, + snake_case, + }, + tcp_connection::instance::ConnectionInstance, + }, + vcs::{ + constants::{ + CLIENT_FILE_TODOLIST, CLIENT_FILE_WORKSPACE, CLIENT_FOLDER_WORKSPACE_ROOT_NAME, + CLIENT_PATH_WORKSPACE_ROOT, PORT, VAULT_HOST_NAME, + }, + data::{ + local::{ + LocalWorkspace, + align_tasks::{AlignTaskName, AlignTasks}, + cached_sheet::CachedSheet, + latest_file_data::LatestFileData, + latest_info::LatestInfo, + modified_status::check_vault_modified, + workspace_analyzer::{AnalyzeResult, FromRelativePathBuf}, + workspace_config::LocalConfig, + }, + member::{Member, MemberId}, + sheet::{SheetData, SheetMappingMetadata}, + user::UserDirectory, + vault::{ + mapping_share::{Share, ShareMergeMode}, + virtual_file::{VirtualFileId, VirtualFileVersion}, + }, + }, + docs::{ASCII_YIZI, document, documents}, + env::{correct_current_dir, current_cfg_dir, current_local_path}, + registry::client_registry, + remote_actions::{ + content_manage::track_file::{ + CreateTaskResult, NextVersion, SyncTaskResult, TrackFileActionArguments, + TrackFileActionResult, UpdateDescription, UpdateTaskResult, VerifyFailReason, + proc_track_file_action, + }, + edit_right_manage::change_virtual_file_edit_right::{ + ChangeVirtualFileEditRightResult, EditRightChangeBehaviour, + proc_change_virtual_file_edit_right_action, + }, + mapping_manage::{ + edit_mapping::{ + EditMappingActionArguments, EditMappingActionResult, EditMappingOperations, + InvalidMoveReason, OperationArgument, proc_edit_mapping_action, + }, + merge_share_mapping::{ + MergeShareMappingActionResult, MergeShareMappingArguments, + proc_merge_share_mapping_action, + }, + share_mapping::{ + ShareMappingActionResult, ShareMappingArguments, proc_share_mapping_action, + }, + }, + sheet_manage::{ + drop_sheet::{DropSheetActionResult, proc_drop_sheet_action}, + make_sheet::{MakeSheetActionResult, proc_make_sheet_action}, + }, + workspace_manage::{ + set_upstream_vault::{ + SetUpstreamVaultActionResult, proc_set_upstream_vault_action, + }, + update_to_latest_info::{ + SyncCachedSheetFailReason, UpdateToLatestInfoResult, + proc_update_to_latest_info_action, + }, + }, + }, + }, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env::{current_dir, set_current_dir}, + io::Error, + net::SocketAddr, + path::PathBuf, + process::exit, + str::FromStr, + sync::Arc, + time::SystemTime, +}; + +use clap::{Parser, Subcommand}; +use just_enough_vcs::utils::tcp_connection::error::TcpTargetError; +use just_enough_vcs_cli::{ + data::{ + compile_info::CompileInfo, + ipaddress_history::{get_recent_ip_address, insert_recent_ip_address}, + }, + output::{ + accounts::{AccountItem, AccountListJsonResult}, + align::{AlignJsonResult, AlignTaskMapping}, + analyzer_result::{AnalyzerJsonResult, ModifiedItem, ModifiedType, MovedItem}, + here::{HereJsonResult, HereJsonResultItem}, + info::{InfoHistory, InfoJsonResult}, + share::{SeeShareResult, ShareItem, ShareListResult}, + sheets::{SheetItem, SheetListJsonResult}, + }, + utils::{ + display::{SimpleTable, display_width, md, render_share_path_tree, size_str}, + env::{auto_update_outdate, current_locales, enable_auto_update}, + fs::move_across_partitions, + globber::{GlobItem, Globber}, + input::{confirm_hint, confirm_hint_or, input_with_editor, show_in_pager}, + push_version::push_version, + socket_addr_helper, + }, +}; +use rust_i18n::{set_locale, t}; +use tokio::{ + fs::{self}, + net::TcpSocket, + process::Command, + sync::mpsc::{self, Receiver}, +}; + +// Import i18n files +rust_i18n::i18n!("resources/locales", fallback = "en"); + +#[derive(Parser, Debug)] +#[command( + disable_help_flag = true, + disable_version_flag = true, + disable_help_subcommand = true, + help_template = "{all-args}" +)] + +struct JustEnoughVcsWorkspace { + #[command(subcommand)] + command: JustEnoughVcsWorkspaceCommand, +} + +#[derive(Subcommand, Debug)] +enum JustEnoughVcsWorkspaceCommand { + /// Version information + #[command(alias = "--version", alias = "-v")] + Version(VersionArgs), + + /// Display help information + #[command(alias = "--help", alias = "-h")] + Help, + + // Member management + /// Manage your local accounts + #[command(subcommand, alias = "acc")] + Account(AccountManage), + + /// Create an empty workspace + Create(CreateWorkspaceArgs), + + /// Create an empty workspace in the current directory + Init(InitWorkspaceArgs), + + /// Get workspace information in the current directory + #[command(alias = "h")] + Here(HereArgs), + + /// Display current sheet status information + #[command(alias = "s")] + Status(StatusArgs), + + /// Display detailed information about the specified file + Info(InfoArgs), + + // Sheet management + /// Manage sheets in the workspace + #[command(subcommand, alias = "sh")] + Sheet(SheetManage), + + // File management + /// Track files to the upstream vault + /// First track - Create and upload the "First Version", then hold them + /// Subsequent tracks - Update files with new versions + #[command(alias = "t")] + Track(TrackFileArgs), + + /// Hold files for editing + #[command(alias = "hd")] + Hold(HoldFileArgs), + + /// Throw files, and release edit rights + #[command(alias = "tr")] + Throw(ThrowFileArgs), + + /// Move or rename files safely + #[command(alias = "mv")] + Move(MoveMappingArgs), + + /// Share file visibility to other sheets + Share(ShareMappingArgs), + + /// Sync information from upstream vault + #[command(alias = "u")] + Update(UpdateArgs), + + // Connection management + /// Direct to an upstream vault and stain this workspace + Direct(DirectArgs), + + /// DANGER ZONE : Unstain this workspace + Unstain(UnstainArgs), + + // Other + /// Query built-in documentation + Docs(DocsArgs), + + // Lazy commands + /// Try exit current sheet + Exit, + + /// Try exit current sheet and use another sheet + Use(UseArgs), + + /// List all sheets + Sheets, + + /// List all accounts + Accounts, + + /// Align file structure + Align(SheetAlignArgs), + + /// Set current local workspace account + As(SetLocalWorkspaceAccountArgs), + + /// Make a new sheet + Make(SheetMakeArgs), + + /// Drop a sheet + Drop(SheetDropArgs), + + /// As Member, Direct, and Update + #[command(alias = "signin")] + Login(LoginArgs), + + // Completion Helpers + /// Display History IP Address + #[command(name = "_ip_history")] + GetHistoryIpAddress, + + /// Display Workspace Directory + #[command(name = "_workspace_dir")] + GetWorkspaceDir, + + /// Display Current Account + #[command(name = "_account")] + GetCurrentAccount, + + /// Display Current Upstream Vault + #[command(name = "_upstream")] + GetCurrentUpstream, + + /// Display Current Sheet + #[command(name = "_sheet")] + GetCurrentSheet, + + // Debug Tools + #[command(name = "_glob")] + DebugGlob(DebugGlobArgs), +} + +#[derive(Parser, Debug)] +struct VersionArgs { + #[arg(short = 'C', long = "compile-info")] + compile_info: bool, + + #[arg(long)] + without_banner: bool, +} + +#[derive(Subcommand, Debug)] +enum AccountManage { + /// Show help information + #[command(alias = "--help", alias = "-h")] + Help, + + /// Register a member to this computer + #[command(alias = "+")] + Add(AccountAddArgs), + + /// Remove a account from this computer + #[command(alias = "rm", alias = "-")] + Remove(AccountRemoveArgs), + + /// List all accounts in this computer + #[command(alias = "ls")] + List(AccountListArgs), + + /// Set current local workspace account + As(SetLocalWorkspaceAccountArgs), + + /// Move private key file to account + #[command(alias = "mvkey", alias = "mvk", alias = "movekey")] + MoveKey(MoveKeyToAccountArgs), + + /// Output public key file to specified directory + #[command(alias = "genpub")] + GeneratePublicKey(GeneratePublicKeyArgs), +} + +#[derive(Subcommand, Debug)] +enum SheetManage { + /// Show help information + #[command(alias = "--help", alias = "-h")] + Help, + + /// List all sheets + #[command(alias = "ls")] + List(SheetListArgs), + + /// Use a sheet + Use(SheetUseArgs), + + /// Exit current sheet + Exit(SheetExitArgs), + + /// Create a new sheet + #[command(alias = "mk")] + Make(SheetMakeArgs), + + /// Drop current sheet + Drop(SheetDropArgs), + + /// Align file structure + Align(SheetAlignArgs), +} + +#[derive(Parser, Debug)] +struct SheetListArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Show other's sheets + #[arg(short, long = "other")] + others: bool, + + /// Show all sheets + #[arg(short = 'A', long)] + all: bool, + + /// Show raw output + #[arg(short, long)] + raw: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct SheetUseArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Sheet name + sheet_name: String, +} + +#[derive(Parser, Debug)] +struct SheetExitArgs { + /// Show help information + #[arg(short, long)] + help: bool, +} + +#[derive(Parser, Debug)] +struct SheetMakeArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Sheet name + sheet_name: String, +} + +#[derive(Parser, Debug)] +struct SheetDropArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Whether to skip confirmation + #[arg(short = 'C', long)] + confirm: bool, + + /// Sheet name + sheet_name: String, +} + +#[derive(Parser, Debug)] +struct SheetAlignArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Align task + task: Option, + + /// Align operation + to: Option, + + /// List Option: All + #[arg(long = "all")] + list_all: bool, + + /// List Option: Unsolved + #[arg(long = "unsolved")] + list_unsolved: bool, + + /// List Option: Created + #[arg(long = "created")] + list_created: bool, + + /// Editor mode + #[arg(short, long)] + work: bool, + + /// Show raw output (for list) + #[arg(short, long)] + raw: bool, + + /// Show json output (for list) + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct LoginArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Whether to skip confirmation + #[arg(short = 'C', long)] + confirm: bool, + + /// Member ID + login_member_id: MemberId, + + /// Upstream + upstream: String, +} + +#[derive(Parser, Debug)] +struct CreateWorkspaceArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Workspace directory path + path: Option, + + /// Force create, ignore files in the directory + #[arg(short, long)] + force: bool, +} + +#[derive(Parser, Debug)] +struct InitWorkspaceArgs { + /// Show help information + #[arg(short, long)] + help: bool, +} + +#[derive(Parser, Debug)] +struct HereArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Show help information + #[arg(short = 'd', long = "desc")] + show_description: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct StatusArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct InfoArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// File pattern + file_pattern: Option, + + /// Full histories output + #[arg(short, long = "full")] + full: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct AccountAddArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Account name + account_name: String, + + /// Auto generate ED25519 private key + #[arg(short = 'K', long = "keygen")] + keygen: bool, +} + +#[derive(Parser, Debug)] +struct AccountRemoveArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Account name + account_name: String, +} + +#[derive(Parser, Debug)] +struct AccountListArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Show raw output + #[arg(short, long)] + raw: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, +} + +#[derive(Parser, Debug)] +struct SetLocalWorkspaceAccountArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Account name + account_name: String, +} + +#[derive(Parser, Debug)] +struct MoveKeyToAccountArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Account name + account_name: String, + + /// Private key file path + key_path: PathBuf, +} + +#[derive(Parser, Debug)] +struct GeneratePublicKeyArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Account name + account_name: String, + + /// Private key file path + output_dir: Option, +} + +#[derive(Parser, Debug, Clone)] +struct TrackFileArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Track file pattern + track_file_pattern: Option, + + /// Overwrite modified + #[arg(short = 'o', long = "overwrite")] + allow_overwrite: bool, + + /// Commit - Description + #[arg(short, long)] + desc: Option, + + /// Commit - Description + #[arg(short = 'v', long = "version")] + next_version: Option, + + /// Commit - Editor mode + #[arg(short, long)] + work: bool, +} + +#[derive(Parser, Debug)] +struct HoldFileArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Hold file pattern + hold_file_pattern: Option, + + /// Show fail details + #[arg(short = 'd', long = "details")] + show_fail_details: bool, + + /// Skip failed items + #[arg(short = 'S', long)] + skip_failed: bool, + + /// Skip check + #[arg(short = 'F', long)] + force: bool, +} + +#[derive(Parser, Debug)] +struct ThrowFileArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Throw file pattern + throw_file_pattern: Option, + + /// Show fail details + #[arg(short = 'd', long = "details")] + show_fail_details: bool, + + /// Skip failed items + #[arg(short = 'S', long)] + skip_failed: bool, + + /// Skip check + #[arg(short = 'F', long)] + force: bool, +} + +#[derive(Parser, Debug)] +struct MoveMappingArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Move mapping pattern + move_mapping_pattern: Option, + + /// To mapping pattern + to_mapping_pattern: Option, + + /// Erase + #[arg(short = 'e', long)] + erase: bool, + + /// Only modify upstream mapping + #[arg(short = 'r', long)] + only_remote: bool, +} + +#[derive(Parser, Debug)] +struct ShareMappingArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Arguments 1 + args1: Option, + + /// Arguments 2 + args2: Option, + + /// Arguments 3 + args3: Option, + + /// Safe merge + #[arg(short = 's', long)] + safe: bool, + + /// Skip all conflicting mappings + #[arg(short = 'S', long)] + skip: bool, + + /// Overwrite all conflicting mappings + #[arg(short = 'o', long)] + overwrite: bool, + + /// Reject this share + #[arg(short = 'R', long)] + reject: bool, + + /// Show raw output + #[arg(short = 'r', long)] + raw: bool, + + /// Show json output + #[arg(long = "json")] + json_output: bool, + + /// Show json output pretty + #[arg(long = "pretty")] + pretty: bool, + + /// Share - Editor mode + #[arg(short, long)] + work: bool, +} + +#[derive(Parser, Debug)] +struct UpdateArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Silent mode + #[arg(short, long)] + silent: bool, +} + +#[derive(Parser, Debug)] +struct DirectArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Upstream vault address + upstream: Option, + + /// Whether to skip confirmation + #[arg(short = 'C', long)] + confirm: bool, +} + +#[derive(Parser, Debug)] +struct UnstainArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Whether to skip confirmation + #[arg(short = 'C', long)] + confirm: bool, +} + +#[derive(Parser, Debug)] +struct DocsArgs { + /// Show help information + #[arg(short, long)] + help: bool, + + /// Name of the docs + docs_name: Option, + + /// Direct output + #[arg(short, long)] + direct: bool, + + /// Show raw output for list + #[arg(short, long)] + raw: bool, +} + +#[derive(Parser, Debug)] +struct UseArgs { + sheet_name: String, +} + +#[derive(Parser, Debug)] +struct DebugGlobArgs { + /// Pattern + pattern: String, // Using 'noglob jvv _glob' in ZSH plz +} + +#[tokio::main] +async fn main() { + // Init i18n + set_locale(¤t_locales()); + + // Init colored + #[cfg(windows)] + colored::control::set_virtual_terminal(true).unwrap(); + + // Outdate update + let required_outdated_minutes = auto_update_outdate(); + let outdate_update_enabled = required_outdated_minutes >= 0; + + // Auto update + let enable_auto_update = enable_auto_update(); + + // The following conditions will trigger automatic update: + // 1. Auto-update is enabled + // 2. Vault has been modified OR (timeout update is enabled AND timeout is set to 0) + if enable_auto_update + && (check_vault_modified().await + || outdate_update_enabled && required_outdated_minutes == 0) + { + // Record current directory + let path = match current_dir() { + Ok(path) => path, + Err(e) => { + eprintln!("{}", t!("jv.fail.get_current_dir", error = e.to_string())); + return; + } + }; + // Update + // This will change the current current_dir + jv_update(UpdateArgs { + help: false, + silent: true, + }) + .await; + // Restore current directory + if let Err(e) = set_current_dir(&path) { + eprintln!( + "{}", + t!( + "jv.fail.std.set_current_dir", + dir = path.display(), + error = e + ) + ); + return; + } + } else + // If automatic update and timeout update are enabled, + // but required time > 0 (not in disabled or always-update state) + if enable_auto_update && outdate_update_enabled && required_outdated_minutes > 0 { + // Read the last update time and calculate the duration + if let Some(local_cfg) = LocalConfig::read().await.ok() { + if let Some(local_dir) = current_local_path() { + if let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + { + if let Some(update_instant) = latest_info.update_instant { + let now = SystemTime::now(); + let duration_secs = now + .duration_since(update_instant) + .unwrap_or_default() + .as_secs(); + + if duration_secs > required_outdated_minutes as u64 * 60 { + // Update + // This will change the current current_dir + jv_update(UpdateArgs { + help: false, + silent: true, + }) + .await + } + } + } + }; + }; + } + + let Ok(parser) = JustEnoughVcsWorkspace::try_parse() else { + eprintln!("{}", md(t!("jv.fail.parse.parser_failed"))); + + // Tips + // Guide to create + { + // Check if workspace exist + let Some(local_dir) = current_local_path() else { + println!(); + println!("{}", t!("jv.tip.not_workspace").trim().yellow()); + return; + }; + + let _ = correct_current_dir(); + + // Check if account list is not empty + let Some(dir) = UserDirectory::current_cfg_dir() else { + return; + }; + + if let Ok(ids) = dir.account_ids() { + if ids.len() < 1 { + println!(); + println!("{}", t!("jv.tip.no_account").trim().yellow()); + return; + } + } + + // Check if the workspace has a registered account (account = unknown) + let Some(local_cfg) = LocalConfig::read().await.ok() else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + // Account exists check + if local_cfg.current_account() == "unknown" { + println!(); + println!("{}", t!("jv.tip.no_account_set").trim().yellow()); + } else { + if dir + .account_ids() + .ok() + .map(|ids| !ids.contains(&local_cfg.current_account())) + .unwrap_or(false) + { + println!(); + println!( + "{}", + t!( + "jv.tip.account_not_exist", + account = local_cfg.current_account() + ) + .trim() + .yellow() + ); + return; + } + } + + // Outdated + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + else { + return; + }; + if let Some(instant) = latest_info.update_instant { + let now = SystemTime::now(); + let duration = now.duration_since(instant).unwrap_or_default(); + + if duration.as_secs() > 60 * required_outdated_minutes.clamp(5, i64::MAX) as u64 { + // Automatically prompt if exceeding the set timeout (at least 5 minutes) + let hours = duration.as_secs() / 3600; + let minutes = (duration.as_secs() % 3600) / 60; + + println!( + "\n{}", + t!("jv.tip.outdated", hour = hours, minutes = minutes) + .trim() + .yellow() + ); + } + } + } + + return; + }; + + match parser.command { + JustEnoughVcsWorkspaceCommand::Version(version_args) => { + let compile_info = CompileInfo::default(); + let core_compile_info = CoreCompileInfo::default(); + if version_args.without_banner { + println!( + "{}", + md(t!( + "jv.version.header", + version = compile_info.cli_version, + vcs_version = core_compile_info.vcs_version + )) + ); + } else { + println!(); + let ascii_art_banner = ASCII_YIZI + .split('\n') + .skip_while(|line| !line.contains("#BANNER START#")) + .skip(1) + .take_while(|line| !line.contains("#BANNER END#")) + .collect::>() + .join("\n"); + + println!( + "{}", + ascii_art_banner + .replace("{banner_line_1}", "JustEnoughVCS") + .replace( + "{banner_line_2}", + &format!( + "{}: {} ({})", + t!("common.word.cli_version"), + &compile_info.cli_version, + &compile_info.date + ) + ) + .replace( + "{banner_line_3}", + &format!( + "{}: {}", + t!("common.word.vcs_version"), + &core_compile_info.vcs_version + ) + ) + ); + + if !version_args.compile_info { + println!(); + } + } + + if version_args.compile_info { + println!( + "\n{}", + md(t!( + "jv.version.compile_info", + build_time = compile_info.date, + build_target = compile_info.target, + build_platform = compile_info.platform, + build_toolchain = compile_info.toolchain, + cli_build_branch = compile_info.build_branch, + cli_build_commit = + &compile_info.build_commit[..7.min(compile_info.build_commit.len())], + core_build_branch = core_compile_info.build_branch, + core_build_commit = &core_compile_info.build_commit + [..7.min(core_compile_info.build_commit.len())] + )) + ); + } + } + + JustEnoughVcsWorkspaceCommand::Help => { + println!("{}", md(t!("jv.help"))); + } + + JustEnoughVcsWorkspaceCommand::Account(account_manage) => { + let user_dir = match UserDirectory::current_cfg_dir() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.account.no_user_dir")); + return; + } + }; + + match account_manage { + AccountManage::Help => { + println!("{}", md(t!("jv.account"))); + } + AccountManage::Add(account_add_args) => { + if account_add_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_add(user_dir, account_add_args).await; + } + AccountManage::Remove(account_remove_args) => { + if account_remove_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_remove(user_dir, account_remove_args).await; + } + AccountManage::List(account_list_args) => { + if account_list_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_list(user_dir, account_list_args).await; + } + AccountManage::As(set_local_workspace_account_args) => { + if set_local_workspace_account_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_as(user_dir, set_local_workspace_account_args).await; + } + AccountManage::MoveKey(move_key_to_account_args) => { + if move_key_to_account_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_move_key(user_dir, move_key_to_account_args).await; + } + AccountManage::GeneratePublicKey(generate_public_key_args) => { + if generate_public_key_args.help { + println!("{}", md(t!("jv.account"))); + return; + } + jv_account_generate_pub_key(user_dir, generate_public_key_args).await; + } + } + } + JustEnoughVcsWorkspaceCommand::Create(create_workspace_args) => { + if create_workspace_args.help { + println!("{}", md(t!("jv.create"))); + return; + } + jv_create(create_workspace_args).await; + } + JustEnoughVcsWorkspaceCommand::Init(init_workspace_args) => { + if init_workspace_args.help { + println!("{}", md(t!("jv.init"))); + return; + } + jv_init(init_workspace_args).await; + } + JustEnoughVcsWorkspaceCommand::Here(here_args) => { + if here_args.help { + println!("{}", md(t!("jv.here"))); + return; + } + jv_here(here_args).await; + } + JustEnoughVcsWorkspaceCommand::Status(status_args) => { + if status_args.help { + println!("{}", md(t!("jv.status"))); + return; + } + jv_status(status_args).await; + } + JustEnoughVcsWorkspaceCommand::Info(info_args) => { + if info_args.help { + println!("{}", md(t!("jv.info"))); + return; + } + jv_info(info_args).await; + } + JustEnoughVcsWorkspaceCommand::Sheet(sheet_manage) => match sheet_manage { + SheetManage::Help => { + println!("{}", md(t!("jv.sheet"))); + return; + } + SheetManage::List(sheet_list_args) => jv_sheet_list(sheet_list_args).await, + SheetManage::Use(sheet_use_args) => jv_sheet_use(sheet_use_args).await, + SheetManage::Exit(sheet_exit_args) => { + let _ = jv_sheet_exit(sheet_exit_args).await; + } + SheetManage::Make(sheet_make_args) => jv_sheet_make(sheet_make_args).await, + SheetManage::Drop(sheet_drop_args) => jv_sheet_drop(sheet_drop_args).await, + SheetManage::Align(sheet_align_args) => jv_sheet_align(sheet_align_args).await, + }, + JustEnoughVcsWorkspaceCommand::Track(track_file_args) => { + if track_file_args.help { + println!("{}", md(t!("jv.track"))); + return; + } + jv_track(track_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Hold(hold_file_args) => { + if hold_file_args.help { + println!("{}", md(t!("jv.hold"))); + return; + } + jv_hold(hold_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Throw(throw_file_args) => { + if throw_file_args.help { + println!("{}", md(t!("jv.throw"))); + return; + } + jv_throw(throw_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Move(move_file_args) => { + if move_file_args.help { + println!("{}", md(t!("jv.move"))); + return; + } + jv_move(move_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Share(share_file_args) => { + if share_file_args.help { + println!("{}", md(t!("jv.share"))); + return; + } + jv_share(share_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Update(update_file_args) => { + if update_file_args.help { + println!("{}", md(t!("jv.update"))); + return; + } + jv_update(update_file_args).await; + } + JustEnoughVcsWorkspaceCommand::Direct(direct_args) => { + if direct_args.help { + println!("{}", md(t!("jv.direct"))); + return; + } + jv_direct(direct_args).await; + } + JustEnoughVcsWorkspaceCommand::Unstain(unstain_args) => { + if unstain_args.help { + println!("{}", md(t!("jv.unstain"))); + return; + } + jv_unstain(unstain_args).await; + } + JustEnoughVcsWorkspaceCommand::Docs(docs_args) => { + if docs_args.help { + println!("{}", md(t!("jv.docs"))); + return; + } + jv_docs(docs_args).await; + } + JustEnoughVcsWorkspaceCommand::Exit => { + let _ = jv_sheet_exit(SheetExitArgs { help: false }).await; + } + JustEnoughVcsWorkspaceCommand::Use(use_args) => { + if let Ok(_) = jv_sheet_exit(SheetExitArgs { help: false }).await { + jv_sheet_use(SheetUseArgs { + help: false, + sheet_name: use_args.sheet_name, + }) + .await; + } + } + JustEnoughVcsWorkspaceCommand::Sheets => { + jv_sheet_list(SheetListArgs { + help: false, + others: false, + all: false, + raw: false, + json_output: false, + pretty: false, + }) + .await + } + JustEnoughVcsWorkspaceCommand::Accounts => { + let user_dir = match UserDirectory::current_cfg_dir() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.account.no_user_dir")); + return; + } + }; + jv_account_list( + user_dir, + AccountListArgs { + help: false, + raw: false, + json_output: false, + pretty: false, + }, + ) + .await + } + JustEnoughVcsWorkspaceCommand::Align(sheet_align_args) => { + if sheet_align_args.help { + println!("{}", md(t!("jv.align"))); + return; + } + jv_sheet_align(sheet_align_args).await + } + JustEnoughVcsWorkspaceCommand::As(args) => { + let user_dir = match UserDirectory::current_cfg_dir() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.account.no_user_dir")); + return; + } + }; + jv_account_as(user_dir, args).await + } + JustEnoughVcsWorkspaceCommand::Make(args) => { + jv_sheet_make(args).await; + } + JustEnoughVcsWorkspaceCommand::Drop(args) => { + jv_sheet_drop(args).await; + } + JustEnoughVcsWorkspaceCommand::Login(args) => { + if !args.confirm { + println!( + "{}", + t!( + "jv.confirm.login", + account = args.login_member_id, + upstream = args.upstream + ) + .trim() + .yellow() + ); + confirm_hint_or(t!("common.confirm"), || exit(1)).await; + } + + let user_dir = match UserDirectory::current_cfg_dir() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.account.no_user_dir")); + return; + } + }; + + jv_account_as( + user_dir, + SetLocalWorkspaceAccountArgs { + help: false, + account_name: args.login_member_id, + }, + ) + .await; + + jv_direct(DirectArgs { + help: false, + upstream: Some(args.upstream.clone()), + confirm: true, + }) + .await; + + jv_update(UpdateArgs { + help: false, + silent: true, + }) + .await; + + if let Some(local_dir) = current_local_path() { + let _ = fs::remove_file(local_dir.join(CLIENT_FILE_TODOLIST)).await; + }; + } + + // Completion Helpers + JustEnoughVcsWorkspaceCommand::GetHistoryIpAddress => { + get_recent_ip_address() + .await + .iter() + .for_each(|ip| println!("{}", ip)); + } + JustEnoughVcsWorkspaceCommand::GetWorkspaceDir => { + if let Some(local_dir) = current_local_path() { + println!("{}", local_dir.display()); + return; + }; + exit(1) + } + JustEnoughVcsWorkspaceCommand::GetCurrentAccount => { + let _ = correct_current_dir(); + if let Ok(local_config) = LocalConfig::read().await { + if local_config.is_host_mode() { + println!("host/{}", local_config.current_account()); + return; + } else { + println!("{}", local_config.current_account()); + return; + } + }; + exit(1) + } + JustEnoughVcsWorkspaceCommand::GetCurrentUpstream => { + let _ = correct_current_dir(); + if let Ok(local_config) = LocalConfig::read().await { + println!("{}", local_config.upstream_addr()); + return; + }; + exit(1) + } + JustEnoughVcsWorkspaceCommand::GetCurrentSheet => { + let _ = correct_current_dir(); + if let Ok(local_config) = LocalConfig::read().await { + let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default(); + if sheet_name.len() > 0 { + println!("{}", sheet_name); + return; + } + }; + exit(1) + } + + // Debug Tools + JustEnoughVcsWorkspaceCommand::DebugGlob(glob_args) => { + jv_debug_glob(glob_args).await; + } + } +} + +async fn jv_create(args: CreateWorkspaceArgs) { + let Some(path) = args.path else { + println!("{}", md(t!("jv.create"))); + return; + }; + + if !args.force && path.exists() && !is_directory_empty(&path).await { + eprintln!("{}", t!("jv.fail.init_create_dir_not_empty").trim()); + return; + } + + match LocalWorkspace::setup_local_workspace(path).await { + Ok(_) => { + println!("{}", t!("jv.success.create")); + } + Err(e) => { + eprintln!("{}", t!("jv.fail.create", error = e.to_string())); + } + } +} + +async fn jv_init(_args: InitWorkspaceArgs) { + let path = match current_dir() { + Ok(path) => path, + Err(e) => { + eprintln!("{}", t!("jv.fail.get_current_dir", error = e.to_string())); + return; + } + }; + + if path.exists() && !is_directory_empty(&path).await { + eprintln!("{}", t!("jv.fail.init_create_dir_not_empty").trim()); + return; + } + + match LocalWorkspace::setup_local_workspace(path).await { + Ok(_) => { + println!("{}", t!("jv.success.init")); + } + Err(e) => { + eprintln!("{}", t!("jv.fail.init", error = e.to_string())); + } + } +} + +async fn is_directory_empty(path: &PathBuf) -> bool { + match fs::read_dir(path).await { + Ok(mut entries) => entries.next_entry().await.unwrap().is_none(), + Err(_) => false, + } +} + +async fn jv_here(args: HereArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Ok(local_cfg) = LocalConfig::read_from(local_dir.join(CLIENT_FILE_WORKSPACE)).await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_cfg.current_account() + )) + ); + return; + }; + + let Ok(latest_file_data_path) = LatestFileData::data_path(&local_cfg.current_account()) else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &local_cfg.current_account() + )) + ); + return; + }; + + let Ok(latest_file_data) = LatestFileData::read_from(&latest_file_data_path).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &local_cfg.current_account() + )) + ); + return; + }; + + // Print path information + let sheet_name = if let Some(sheet_name) = local_cfg.sheet_in_use() { + sheet_name.to_string() + } else { + "".to_string() + }; + + // Read cached sheet + let Ok(cached_sheet) = CachedSheet::cached_sheet_data(&sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.cached_sheet", + sheet = &sheet_name + )) + ); + return; + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_cfg.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(analyzed) = AnalyzeResult::analyze_local_status(&local_workspace).await else { + eprintln!("{}", md(t!("jv.fail.status.analyze")).trim()); + return; + }; + + // Read local sheet + let Ok(local_sheet) = local_workspace + .local_sheet(&local_cfg.current_account(), &sheet_name) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &local_cfg.current_account(), + sheet = &sheet_name + )) + ); + return; + }; + + let path = match current_dir() { + Ok(path) => path, + Err(_) => { + eprintln!("{}", t!("jv.fail.get_current_dir")); + return; + } + }; + + let local_dir = match current_local_path() { + Some(path) => path, + None => { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + + let relative_path = match path.strip_prefix(&local_dir) { + Ok(path) => path.display().to_string(), + Err(_) => path.display().to_string(), + }; + + let mut remote_files = mapping_names_here(&path, &local_dir, &cached_sheet); + + let duration_updated = SystemTime::now() + .duration_since(latest_info.update_instant.unwrap_or(SystemTime::now())) + .unwrap_or_default(); + let minutes = duration_updated.as_secs() / 60; + + // JSON output handling + if args.json_output { + let mut json_result = HereJsonResult::default(); + let mut dirs = HashSet::new(); + let mut files = HashSet::new(); + + // Process local files + if let Ok(mut entries) = fs::read_dir(&path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Ok(file_type) = entry.file_type().await { + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name == CLIENT_FOLDER_WORKSPACE_ROOT_NAME { + continue; + } + + let metadata = match entry.metadata().await { + Ok(meta) => meta, + Err(_) => continue, + }; + + let size = metadata.len() as usize; + let is_dir = file_type.is_dir(); + + // Skip if already processed + if is_dir { + if dirs.contains(&file_name) { + continue; + } + dirs.insert(file_name.clone()); + } else { + if files.contains(&file_name) { + continue; + } + files.insert(file_name.clone()); + } + + // Get current path + let current_path = PathBuf::from_str(relative_path.as_ref()) + .unwrap() + .join(file_name.clone()); + + let mut current_version = VirtualFileVersion::default(); + let mut modified = false; + let mut holder = None; + + // Get mapping info (only for files) + if !is_dir { + if let Some(mapping) = cached_sheet.mapping().get(¤t_path) { + let id = mapping.id.clone(); + if let Some(latest_version) = latest_file_data.file_version(&id) { + current_version = latest_version.clone(); + } + if let Some(file_holder) = latest_file_data.file_holder(&id) { + holder = Some(file_holder.clone()); + } + + // Check if file is modified + modified = analyzed.modified.contains(¤t_path); + } + } + + // Remove from remote files list + remote_files.remove(&file_name); + + // Add to JSON result + json_result.items.push(HereJsonResultItem { + mapping: if is_dir { + "".to_string() + } else { + format_path_str(¤t_path.display().to_string()).unwrap_or_default() + }, + name: file_name.clone(), + current_version, + size, + exist: true, + modified, + holder: holder.unwrap_or_default(), + is_dir, + }); + } + } + } + + // Process remote files (not existing locally) + for (name, metadata) in remote_files { + let current_path = PathBuf::from_str(relative_path.as_ref()) + .unwrap() + .join(name.clone()); + + if let Some(metadata) = metadata { + // It's a file + // Skip if already processed + if files.contains(&name) { + continue; + } + files.insert(name.clone()); + + let holder = latest_file_data.file_holder(&metadata.id).cloned(); + json_result.items.push(HereJsonResultItem { + mapping: format_path_str(¤t_path.display().to_string()) + .unwrap_or_default(), + name: name.clone(), + current_version: metadata.version, + size: 0, + exist: false, + modified: false, + holder: holder.unwrap_or_default(), + is_dir: false, + }); + } else { + // It's a directory + // Skip if already processed + let trimmed_name = if name.ends_with('/') { + name[..name.len() - 1].to_string() + } else { + name.clone() + }; + + if dirs.contains(&trimmed_name) { + continue; + } + dirs.insert(trimmed_name.clone()); + + json_result.items.push(HereJsonResultItem { + mapping: String::default(), + name: trimmed_name, + current_version: VirtualFileVersion::default(), + size: 0, + exist: false, + modified: false, + holder: String::new(), + is_dir: true, + }); + } + } + + print_json(json_result, args.pretty); + return; + } + + let account_str = if local_cfg.is_host_mode() { + format!("{}/{}", "host".red(), local_cfg.current_account()) + } else { + local_cfg.current_account() + }; + + println!( + "{}", + t!( + "jv.success.here.path_info", + upstream = local_cfg.upstream_addr().to_string(), + account = account_str, + sheet_name = sheet_name.yellow(), + path = relative_path, + minutes = minutes + ) + .trim() + ); + + // Print file info + let mut columns = vec![ + t!("jv.success.here.items.editing"), + t!("jv.success.here.items.holder"), + t!("jv.success.here.items.size"), + t!("jv.success.here.items.version"), + t!("jv.success.here.items.name"), + ]; + if args.show_description { + columns.push(t!("jv.success.here.items.description")); + } + let mut table = SimpleTable::new(columns); + + let mut dir_count = 0; + let mut file_count = 0; + let mut total_size = 0; + + // Exists files + if let Ok(mut entries) = fs::read_dir(&path).await { + while let Ok(Some(entry)) = entries.next_entry().await { + if let Ok(file_type) = entry.file_type().await { + let file_name = entry.file_name().to_string_lossy().to_string(); + + if file_name == CLIENT_FOLDER_WORKSPACE_ROOT_NAME { + continue; + } + + let metadata = match entry.metadata().await { + Ok(meta) => meta, + Err(_) => continue, + }; + + let size = metadata.len(); + let is_dir = file_type.is_dir(); + let mut version = "-".to_string(); + let mut hold = "-".to_string(); + let mut editing = "-".to_string(); + let mut desc = "-".to_string(); + + if is_dir { + // Directory + // Add directory count + dir_count += 1; + + let dir_name = format!("{}/", file_name); + + // Remove remote dirs items that already exist locally + remote_files.remove(&dir_name); + + // Add directory item + let mut line = vec![ + editing.to_string(), + hold.to_string(), + "-".to_string(), + version.to_string(), + t!( + "jv.success.here.append_info.name", + name = dir_name.to_string().cyan() + ) + .trim() + .to_string(), + ]; + if args.show_description { + line.push(desc); + } + table.insert_item(0, line); + } else { + // Local File + // Add file count + file_count += 1; + + // Get current path + let current_path = PathBuf::from_str(relative_path.as_ref()) + .unwrap() + .join(file_name.clone()); + + // Get mapping + if let Some(mapping) = cached_sheet.mapping().get(¤t_path) { + let mut is_file_held = false; + let mut is_version_match = false; + + // Hold status + let id = mapping.id.clone(); + if let Some(holder) = latest_file_data.file_holder(&id) { + if holder == &local_cfg.current_account() { + hold = t!("jv.success.here.append_info.holder.yourself") + .trim() + .green() + .to_string(); + is_file_held = true; + } else { + let holder_text = t!( + "jv.success.here.append_info.holder.others", + holder = holder + ) + .trim() + .truecolor(128, 128, 128); + hold = holder_text.to_string(); + } + } + + // Version status && description + if let Some(latest_version) = latest_file_data.file_version(&id) { + let local_version = local_sheet.mapping_data(¤t_path); + if let Ok(local_mapping) = local_version { + let local_version = local_mapping.version_when_updated(); + + // Append version status + if latest_version == local_version { + version = t!( + "jv.success.here.append_info.version.match", + version = latest_version + ) + .trim() + .to_string(); + is_version_match = true; + } else { + version = t!( + "jv.success.here.append_info.version.unmatch", + remote_version = local_version, + ) + .trim() + .red() + .to_string(); + } + + // Append description + if args.show_description { + let content = local_mapping + .version_desc_when_updated() + .description + .clone(); + + let content = truncate_first_line(content); + + desc = t!( + "jv.success.here.append_info.description", + creator = local_mapping + .version_desc_when_updated() + .creator + .cyan() + .to_string(), + description = content, + ) + .trim() + .to_string(); + } + } + } + + // Editing status + let modified = analyzed.modified.contains(¤t_path); + if !is_file_held || !is_version_match { + if modified { + editing = t!( + "jv.success.here.append_info.editing.cant_edit_but_modified" + ) + .trim() + .red() + .to_string(); + } else { + editing = t!("jv.success.here.append_info.editing.cant_edit") + .trim() + .truecolor(128, 128, 128) + .to_string(); + } + } else { + if modified { + editing = t!("jv.success.here.append_info.editing.modified") + .trim() + .cyan() + .to_string(); + } else { + editing = t!("jv.success.here.append_info.editing.can_edit") + .trim() + .green() + .to_string(); + } + } + } + + // Remove remote file items that already exist locally + remote_files.remove(&file_name); + + // Add Table Item + let mut line = vec![ + editing.to_string(), + hold.to_string(), + t!( + "jv.success.here.append_info.size", + size = size_str(size as usize) + ) + .trim() + .yellow() + .to_string(), + version.to_string(), + t!("jv.success.here.append_info.name", name = file_name) + .trim() + .to_string(), + ]; + + if args.show_description { + line.push(desc); + } + + table.push_item(line); + } + + // Total Size + total_size += size; + } + } + } + + // Remote Files + for mapping in remote_files { + if let Some(metadata) = mapping.1 { + let mut hold = "-".to_string(); + if let Some(holder) = latest_file_data.file_holder(&metadata.id) { + if holder == &local_cfg.current_account() { + hold = t!("jv.success.here.append_info.holder.yourself") + .trim() + .green() + .to_string(); + } else { + let holder_text = + t!("jv.success.here.append_info.holder.others", holder = holder) + .trim() + .truecolor(128, 128, 128); + hold = holder_text.to_string(); + } + } + + // File + let mut line = vec![ + t!("jv.success.here.append_info.editing.not_local") + .trim() + .truecolor(128, 128, 128) + .to_string(), + hold.to_string(), + "-".to_string(), + metadata.version, + t!("jv.success.here.append_info.name", name = mapping.0) + .trim() + .truecolor(128, 128, 128) + .to_string(), + ]; + + if args.show_description { + line.push("-".to_string()); + } + + table.push_item(line); + } else { + // Directory + let mut line = vec![ + "-".to_string(), + "-".to_string(), + "-".to_string(), + "-".to_string(), + t!("jv.success.here.append_info.name", name = mapping.0) + .trim() + .truecolor(128, 128, 128) + .to_string(), + ]; + + if args.show_description { + line.push("-".to_string()); + } + + table.push_item(line); + } + } + + println!("{}", table); + + // Print directory info + println!( + "{}", + t!( + "jv.success.here.count_info", + dir_count = dir_count, + file_count = file_count, + size = size_str(total_size as usize) + ) + .trim() + ); +} + +async fn jv_status(args: StatusArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(local_cfg) = LocalConfig::read_from(local_dir.join(CLIENT_FILE_WORKSPACE)).await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_cfg.current_account() + )) + ); + return; + }; + + let account = local_cfg.current_account(); + + let Ok(latest_file_data_path) = LatestFileData::data_path(&account) else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + let Ok(latest_file_data) = LatestFileData::read_from(&latest_file_data_path).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + let Some(sheet_name) = local_cfg.sheet_in_use().clone() else { + eprintln!("{}", md(t!("jv.fail.status.no_sheet_in_use")).trim()); + return; + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_cfg.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(local_sheet) = local_workspace.local_sheet(&account, &sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &account, + sheet = &sheet_name + )) + ); + return; + }; + + let in_ref_sheet = latest_info.reference_sheets.contains(&sheet_name); + let is_host_mode = local_cfg.is_host_mode(); + + let Ok(analyzed) = AnalyzeResult::analyze_local_status(&local_workspace).await else { + eprintln!("{}", md(t!("jv.fail.status.analyze")).trim()); + return; + }; + + let mut created_items: Vec; + let mut erased_items: Vec; + let mut lost_items: Vec; + let mut moved_items: Vec; + let mut modified_items: Vec; + + if args.json_output { + let mut created: Vec = analyzed.created.iter().cloned().collect(); + let mut lost: Vec = analyzed.lost.iter().cloned().collect(); + let mut erased: Vec = analyzed.erased.iter().cloned().collect(); + let mut moved: Vec = analyzed + .moved + .iter() + .map(|(_, (from, to))| MovedItem { + from: from.clone(), + to: to.clone(), + }) + .collect(); + let mut modified: Vec = analyzed + .modified + .iter() + .cloned() + .map(|path| { + let holder_match = { + if let Ok(mapping) = local_sheet.mapping_data(&path) { + let vfid = mapping.mapping_vfid(); + match latest_file_data.file_holder(vfid) { + Some(holder) => holder == &account, + None => false, + } + } else { + false + } + }; + + let base_version_match = { + if let Ok(mapping) = local_sheet.mapping_data(&path) { + let vfid = mapping.mapping_vfid(); + let ver = mapping.version_when_updated(); + if let Some(latest_version) = latest_file_data.file_version(&vfid) { + ver == latest_version + } else { + true + } + } else { + true + } + }; + + let modification_type = if !holder_match { + ModifiedType::ModifiedButNotHeld + } else if !base_version_match { + ModifiedType::ModifiedButBaseVersionMismatch + } else { + ModifiedType::Modified + }; + + ModifiedItem { + path, + modification_type, + } + }) + .collect(); + + // Sort + created.sort(); + lost.sort(); + erased.sort(); + moved.sort_by(|a, b| a.from.cmp(&b.from).then(a.to.cmp(&b.to))); + modified.sort_by(|a, b| a.path.cmp(&b.path)); + + let json_result = AnalyzerJsonResult { + created, + lost, + erased, + moved, + modified, + }; + + print_json(json_result, args.pretty); + return; + } else { + // Format created items + created_items = analyzed + .created + .iter() + .map(|path| { + t!( + "jv.success.status.created_item", + path = path.display().to_string() + ) + .trim() + .green() + .to_string() + }) + .collect(); + + // Format erased items + erased_items = analyzed + .erased + .iter() + .map(|path| { + t!( + "jv.success.status.erased_item", + path = path.display().to_string() + ) + .trim() + .magenta() + .to_string() + }) + .collect(); + + // Format lost items + lost_items = analyzed + .lost + .iter() + .map(|path| { + t!( + "jv.success.status.lost_item", + path = path.display().to_string() + ) + .trim() + .red() + .to_string() + }) + .collect(); + + // Format moved items + moved_items = analyzed + .moved + .iter() + .map(|(_, (from, to))| { + t!( + "jv.success.status.moved_item", + from = from.display(), + to = to.display() + ) + .trim() + .yellow() + .to_string() + }) + .collect(); + + // Format modified items + modified_items = analyzed + .modified + .iter() + .map(|path| { + let holder_match = { + if let Ok(mapping) = local_sheet.mapping_data(path) { + let vfid = mapping.mapping_vfid(); + match latest_file_data.file_holder(vfid) { + Some(holder) => holder == &account, + None => false, + } + } else { + false + } + }; + + let base_version_match = { + if let Ok(mapping) = local_sheet.mapping_data(path) { + let vfid = mapping.mapping_vfid(); + let ver = mapping.version_when_updated(); + if let Some(latest_version) = latest_file_data.file_version(&vfid) { + ver == latest_version + } else { + true + } + } else { + true + } + }; + + // Holder dismatch + if !holder_match { + return t!( + "jv.success.status.invalid_modified_item", + path = path.display().to_string(), + reason = t!("jv.success.status.invalid_modified_reasons.not_holder") + ) + .trim() + .red() + .to_string(); + } + + // Base version mismatch + if !base_version_match { + return t!( + "jv.success.status.invalid_modified_item", + path = path.display().to_string(), + reason = + t!("jv.success.status.invalid_modified_reasons.base_version_mismatch") + ) + .trim() + .red() + .to_string(); + } + + t!( + "jv.success.status.modified_item", + path = path.display().to_string() + ) + .trim() + .cyan() + .to_string() + }) + .collect(); + } + + let has_struct_changes = !created_items.is_empty() + || !lost_items.is_empty() + || !erased_items.is_empty() + || !moved_items.is_empty(); + let has_file_modifications = !modified_items.is_empty(); + + if has_struct_changes { + sort_paths(&mut created_items); + sort_paths(&mut lost_items); + sort_paths(&mut erased_items); + sort_paths(&mut moved_items); + } + if has_file_modifications { + sort_paths(&mut modified_items); + } + + // Calculate duration since last update + let update_instant = latest_info.update_instant.unwrap_or(SystemTime::now()); + let duration = SystemTime::now() + .duration_since(update_instant) + .unwrap_or_default(); + let h = duration.as_secs() / 3600; + let m = (duration.as_secs() % 3600) / 60; + let s = duration.as_secs() % 60; + + if has_struct_changes { + println!( + "{}", + md(t!( + "jv.success.status.struct_changes_display", + sheet_name = sheet_name, + moved_items = if moved_items.is_empty() { + "".to_string() + } else { + moved_items.join("\n") + "\n" + }, + lost_items = if lost_items.is_empty() { + "".to_string() + } else { + lost_items.join("\n") + "\n" + }, + erased_items = if erased_items.is_empty() { + "".to_string() + } else { + erased_items.join("\n") + "\n" + }, + created_items = if created_items.is_empty() { + "".to_string() + } else { + created_items.join("\n") + "\n" + }, + h = h, + m = m, + s = s + )) + .trim() + ); + } else if has_file_modifications { + println!( + "{}", + md(t!( + "jv.success.status.content_modifies_display", + sheet_name = sheet_name, + modified_items = if modified_items.is_empty() { + "".to_string() + } else { + modified_items.join("\n") + }, + h = h, + m = m, + s = s + )) + .trim() + ); + } else { + if in_ref_sheet { + println!( + "{}", + md(t!( + "jv.success.status.no_changes_in_reference_sheet", + sheet_name = sheet_name, + h = h, + m = m, + s = s + )) + ); + } else { + println!( + "{}", + md(t!( + "jv.success.status.no_changes", + sheet_name = sheet_name, + h = h, + m = m, + s = s + )) + ); + } + } + + if in_ref_sheet && !is_host_mode { + println!( + "\n{}", + md(t!("jv.success.status.hint_in_reference_sheet")).yellow() + ); + } + if is_host_mode { + println!("\n{}", md(t!("jv.success.status.hint_as_host"))); + } +} + +async fn jv_info(args: InfoArgs) { + let local_dir = match current_local_path() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + + let query_file_paths = if let Some(pattern) = args.file_pattern.clone() { + let files = glob(pattern, &local_dir).await; + files + .iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect::>() + } else { + println!("{}", md(t!("jv.info"))); + return; + }; + + let _ = correct_current_dir(); + + let Ok(local_cfg) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_cfg.current_account() + )) + ); + return; + }; + + let account = local_cfg.current_account(); + + let Ok(latest_file_data_path) = LatestFileData::data_path(&account) else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + // Get latest file data + let Ok(latest_file_data) = LatestFileData::read_from(&latest_file_data_path).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + let Some(sheet_name) = local_cfg.sheet_in_use().clone() else { + eprintln!("{}", md(t!("jv.fail.status.no_sheet_in_use")).trim()); + return; + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_cfg.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(local_sheet) = local_workspace.local_sheet(&account, &sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &account, + sheet = &sheet_name + )) + ); + return; + }; + + if query_file_paths.len() < 1 { + return; + } + // File to query + let query_file_path = query_file_paths[0].to_path_buf(); + let Ok(mapping) = local_sheet.mapping_data(&query_file_path) else { + return; + }; + let vfid = mapping.mapping_vfid(); + + let query_file_path_string = + format_path_str(query_file_path.display().to_string()).unwrap_or_default(); + + // JSON output handling + if args.json_output { + let mut json_result = InfoJsonResult::default(); + json_result.mapping = query_file_path_string.clone(); + + // Get reference sheet path + json_result.in_ref = if let Some(path) = latest_info.ref_sheet_vfs_mapping.get(vfid) { + path.display().to_string() + } else { + vfid.clone() + }; + + json_result.vfid = vfid.clone(); + + // Get file version in reference sheet + let version_in_ref = if let Some(mapping) = latest_info + .ref_sheet_content + .mapping() + .get(&query_file_path) + { + mapping.version.clone() + } else { + "".to_string() + }; + + // Get current file version + let version_current = latest_file_data + .file_version(vfid) + .cloned() + .unwrap_or_else(|| "".to_string()); + + // Check if file is being edited based on latest version (regardless of hold status) + let modified_correctly = if let Ok(mapping) = local_sheet.mapping_data(&query_file_path) { + // If base editing version is correct + if mapping.version_when_updated() == &version_current { + mapping.last_modifiy_check_result() // Return detection result + } else { + false + } + } else { + false + }; + + // Build history list + if let Some(histories) = latest_file_data.file_histories(vfid) { + for (version, description) in histories { + json_result.histories.push(InfoHistory { + version: version.clone(), + version_creator: description.creator.clone(), + version_description: description.description.clone(), + is_current_version: version == &version_current, + is_ref_version: version == &version_in_ref, + }); + } + } + + // Add current modification if exists + if modified_correctly { + json_result.histories.insert( + 0, + InfoHistory { + version: "CURRENT".to_string(), + version_creator: local_workspace + .config() + .lock() + .await + .current_account() + .clone(), + version_description: t!("jv.success.info.oneline.description_current") + .to_string(), + is_current_version: true, + is_ref_version: false, + }, + ); + } + + print_json(json_result, args.pretty); + return; + } + + // Render initial location + { + println!("{}", query_file_path_string); + } + + // Render reference sheet location, use ID if not found + { + let path_in_ref = if let Some(path) = latest_info.ref_sheet_vfs_mapping.get(vfid) { + path.display().to_string() + } else { + vfid.clone() + }; + + // Offset string + let offset_string = " ".repeat(display_width( + if let Some(last_slash) = query_file_path_string.rfind('/') { + &query_file_path_string[..last_slash] + } else { + "" + }, + )); + + println!( + "{}{}{}", + offset_string, + "\\_ ".truecolor(128, 128, 128), + path_in_ref.cyan() + ); + } + + // Render complete file history + { + if let Some(histories) = latest_file_data.file_histories(vfid) { + // Get file version in reference sheet + let version_in_ref = if let Some(mapping) = latest_info + .ref_sheet_content + .mapping() + .get(&query_file_path) + { + mapping.version.clone() + } else { + "".to_string() + }; + + // Get current file version + let version_current = latest_file_data + .file_version(vfid) + .cloned() + .unwrap_or_else(|| "".to_string()); + + // Check if file is being edited based on latest version (regardless of hold status) + let modified_correctly = if let Ok(mapping) = local_sheet.mapping_data(&query_file_path) + { + // If base editing version is correct + if mapping.version_when_updated() == &version_current { + mapping.last_modifiy_check_result() // Return detection result + } else { + false + } + } else { + false + }; + + // Text + let (prefix_str, version_str, creator_str, description_str) = ( + t!("jv.success.info.oneline.table_headers.prefix"), + t!("jv.success.info.oneline.table_headers.version"), + t!("jv.success.info.oneline.table_headers.creator"), + t!("jv.success.info.oneline.table_headers.description"), + ); + + // Single-line output + if !args.full { + // Create table + let mut table = + SimpleTable::new(vec![prefix_str, version_str, creator_str, description_str]); + + // Append data + for (version, description) in histories { + // If it's reference version, render "@" + // Current version, render "\_" + // Other versions, render "|" + let prefix = if version == &version_in_ref { + "@".cyan().to_string() + } else if version == &version_current { + "|->".yellow().to_string() + } else { + "|".truecolor(128, 128, 128).to_string() + }; + + table.insert_item( + 0, + vec![ + prefix, + version.to_string(), + format!("@{}: ", &description.creator.cyan()), + truncate_first_line(description.description.to_string()), + ], + ); + } + + // If file has new version, append + if modified_correctly { + table.insert_item( + 0, + vec![ + "+".green().to_string(), + "CURRENT".green().to_string(), + format!( + "@{}: ", + local_workspace + .config() + .lock() + .await + .current_account() + .cyan() + ), + format!( + "{}", + t!("jv.success.info.oneline.description_current").green() + ), + ], + ); + } + + // Render table + let table_str = table.to_string(); + if table_str.lines().count() > 1 { + println!(); + } + for line in table_str.lines().skip(1) { + println!("{}", line); + } + } else { + // Multi-line output + if histories.len() > 0 { + println!(); + } + for (version, description) in histories { + println!("{}: {}", version_str, version); + println!("{}: {}", creator_str, description.creator.cyan()); + println!("{}", description.description); + if version != &histories.last().unwrap().0 { + println!("{}", "-".repeat(45)); + } + } + } + } + } +} + +async fn jv_sheet_list(args: SheetListArgs) { + let _ = correct_current_dir(); + + let Some(local_dir) = current_local_path() else { + if !args.raw { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + } + return; + }; + + let Ok(local_cfg) = LocalConfig::read().await else { + if !args.raw { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + } + return; + }; + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_cfg.current_account(), + )) + .await + else { + if !args.raw { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_cfg.current_account() + )) + ); + } + return; + }; + + let mut your_sheet_counts = 0; + let mut other_sheet_counts = 0; + + // JSON output handling + if args.json_output { + let mut json_result = SheetListJsonResult::default(); + + // Populate reference sheets + for sheet_name in &latest_info.reference_sheets { + json_result.reference_sheets.push(SheetItem { + name: sheet_name.clone(), + holder: local_cfg.current_account().clone(), + }); + } + + // Populate other sheets + for sheet in &latest_info.invisible_sheets { + json_result.other_sheets.push(SheetItem { + name: sheet.sheet_name.clone(), + holder: sheet.holder_name.clone().unwrap_or_default(), + }); + } + + // Populate my sheets (excluding reference sheets) + for sheet_name in &latest_info.visible_sheets { + if !latest_info.reference_sheets.contains(sheet_name) { + json_result.my_sheets.push(SheetItem { + name: sheet_name.clone(), + holder: local_cfg.current_account().clone(), + }); + } + } + + print_json(json_result, args.pretty); + return; + } + + if args.raw { + // Print your sheets + if !args.others && !args.all || !args.others { + latest_info + .visible_sheets + .iter() + .for_each(|s| println!("{}", s)); + } + // Print other sheets + if args.others || args.all { + latest_info + .invisible_sheets + .iter() + .for_each(|s| println!("{}", s.sheet_name)); + } + } else { + // Print your sheets + if !args.others && !args.all || !args.others { + println!("{}", md(t!("jv.success.sheet.list.your_sheet"))); + let in_use = local_cfg.sheet_in_use(); + for sheet in latest_info.visible_sheets { + let is_ref_sheet = latest_info.reference_sheets.contains(&sheet); + let display_name = if is_ref_sheet { + format!( + "{} {}", + sheet, + md(t!("jv.success.sheet.list.reference_sheet_suffix")) + .truecolor(128, 128, 128) + ) + } else { + sheet.clone() + }; + + if let Some(in_use) = in_use + && in_use == &sheet + { + println!( + "{}", + md(t!( + "jv.success.sheet.list.your_sheet_item_use", + number = your_sheet_counts + 1, + name = display_name.cyan() + )) + ); + } else { + println!( + "{}", + md(t!( + "jv.success.sheet.list.your_sheet_item", + number = your_sheet_counts + 1, + name = display_name + )) + ); + } + your_sheet_counts += 1; + } + } + + // Print other sheets + if args.others || args.all { + if args.all { + println!(); + } + println!("{}", md(t!("jv.success.sheet.list.other_sheet"))); + for sheet in latest_info.invisible_sheets { + if let Some(holder) = sheet.holder_name { + println!( + "{}", + md(t!( + "jv.success.sheet.list.other_sheet_item", + number = other_sheet_counts + 1, + name = sheet.sheet_name, + holder = holder + )) + ); + } else { + println!( + "{}", + md(t!( + "jv.success.sheet.list.other_sheet_item_no_holder", + number = other_sheet_counts + 1, + name = sheet.sheet_name + )) + ); + } + other_sheet_counts += 1; + } + } + + // If not use any sheets, print tips + if local_cfg.sheet_in_use().is_none() { + println!(); + if your_sheet_counts > 0 { + println!("{}", md(t!("jv.success.sheet.list.tip_has_sheet"))); + } else { + println!("{}", md(t!("jv.success.sheet.list.tip_no_sheet"))); + } + } + } +} + +async fn jv_sheet_use(args: SheetUseArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let current_dir = current_dir().unwrap(); + + if local_dir != current_dir { + eprintln!("{}", t!("jv.fail.not_root_dir").trim()); + return; + } + + let Ok(mut local_cfg) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + match local_cfg.use_sheet(args.sheet_name.clone()).await { + Ok(_) => { + match LocalConfig::write(&local_cfg).await { + Ok(_) => (), + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + + // After successfully switching sheets, status should be automatically prompted + jv_status(StatusArgs { + help: false, + json_output: false, + pretty: false, + }) + .await; + } + Err(e) => match e.kind() { + std::io::ErrorKind::AlreadyExists => {} // Already In Use + std::io::ErrorKind::NotFound => { + eprintln!( + "{}", + md(t!("jv.fail.use.sheet_not_exists", name = args.sheet_name)) + ); + return; + } + std::io::ErrorKind::DirectoryNotEmpty => { + eprintln!( + "{}", + md(t!( + "jv.fail.use.directory_not_empty", + name = args.sheet_name + )) + ); + return; + } + _ => { + handle_err(e.into()); + } + }, + } +} + +async fn jv_sheet_exit(_args: SheetExitArgs) -> Result<(), ()> { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return Err(()); + }; + + let current_dir = current_dir().unwrap(); + + if local_dir != current_dir { + eprintln!("{}", t!("jv.fail.not_root_dir").trim()); + return Err(()); + } + + let Ok(mut local_cfg) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return Err(()); + }; + + match local_cfg.exit_sheet().await { + Ok(_) => { + match LocalConfig::write(&local_cfg).await { + Ok(_) => (), + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return Err(()); + } + }; + return Ok(()); + } + Err(e) => { + handle_err(e.into()); + return Err(()); + } + } +} + +async fn jv_sheet_make(args: SheetMakeArgs) { + let sheet_name = snake_case!(args.sheet_name); + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let latest_info = match LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_config.current_account(), + )) + .await + { + Ok(info) => info, + Err(_) => { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_config.current_account() + )) + ); + return; + } + }; + + if latest_info + .invisible_sheets + .iter() + .any(|sheet| sheet.sheet_name == sheet_name && sheet.holder_name.is_none()) + { + println!( + "{}", + md(t!("jv.confirm.sheet.make.restore", sheet_name = sheet_name)).yellow() + ); + if !confirm_hint(t!("common.confirm")).await { + return; + } + } + + match proc_make_sheet_action(&pool, ctx, sheet_name.clone()).await { + Ok(r) => match r { + MakeSheetActionResult::Success => { + println!( + "{}", + md(t!("jv.result.sheet.make.success", name = sheet_name)) + ) + } + MakeSheetActionResult::SuccessRestore => { + println!( + "{}", + md(t!( + "jv.result.sheet.make.success_restore", + name = sheet_name + )) + ) + } + MakeSheetActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + MakeSheetActionResult::SheetAlreadyExists => { + eprintln!( + "{}", + md(t!( + "jv.result.sheet.make.sheet_already_exists", + name = sheet_name + )) + ); + } + MakeSheetActionResult::SheetCreationFailed(e) => { + eprintln!( + "{}", + md(t!("jv.result.sheet.make.sheet_creation_failed", err = e)) + ) + } + MakeSheetActionResult::Unknown => todo!(), + }, + Err(e) => handle_err(e), + } +} + +async fn jv_sheet_drop(args: SheetDropArgs) { + let sheet_name = snake_case!(args.sheet_name); + + if !args.confirm { + println!( + "{}", + t!("jv.confirm.sheet.drop", sheet_name = sheet_name) + .trim() + .yellow() + ); + confirm_hint_or(t!("common.confirm"), || exit(1)).await; + } + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + match proc_drop_sheet_action(&pool, ctx, sheet_name.clone()).await { + Ok(r) => match r { + DropSheetActionResult::Success => { + println!( + "{}", + md(t!("jv.result.sheet.drop.success", name = sheet_name)) + ) + } + DropSheetActionResult::SheetInUse => { + eprintln!( + "{}", + md(t!("jv.result.sheet.drop.sheet_in_use", name = sheet_name)) + ) + } + DropSheetActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + DropSheetActionResult::SheetNotExists => { + eprintln!( + "{}", + md(t!( + "jv.result.sheet.drop.sheet_not_exists", + name = sheet_name + )) + ) + } + DropSheetActionResult::SheetDropFailed(e) => { + eprintln!( + "{}", + md(t!("jv.result.sheet.drop.sheet_drop_failed", err = e)) + ) + } + DropSheetActionResult::NoHolder => { + eprintln!( + "{}", + md(t!("jv.result.sheet.drop.no_holder", name = sheet_name)) + ) + } + DropSheetActionResult::NotOwner => { + eprintln!( + "{}", + md(t!("jv.result.sheet.drop.not_owner", name = sheet_name)) + ) + } + _ => {} + }, + Err(e) => handle_err(e), + } +} + +async fn jv_sheet_align(args: SheetAlignArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let local_cfg = match precheck().await { + Some(config) => config, + None => { + return; + } + }; + + let account = local_cfg.current_account(); + + let Some(sheet_name) = local_cfg.sheet_in_use().clone() else { + eprintln!("{}", md(t!("jv.fail.status.no_sheet_in_use")).trim()); + return; + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_cfg.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(mut local_sheet) = local_workspace.local_sheet(&account, &sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &account, + sheet = &sheet_name + )) + ); + return; + }; + + let Ok(analyzed) = AnalyzeResult::analyze_local_status(&local_workspace).await else { + eprintln!("{}", md(t!("jv.fail.status.analyze")).trim()); + return; + }; + + let align_tasks = AlignTasks::from_analyze_result(analyzed); + + // No task input, list all tasks needs align + let Some(task) = args.task else { + // Raw output + if args.raw { + if args.list_all { + align_tasks.created.iter().for_each(|i| println!("{}", i.0)); + align_tasks.moved.iter().for_each(|i| println!("{}", i.0)); + align_tasks.lost.iter().for_each(|i| println!("{}", i.0)); + align_tasks.erased.iter().for_each(|i| println!("{}", i.0)); + return; + } + if args.list_created { + align_tasks.created.iter().for_each(|i| println!("{}", i.0)); + return; + } + if args.list_unsolved { + align_tasks.moved.iter().for_each(|i| println!("{}", i.0)); + align_tasks.lost.iter().for_each(|i| println!("{}", i.0)); + align_tasks.erased.iter().for_each(|i| println!("{}", i.0)); + return; + } + return; + } + + // Json Output + if args.json_output { + let mut json_result = AlignJsonResult::default(); + + for (name, local_path) in &align_tasks.created { + json_result.align_tasks.insert( + name.clone(), + AlignTaskMapping { + local_mapping: local_path.clone(), + remote_mapping: PathBuf::new(), + }, + ); + } + for (name, (remote_path, local_path)) in &align_tasks.moved { + json_result.align_tasks.insert( + name.clone(), + AlignTaskMapping { + local_mapping: local_path.clone(), + remote_mapping: remote_path.clone(), + }, + ); + } + for (name, local_path) in &align_tasks.lost { + json_result.align_tasks.insert( + name.clone(), + AlignTaskMapping { + local_mapping: local_path.clone(), + remote_mapping: PathBuf::new(), + }, + ); + } + for (name, local_path) in &align_tasks.erased { + json_result.align_tasks.insert( + name.clone(), + AlignTaskMapping { + local_mapping: local_path.clone(), + remote_mapping: PathBuf::new(), + }, + ); + } + + print_json(json_result, args.pretty); + return; + } + + let mut table = SimpleTable::new(vec![ + t!("jv.success.sheet.align.task_name").to_string(), + t!("jv.success.sheet.align.local_path").to_string(), + if !align_tasks.moved.is_empty() { + t!("jv.success.sheet.align.remote_path").to_string() + } else { + "".to_string() + }, + ]); + + let mut need_align = 0; + + if !align_tasks.created.is_empty() { + align_tasks.created.iter().for_each(|(n, p)| { + table.push_item(vec![ + format!("+ {}", n).green().to_string(), + p.display().to_string().green().to_string(), + "".to_string(), + ]); + }); + } + + if !align_tasks.lost.is_empty() { + align_tasks.lost.iter().for_each(|(n, p)| { + table.push_item(vec![ + format!("- {}", n).red().to_string(), + p.display().to_string().red().to_string(), + "".to_string(), + ]); + }); + need_align += 1; + } + + if !align_tasks.erased.is_empty() { + align_tasks.erased.iter().for_each(|(n, p)| { + table.push_item(vec![ + format!("& {}", n).magenta().to_string(), + p.display().to_string().magenta().to_string(), + "".to_string(), + ]); + }); + need_align += 1; + } + + if !align_tasks.moved.is_empty() { + align_tasks.moved.iter().for_each(|(n, (rp, lp))| { + table.push_item(vec![ + format!("> {}", n).yellow().to_string(), + lp.display().to_string().yellow().to_string(), + rp.display().to_string(), + ]); + }); + need_align += 1; + } + + if need_align > 0 { + println!( + "{}", + md(t!("jv.success.sheet.align.list", tasks = table.to_string())) + ); + + // Suggestion1: Confirm Erased + if align_tasks.erased.len() > 0 { + println!( + "\n{}", + md(t!( + "jv.success.sheet.align.suggestion_1", + example_erased = align_tasks.erased[0].0 + )) + ) + } else + // Suggestion2: Confirm Lost + if align_tasks.lost.len() > 0 { + println!( + "\n{}", + md(t!( + "jv.success.sheet.align.suggestion_2", + example_lost = align_tasks.lost[0].0 + )) + ) + } else + // Suggestion3: Confirm Moved + if align_tasks.moved.len() > 0 { + println!( + "\n{}", + md(t!( + "jv.success.sheet.align.suggestion_3", + example_moved = align_tasks.moved[0].0 + )) + ) + } + } else { + println!("{}", md(t!("jv.success.sheet.align.no_changes").trim())); + } + + return; + }; + + let Some(to) = args.to else { + eprintln!("{}", md(t!("jv.fail.sheet.align.no_direction"))); + return; + }; + + // Move: alignment mode + if task.starts_with("moved") { + let align_to = match to.trim().to_lowercase().as_str() { + "remote" => "remote", + "local" => "local", + "break" => "break", + _ => { + eprintln!("{}", md(t!("jv.fail.sheet.align.unknown_moved_direction"))); + return; + } + }; + + // Build remote move operations + let operations: HashMap = if task == "moved" { + // Align all moved items + align_tasks + .moved + .iter() + .map(|(_, (remote_path, local_path))| { + ( + remote_path.clone(), + (EditMappingOperations::Move, Some(local_path.clone())), + ) + }) + .collect() + } else { + // Align specific moved item + align_tasks + .moved + .iter() + .filter(|(key, _)| key == &task) + .map(|(_, (remote_path, local_path))| { + ( + remote_path.clone(), + (EditMappingOperations::Move, Some(local_path.clone())), + ) + }) + .collect() + }; + + if align_to == "local" { + // Align to local + // Network move mapping + let (pool, ctx, _output) = match build_pool_and_ctx(&local_cfg).await { + Some(result) => result, + None => return, + }; + + // Process mapping edit, errors are handled internally + let _ = proc_mapping_edit(&pool, ctx, EditMappingActionArguments { operations }).await; + } else if align_to == "remote" { + // Align to remote + // Offline move files + for (remote_path, (_, local_path)) in operations { + let local_path = local_path.unwrap(); + let from = local_dir.join(&local_path); + let to = local_dir.join(&remote_path); + + if to.exists() { + eprintln!( + "{}", + md(t!( + "jv.fail.sheet.align.target_exists", + local = local_path.display(), + remote = remote_path.display() + )) + ); + return; + } + + if let Some(parent) = to.parent() { + if let Err(err) = fs::create_dir_all(parent).await { + eprintln!("{}", md(t!("jv.fail.sheet.align.move_failed", err = err))); + continue; + } + } else { + eprintln!( + "{}", + md(t!( + "jv.fail.sheet.align.move_failed", + err = "no parent directory" + )) + ); + continue; + } + if let Err(err) = fs::rename(from, to).await { + eprintln!("{}", md(t!("jv.fail.sheet.align.move_failed", err = err))); + } + } + } else if align_to == "break" { + for (remote_path, (_, _)) in operations { + let Ok(mapping) = local_sheet.mapping_data_mut(&remote_path) else { + eprintln!( + "{}", + md(t!( + "jv.fail.sheet.align.mapping_not_found", + mapping = remote_path.display() + )) + ); + return; + }; + + // Restore the latest detected hash to the original hash, + // making the analyzer unable to correctly match + // + // That is to say, + // if the file's hash has remained completely unchanged from the beginning to the end, + // then break is also ineffective. + mapping.set_last_modifiy_check_hash(Some(mapping.hash_when_updated().clone())); + } + + // Save sheet + match local_sheet.write().await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + } + } + // Lost: match or confirm mode + else if task.starts_with("lost") { + let selected_lost_mapping: Vec<(AlignTaskName, PathBuf)> = align_tasks + .lost + .iter() + .filter(|(name, _)| name.starts_with(&task)) + .cloned() + .collect(); + + if to == "confirm" { + // Confirm mode + for (_, path) in selected_lost_mapping { + if let Err(err) = local_sheet.remove_mapping(&path) { + eprintln!( + "{}", + md(t!("jv.fail.sheet.align.remove_mapping_failed", err = err)) + ); + }; + } + // Save sheet + match local_sheet.write().await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + return; + } + + if to.starts_with("created") { + // Match mode + let created_file: Vec<(AlignTaskName, PathBuf)> = align_tasks + .created + .iter() + .find(|p| p.0.starts_with(&to)) + .map(|found| found.clone()) + .into_iter() + .collect(); + + if selected_lost_mapping.len() < 1 { + eprintln!("{}", md(t!("jv.fail.sheet.align.no_lost_matched"))); + return; + } + + if created_file.len() < 1 { + eprintln!("{}", md(t!("jv.fail.sheet.align.no_created_matched"))); + return; + } + + if selected_lost_mapping.len() > 1 { + eprintln!("{}", md(t!("jv.fail.sheet.align.too_many_lost"))); + return; + } + + if created_file.len() > 1 { + eprintln!("{}", md(t!("jv.fail.sheet.align.too_many_created"))); + return; + } + + // Check completed, match lost and created items + let lost_mapping = &selected_lost_mapping.first().unwrap().1; + let created_file = local_dir.join(&created_file.first().unwrap().1); + + let Ok(hash_calc) = sha1_hash::calc_sha1(&created_file, 4096usize).await else { + eprintln!("{}", md(t!("jv.fail.sheet.align.calc_hash_failed"))); + return; + }; + let Ok(mapping) = local_sheet.mapping_data_mut(lost_mapping) else { + eprintln!( + "{}", + md(t!( + "jv.fail.sheet.align.mapping_not_found", + mapping = lost_mapping.display() + )) + ); + return; + }; + + mapping.set_last_modifiy_check_hash(Some(hash_calc.hash)); + + // Save sheet + match local_sheet.write().await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + } + } + // Erased: confirm mode + else if task.starts_with("erased") { + let selected_erased_mapping: Vec<(AlignTaskName, PathBuf)> = align_tasks + .erased + .iter() + .filter(|(name, _)| name.starts_with(&task)) + .cloned() + .collect(); + + if to == "confirm" { + // Confirm mode + for (_, path) in selected_erased_mapping { + if let Err(err) = local_sheet.remove_mapping(&path) { + eprintln!( + "{}", + md(t!("jv.fail.sheet.align.delete_mapping_failed", err = err)) + ); + }; + + let from = local_dir.join(&path); + + if !from.exists() { + continue; + } + + let to = local_dir + .join(CLIENT_FOLDER_WORKSPACE_ROOT_NAME) + .join(".temp") + .join("erased") + .join(path); + let to_path = to + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| to.clone()); + + let _ = fs::create_dir_all(&to_path).await; + if let Some(e) = fs::rename(&from, &to).await.err() { + eprintln!( + "{}", + md(t!( + "jv.fail.move.rename_failed", + from = from.display(), + to = to.display(), + error = e + )) + .yellow() + ); + } + } + + // Save sheet + match local_sheet.write().await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + return; + } + } +} + +async fn jv_track(args: TrackFileArgs) { + // Perform glob operation before precheck, as precheck will call set_current_dir + let track_files = if let Some(pattern) = args.track_file_pattern.clone() { + let local_dir = match current_local_path() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + let files = glob(pattern, &local_dir).await; + files + .iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect::>() + } else { + println!("{}", md(t!("jv.track"))); + return; + }; + + // set_current_dir called here + let local_config = match precheck().await { + Some(config) => config, + None => { + return; + } + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_config.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + if track_files.iter().len() < 1 { + eprintln!("{}", md(t!("jv.fail.track.no_selection"))); + return; + }; + + let (pool, ctx, mut output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + let files = track_files.iter().cloned().collect(); + let overwrite = args.allow_overwrite; + let update_info = get_update_info(local_workspace, &files, args).await; + + let track_action = proc_track_file_action( + &pool, + ctx, + TrackFileActionArguments { + relative_pathes: files, + file_update_info: update_info, + print_infos: true, + allow_overwrite_modified: overwrite, + }, + ); + + tokio::select! { + result = track_action => { + match result { + Ok(result) => match result { + TrackFileActionResult::Done { + created, + updated, + synced, + skipped, + } => { + println!( + "{}", + md(t!( + "jv.result.track.done", + count = created.len() + updated.len() + synced.len(), + created = created.len(), + updated = updated.len(), + synced = synced.len() + )) + ); + + if skipped.len() > 0 { + println!( + "\n{}", + md(t!( + "jv.result.track.tip_has_skipped", + skipped_num = skipped.len(), + skipped = skipped + .iter() + .map(|f| f.display().to_string()) + .collect::>() + .join("\n") + .trim() + )) + .yellow() + ); + } + } + TrackFileActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + TrackFileActionResult::StructureChangesNotSolved => { + eprintln!("{}", md(t!("jv.result.track.structure_changes_not_solved"))) + } + TrackFileActionResult::CreateTaskFailed(create_task_result) => match create_task_result + { + CreateTaskResult::Success(_) => {} // Success is not handled here + CreateTaskResult::CreateFileOnExistPath(path) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.create_failed.create_file_on_exist_path", + path = path.display() + )) + ) + } + CreateTaskResult::SheetNotFound(sheet) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.create_failed.sheet_not_found", + name = sheet + )) + ) + } + }, + TrackFileActionResult::UpdateTaskFailed(update_task_result) => match update_task_result + { + UpdateTaskResult::Success(_) => {} // Success is not handled here + UpdateTaskResult::VerifyFailed { path, reason } => match reason { + VerifyFailReason::SheetNotFound(sheet_name) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.sheet_not_found", + sheet_name = sheet_name + )) + ) + } + VerifyFailReason::MappingNotFound => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.mapping_not_found", + path = path.display() + )) + ) + } + VerifyFailReason::VirtualFileNotFound(vfid) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.virtual_file_not_found", + vfid = vfid + )) + ) + } + VerifyFailReason::VirtualFileReadFailed(vfid) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.virtual_file_read_failed", + vfid = vfid + )) + ) + } + VerifyFailReason::NotHeld => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.not_held", + path = path.display() + )) + ) + } + VerifyFailReason::VersionDismatch(current_version, latest_version) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.version_dismatch", + version_current = current_version, + version_latest = latest_version + )) + ) + } + VerifyFailReason::UpdateButNoDescription => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.update_but_no_description" + )) + ) + } + VerifyFailReason::VersionAlreadyExist(latest_version) => { + eprintln!( + "{}", + md(t!( + "jv.result.track.update_failed.verify.version_already_exist", + path = path.display(), + version = latest_version + )) + ) + } + }, + }, + TrackFileActionResult::SyncTaskFailed(sync_task_result) => match sync_task_result { + SyncTaskResult::Success(_) => {} // Success is not handled here + }, + }, + Err(e) => handle_err(e), + } + } + _ = async { + while let Some(msg) = output.recv().await { + println!("{}", msg); + } + } => {} + } +} + +async fn get_update_info( + workspace: LocalWorkspace, + files: &HashSet, + args: TrackFileArgs, +) -> HashMap { + let mut result = HashMap::new(); + + if files.len() == 1 { + if let (Some(desc), Some(ver)) = (&args.desc, &args.next_version) { + if let Some(file) = files.iter().next() { + result.insert(file.clone(), (ver.clone(), desc.clone())); + return result; + } + } + } + if args.work { + return start_update_editor(workspace, files, &args).await; + } + + result +} + +async fn start_update_editor( + workspace: LocalWorkspace, + files: &HashSet, + args: &TrackFileArgs, +) -> HashMap { + let account = workspace.config().lock().await.current_account(); + + let Ok(latest_file_data_path) = LatestFileData::data_path(&account) else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return HashMap::new(); + }; + + // Get latest file data + let Ok(latest_file_data) = LatestFileData::read_from(&latest_file_data_path).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return HashMap::new(); + }; + + // Get files + let Ok(analyzed) = AnalyzeResult::analyze_local_status(&workspace).await else { + return HashMap::new(); + }; + // Has unsolved moves, skip + if analyzed.lost.len() > 0 || analyzed.moved.len() > 0 { + return HashMap::new(); + } + // No modified, skip + if analyzed.modified.len() < 1 { + return HashMap::new(); + } + // No sheet, skip + let Some(sheet) = workspace.config().lock().await.sheet_in_use().clone() else { + return HashMap::new(); + }; + // No cached sheet, skip + let Ok(cached_sheet) = CachedSheet::cached_sheet_data(&sheet).await else { + return HashMap::new(); + }; + let files: Vec<(PathBuf, VirtualFileVersion)> = files + .iter() + .filter_map(|file| { + if analyzed.modified.contains(file) { + if let Some(mapping_item) = cached_sheet.mapping().get(file) { + if let Some(latest_version) = latest_file_data.file_version(&mapping_item.id) { + return Some((file.clone(), latest_version.clone())); + } + } + None + } else { + None + } + }) + .collect(); + + // Generate editor text + let mut table = SimpleTable::new_with_padding( + vec![ + t!("editor.modified_line.header.file_path").trim(), + t!("editor.modified_line.header.old_version").trim(), + "", + t!("editor.modified_line.header.new_version").trim(), + ], + 2, + ); + for item in files { + let path = item.0.display().to_string(); + let base_ver = item.1.to_string(); + let next_ver = push_version(&base_ver).unwrap_or(" ".to_string()); + table.push_item(vec![ + path, + base_ver, + t!("editor.modified_line.content.arrow").trim().to_string(), + next_ver, + ]); + } + let lines = table.to_string(); + + let str = t!( + "editor.update_editor", + modified_lines = lines, + description = args.desc.clone().unwrap_or_default() + ); + + let path = workspace + .local_path() + .join(CLIENT_PATH_WORKSPACE_ROOT) + .join(".UPDATE.md"); + let result = input_with_editor(str, path, "#").await.unwrap_or_default(); + + let mut update_info = HashMap::new(); + + // Parse the result returned from the editor + let lines: Vec<&str> = result.lines().collect(); + let mut i = 0; + + // Find the separator line + let mut separator_index = None; + while i < lines.len() { + let line = lines[i].trim(); + if line.chars().all(|c| c == '-') && line.len() >= 5 { + separator_index = Some(i); + break; + } + i += 1; + } + + if let Some(sep_idx) = separator_index { + // Parse path and version information before the separator + for line in &lines[..sep_idx] { + let trimmed_line = line.trim(); + if trimmed_line.is_empty() { + continue; + } + + // Parse format: /directory/file.extension version -> new_version + if let Some(arrow_pos) = trimmed_line.find("->") { + let before_arrow = &trimmed_line[..arrow_pos].trim(); + let after_arrow = &trimmed_line[arrow_pos + 2..].trim(); + + // Separate path and old version + if let Some(last_space) = before_arrow.rfind(' ') { + let path_str = &before_arrow[..last_space].trim(); + let _old_version = &before_arrow[last_space + 1..].trim(); // Old version, needs parsing but not used + let new_version = after_arrow.trim(); + + if !path_str.is_empty() && !new_version.is_empty() { + let path = PathBuf::from(path_str); + // Get description (all content after the separator) + let description = lines[sep_idx + 1..].join("\n").trim().to_string(); + + // Only add to update_info if description is not empty after trimming + if !description.is_empty() { + update_info.insert(path, (new_version.to_string(), description)); + } + } + } + } + } + } + + update_info +} + +async fn jv_hold(args: HoldFileArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Some(hold_file_pattern) = args.hold_file_pattern else { + println!("{}", md(t!("jv.hold"))); + return; + }; + + let files = glob(hold_file_pattern, &local_dir).await; + + let _ = correct_current_dir(); + + jv_change_edit_right( + files + .iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect(), + EditRightChangeBehaviour::Hold, + args.show_fail_details, + args.skip_failed, + args.force, + ) + .await; +} + +async fn jv_throw(args: ThrowFileArgs) { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Some(throw_file_pattern) = args.throw_file_pattern else { + println!("{}", md(t!("jv.throw"))); + return; + }; + + let files = glob(throw_file_pattern, &local_dir).await; + + let _ = correct_current_dir(); + + jv_change_edit_right( + files + .iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect(), + EditRightChangeBehaviour::Throw, + args.show_fail_details, + args.skip_failed, + args.force, + ) + .await; +} + +async fn jv_change_edit_right( + files: Vec, + behaviour: EditRightChangeBehaviour, + show_fail_details: bool, + mut skip_failed: bool, + force: bool, +) { + // If both `--details` and `--skip-failed` are set, only enable `--details` + if show_fail_details && skip_failed { + skip_failed = false; + } + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Ok(local_cfg) = LocalConfig::read_from(local_dir.join(CLIENT_FILE_WORKSPACE)).await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_cfg.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + // Get files + let Ok(analyzed) = AnalyzeResult::analyze_local_status(&local_workspace).await else { + eprintln!("{}", md(t!("jv.fail.status.analyze")).trim()); + return; + }; + + let account = local_cfg.current_account(); + + let Ok(latest_file_data_path) = LatestFileData::data_path(&account) else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + let Ok(latest_file_data) = LatestFileData::read_from(&latest_file_data_path).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_file_data", + account = &account + )) + ); + return; + }; + + let Some(sheet_name) = local_cfg.sheet_in_use().clone() else { + eprintln!("{}", md(t!("jv.fail.status.no_sheet_in_use")).trim()); + return; + }; + + let Ok(local_sheet) = local_workspace.local_sheet(&account, &sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &account, + sheet = &sheet_name + )) + ); + return; + }; + + let Ok(cached_sheet) = CachedSheet::cached_sheet_data(&sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.cached_sheet", + sheet = &sheet_name + )) + ); + return; + }; + + let num = files.iter().len(); + if num < 1 { + eprintln!("{}", md(t!("jv.fail.change_edit_right.no_selection"))); + return; + } + + let mut passed_files = Vec::new(); + let mut details = Vec::new(); + let mut failed = 0; + + // Helper function to handle validation failures + fn handle_validation_failure( + show_fail_details: bool, + details: &mut Vec, + failed: &mut usize, + num: usize, + reason: String, + ) -> bool { + if show_fail_details { + details.push(reason); + *failed += 1; + true // Continue to next file + } else { + eprintln!( + "{}", + md(t!("jv.fail.change_edit_right.check_failed", num = num)) + ); + false // Break processing + } + } + + for file in files { + let exists = file.exists(); + + // If force is enabled, add to the list regardless + if force { + passed_files.push(file); + continue; + } + + // Mapping exists + let Some(cached_mapping) = cached_sheet.mapping().get(&file) else { + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.not_found_in_sheet") + ) + .trim() + .to_string(); + + if !handle_validation_failure(show_fail_details, &mut details, &mut failed, num, reason) + { + return; + } + continue; + }; + + let vfid: VirtualFileId = if exists { + // Not tracked + let Ok(local_mapping) = local_sheet.mapping_data(&file) else { + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.not_a_tracked_file") + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + continue; + }; + + let vfid = local_mapping.mapping_vfid(); + let local_version = local_mapping.version_when_updated(); + + // Base version unmatch + if local_version + != latest_file_data + .file_version(vfid) + .unwrap_or(&String::default()) + { + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.base_version_unmatch") + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + continue; + } + + vfid.clone() + } else { + cached_mapping.id.clone() + }; + + // Hold validation + let holder = latest_file_data.file_holder(&vfid); + let validation_passed = match behaviour { + EditRightChangeBehaviour::Hold => { + if holder.is_some_and(|h| h != &account) { + // Has holder but not current account + let holder_name = holder.unwrap(); + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!( + "jv.fail.change_edit_right.check_fail_reason.has_holder", + holder = holder_name + ) + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + false + } else if holder.is_some_and(|h| h == &account) { + // Already held by current account + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.already_held") + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + false + } else { + true + } + } + EditRightChangeBehaviour::Throw => { + if holder.is_some_and(|h| h != &account) { + // Not the holder + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.not_holder") + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + false + } else if analyzed.modified.contains(&file) { + // Already modified + let reason = t!( + "jv.fail.change_edit_right.check_fail_item", + path = file.display(), + reason = t!("jv.fail.change_edit_right.check_fail_reason.already_modified") + ) + .trim() + .to_string(); + + if !handle_validation_failure( + show_fail_details, + &mut details, + &mut failed, + num, + reason, + ) { + return; + } + false + } else { + true + } + } + }; + + if validation_passed { + passed_files.push(file); + } + } + + if failed > 0 && show_fail_details { + eprintln!( + "{}", + md(t!( + "jv.fail.change_edit_right.check_failed_details", + num = num, + failed = failed, + items = details.join("\n").trim().yellow() + )) + ); + return; + } + + if !(failed > 0 && skip_failed) && failed != 0 { + return; + } + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_cfg).await { + Some(result) => result, + None => return, + }; + + let passed = passed_files + .iter() + .map(|f| (f.clone(), behaviour.clone())) + .collect(); + + match proc_change_virtual_file_edit_right_action(&pool, ctx, (passed, true)).await { + Ok(r) => match r { + ChangeVirtualFileEditRightResult::Success { + success_hold, + success_throw, + } => { + if success_hold.len() > 0 && success_throw.len() == 0 { + println!( + "{}", + md(t!( + "jv.result.change_edit_right.success.hold", + num = success_hold.len() + )) + ) + } else if success_hold.len() == 0 && success_throw.len() > 0 { + println!( + "{}", + md(t!( + "jv.result.change_edit_right.success.throw", + num = success_throw.len() + )) + ) + } else if success_hold.len() > 0 && success_throw.len() > 0 { + println!( + "{}", + md(t!( + "jv.result.change_edit_right.success.mixed", + num = success_hold.len() + success_throw.len(), + num_hold = success_hold.len(), + num_throw = success_throw.len() + )) + ) + } else { + eprintln!("{}", md(t!("jv.result.change_edit_right.failed.none"))) + } + } + ChangeVirtualFileEditRightResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + ChangeVirtualFileEditRightResult::DoNothing => { + eprintln!("{}", md(t!("jv.result.change_edit_right.failed.none"))) + } + }, + Err(e) => handle_err(e), + } +} + +async fn jv_move(args: MoveMappingArgs) { + let local_dir = match current_local_path() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + + let move_files = if let Some(from_pattern) = args.move_mapping_pattern.clone() { + let from = glob(from_pattern, &local_dir).await; + from.iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect::>() + } else { + println!("{}", md(t!("jv.move"))); + return; + }; + + let to_pattern = if args.to_mapping_pattern.is_some() { + args.to_mapping_pattern.unwrap() + } else { + if args.erase { + "".to_string() + } else { + eprintln!("{}", md(t!("jv.fail.move.no_target_dir"))); + return; + } + }; + + let is_to_pattern_a_dir = to_pattern.ends_with('/') || to_pattern.ends_with('\\'); + + let from_mappings = move_files + .iter() + .map(|f| f.display().to_string()) + .collect::>(); + + let base_path = Globber::from(&to_pattern).base().clone(); + let base_path = format_path(base_path.strip_prefix(&local_dir).unwrap().join("./")).unwrap(); + let to_path = base_path.join(to_pattern); + + let mut edit_mapping_args: EditMappingActionArguments = EditMappingActionArguments { + operations: HashMap::::new(), + }; + + if args.erase { + // Generate erase operation parameters + for from_mapping in from_mappings { + edit_mapping_args + .operations + .insert(from_mapping.into(), (EditMappingOperations::Erase, None)); + } + } else { + // Generate move operation parameters + // Single file move + if from_mappings.len() == 1 { + let from = format_path_str(from_mappings[0].clone()).unwrap(); + let to = if is_to_pattern_a_dir { + // Input is a directory, append the filename + format_path( + to_path + .join(from.strip_prefix(&base_path.display().to_string()).unwrap()) + .to_path_buf(), + ) + .unwrap() + } else { + // Input is a filename, use it directly + format_path(to_path.to_path_buf()).unwrap() + }; + + let from: PathBuf = from.into(); + // If the from path contains to_path, ignore it to avoid duplicate moves + if !from.starts_with(to_path) { + edit_mapping_args + .operations + .insert(from, (EditMappingOperations::Move, Some(to.clone()))); + } + } else + // Multiple file move + if from_mappings.len() > 1 && is_to_pattern_a_dir { + let to_path = format_path(to_path).unwrap(); + for p in &from_mappings { + let name = p.strip_prefix(&base_path.display().to_string()).unwrap(); + let to = format_path(to_path.join(name)) + .unwrap() + .display() + .to_string(); + + let from: PathBuf = p.into(); + // If the from path contains to_path, ignore it to avoid duplicate moves + if !from.starts_with(to_path.display().to_string()) { + edit_mapping_args + .operations + .insert(from, (EditMappingOperations::Move, Some(to.into()))); + } + } + } + if from_mappings.len() > 1 && !is_to_pattern_a_dir { + eprintln!("{}", md(t!("jv.fail.move.count_doesnt_match"))); + return; + } + + // NOTE + // if move_file_mappings.len() < 1 { + // This case has already been handled earlier: output Help + // } + } + + let local_cfg = match precheck().await { + Some(config) => config, + None => return, + }; + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_cfg).await { + Some(result) => result, + None => return, + }; + + if proc_mapping_edit(&pool, ctx, edit_mapping_args.clone()) + .await + .is_ok() + { + // If the operation succeeds and only_remote is not enabled, + // synchronize local moves + if !args.only_remote { + let erase_dir = local_dir + .join(CLIENT_FOLDER_WORKSPACE_ROOT_NAME) + .join(".temp") + .join("erased"); + + let mut skipped = 0; + for (from_relative, (operation, to_relative)) in edit_mapping_args.operations { + let from = local_dir.join(&from_relative); + + if !from.exists() { + continue; + } + + let to = match operation { + EditMappingOperations::Move => local_dir.join(to_relative.unwrap()), + EditMappingOperations::Erase => erase_dir.join(&from_relative), + }; + if let Some(to_dir) = to.parent() { + let _ = fs::create_dir_all(to_dir).await; + } + if let Some(e) = fs::rename(&from, &to).await.err() { + eprintln!( + "{}", + md(t!( + "jv.fail.move.rename_failed", + from = from.display(), + to = to.display(), + error = e + )) + .yellow() + ); + skipped += 1; + } + } + if skipped > 0 { + eprintln!("{}", md(t!("jv.fail.move.has_rename_failed"))); + } + } + } +} + +async fn proc_mapping_edit( + pool: &ActionPool, + ctx: ActionContext, + edit_mapping_args: EditMappingActionArguments, +) -> Result<(), ()> { + match proc_edit_mapping_action( + pool, + ctx, + EditMappingActionArguments { + operations: edit_mapping_args.operations.clone(), + }, + ) + .await + { + Ok(r) => match r { + EditMappingActionResult::Success => { + println!("{}", md(t!("jv.result.move.success"))); + Ok(()) + } + EditMappingActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))); + Err(()) + } + EditMappingActionResult::MappingNotFound(path_buf) => { + eprintln!( + "{}", + md(t!( + "jv.result.move.mapping_not_found", + path = path_buf.display() + )) + ); + Err(()) + } + EditMappingActionResult::InvalidMove(invalid_move_reason) => { + match invalid_move_reason { + InvalidMoveReason::MoveOperationButNoTarget(path_buf) => { + eprintln!( + "{}", + md(t!( + "jv.result.move.invalid_move.no_target", + path = path_buf.display() + )) + ); + } + InvalidMoveReason::ContainsDuplicateMapping(path_buf) => { + eprintln!( + "{}", + md(t!( + "jv.result.move.invalid_move.duplicate_mapping", + path = path_buf.display() + )) + ); + } + } + Err(()) + } + EditMappingActionResult::Unknown => { + eprintln!("{}", md(t!("jv.result.move.unknown"))); + Err(()) + } + EditMappingActionResult::EditNotAllowed => { + eprintln!( + "{}", + md(t!("jv.result.common.not_allowed_in_reference_sheet")) + ); + Err(()) + } + }, + Err(e) => { + handle_err(e); + Err(()) + } + } +} + +async fn jv_share(args: ShareMappingArgs) { + if let (Some(args1), None, None) = (&args.args1, &args.args2, &args.args3) { + // List mode + if args1.trim() == "list" || args1.trim() == "ls" { + share_list(args).await; + return; + } + + share_accept(args1.to_string(), args).await; + return; + } + + if let (Some(args1), Some(args2), None) = (&args.args1, &args.args2, &args.args3) { + // 如果是 work 模式,那么就是分享 + if args.work { + share_out( + args1.to_string(), + args2.to_string(), + String::default(), + args, + ) + .await; + return; + } + + // See mode + if args1.trim() == "see" { + share_see(args2.to_string(), args).await; + return; + } + + share_in(args1.to_string(), args2.to_string(), args).await; + return; + } + + if let (Some(share_pattern), Some(to_sheet), Some(description)) = + (&args.args1, &args.args2, &args.args3) + { + share_out( + share_pattern.to_string(), + to_sheet.to_string(), + description.to_string(), + args, + ) + .await; + return; + } + + println!("{}", md(t!("jv.share"))); +} + +async fn share_list(args: ShareMappingArgs) { + let _ = correct_current_dir(); + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default(); + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_config.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_config.current_account() + )) + ); + return; + }; + + if let Some(shares) = latest_info.shares_in_my_sheets.get(&sheet_name) { + // Sort + let mut sorted_shares: BTreeMap = BTreeMap::new(); + for (id, share) in shares { + sorted_shares.insert(id.clone(), share); + } + + if args.json_output { + let share_list: Vec = sorted_shares + .iter() + .map(|(share_id, share)| ShareItem { + share_id: share_id.clone(), + sharer: share.sharer.clone(), + description: share.description.clone(), + file_count: share.mappings.len(), + }) + .collect(); + let result = ShareListResult { share_list }; + print_json(result, args.pretty); + return; + } + + if !args.raw { + // Create table and insert information + let mut table = SimpleTable::new(vec![ + t!("jv.success.share.list.headers.id"), + t!("jv.success.share.list.headers.sharer"), + t!("jv.success.share.list.headers.description"), + t!("jv.success.share.list.headers.file_count"), + ]); + for (id, share) in sorted_shares { + table.insert_item( + 0, + vec![ + id.to_string(), + share.sharer.to_string(), + truncate_first_line(share.description.to_string()), + share.mappings.len().to_string(), + ], + ); + } + + // Render + println!("{}", table); + println!("{}", md(t!("jv.success.share.list.footer"))); + } else { + sorted_shares + .iter() + .for_each(|share| println!("{}", share.0)); + } + } +} + +async fn share_see(share_id: String, args: ShareMappingArgs) { + let _ = correct_current_dir(); + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default(); + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_config.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_config.current_account() + )) + ); + return; + }; + + if let Some(shares) = latest_info.shares_in_my_sheets.get(&sheet_name) { + if let Some(share) = shares.get(&share_id) { + if args.json_output { + let result = SeeShareResult { + share_id: share_id.clone(), + sharer: share.sharer.clone(), + description: share.description.clone(), + mappings: share.mappings.clone(), + }; + print_json(result, args.pretty); + return; + } + + println!( + "{}", + md(t!( + "jv.success.share.content", + share_id = share_id, + sharer = share.sharer, + description = share.description, + mappings = render_share_path_tree(&share.mappings) + )) + ); + } + } +} + +async fn share_accept(import_id: String, args: ShareMappingArgs) { + let _ = correct_current_dir(); + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let sheet_name = local_config.sheet_in_use().clone().unwrap_or_default(); + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_config.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_config.current_account() + )) + ); + return; + }; + + let contains_share = if let Some(share_ids) = latest_info.shares_in_my_sheets.get(&sheet_name) { + share_ids.contains_key(&import_id) + } else { + false + }; + + if !contains_share { + eprintln!( + "{}", + md(t!("jv.fail.share.share_id_not_exist", id = &import_id)) + ); + return; + } + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + let share_merge_mode = { + if args.safe { + ShareMergeMode::Safe + } else if args.skip { + ShareMergeMode::Skip + } else if args.overwrite { + ShareMergeMode::Overwrite + } else if args.reject { + ShareMergeMode::RejectAll + } else { + ShareMergeMode::Safe + } + }; + + match proc_merge_share_mapping_action( + &pool, + ctx, + MergeShareMappingArguments { + share_id: import_id.clone(), + share_merge_mode, + }, + ) + .await + { + Ok(r) => match r { + MergeShareMappingActionResult::Success => { + if args.reject { + println!( + "{}", + md(t!( + "jv.result.share.merge_shares.success_reject", + share_id = &import_id + )) + ); + } else { + println!( + "{}", + md(t!( + "jv.result.share.merge_shares.success", + share_id = &import_id, + sheet = &sheet_name + )) + ); + } + } + MergeShareMappingActionResult::HasConflicts => { + eprintln!( + "{}", + md(t!( + "jv.result.share.merge_shares.has_conflicts", + share_id = &import_id + )) + ); + } + MergeShareMappingActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))); + } + MergeShareMappingActionResult::EditNotAllowed => { + eprintln!( + "{}", + md(t!("jv.result.share.merge_shares.edit_not_allowed")) + ); + } + MergeShareMappingActionResult::ShareIdNotFound(share_id) => { + eprintln!( + "{}", + md(t!( + "jv.result.share.merge_shares.share_id_not_found", + share_id = share_id + )) + ); + } + MergeShareMappingActionResult::MergeFails(error) => { + eprintln!( + "{}", + md(t!( + "jv.result.share.merge_shares.merge_failed", + error = error + )) + ); + } + MergeShareMappingActionResult::Unknown => { + eprintln!("{}", md(t!("jv.result.share.merge_shares.unknown"))); + } + }, + Err(e) => handle_err(e), + } +} + +async fn share_in(_from_sheet: String, _import_pattern: String, _args: ShareMappingArgs) { + // TODO: Implement pull mode logic +} + +async fn share_out( + share_pattern: String, + to_sheet: String, + description: String, + args: ShareMappingArgs, +) { + let mut shared_files = { + let local_dir = match current_local_path() { + Some(dir) => dir, + None => { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + let files = glob(share_pattern, &local_dir).await; + files + .iter() + .filter_map(|f| PathBuf::from_str(f.0).ok()) + .collect::>() + }; + + let _ = correct_current_dir(); + + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let Some(local_workspace) = LocalWorkspace::init_current_dir(local_config.clone()) else { + eprintln!("{}", md(t!("jv.fail.workspace_not_found")).trim()); + return; + }; + + let Ok(latest_info) = LatestInfo::read_from(LatestInfo::latest_info_path( + &local_dir, + &local_config.current_account(), + )) + .await + else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.latest_info", + account = &local_config.current_account() + )) + ); + return; + }; + + // Pre-check if the sheet exists + let contains_in_my_sheet = latest_info.visible_sheets.contains(&to_sheet); + let contains_in_other_sheet = latest_info + .invisible_sheets + .iter() + .find(|info| info.sheet_name == to_sheet) + .is_some(); + if !contains_in_my_sheet && !contains_in_other_sheet { + eprintln!( + "{}", + md(t!("jv.fail.share.invalid_target_sheet", sheet = &to_sheet)) + ); + return; + } + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + let to_sheet_holder = { + if latest_info.reference_sheets.contains(&to_sheet) { + VAULT_HOST_NAME.to_string() + } else if latest_info.visible_sheets.contains(&to_sheet) { + local_config.current_account() + } else { + let mut holder = String::new(); + for info in &latest_info.invisible_sheets { + if info.sheet_name == to_sheet { + holder = info.holder_name.as_ref().cloned().unwrap_or_default(); + break; + } + } + holder + } + }; + + let Some(description) = (if args.work { + start_share_editor(&local_workspace, &mut shared_files, &to_sheet_holder).await + } else { + Some(description.to_string()) + }) else { + eprintln!("{}", md(t!("jv.fail.share.no_description"))); + return; + }; + + match proc_share_mapping_action( + &pool, + ctx, + ShareMappingArguments { + mappings: shared_files.clone(), + description, + + // Since the Action internally checks the current sheet, + // there's no need to fill in from_sheet here. + // This is prepared for pull operations. + from_sheet: None, + to_sheet: to_sheet.clone(), + }, + ) + .await + { + Ok(r) => match r { + ShareMappingActionResult::Success => { + println!( + "{}", + md(t!( + "jv.result.share.share_mapping.success", + file_nums = shared_files.len(), + to_sheet = to_sheet, + to_sheet_holder = to_sheet_holder + )) + ); + } + ShareMappingActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))); + } + ShareMappingActionResult::TargetSheetNotFound(sheet) => { + eprintln!( + "{}", + md(t!( + "jv.result.share.share_mapping.target_sheet_not_found", + to_sheet = sheet + )) + ); + } + ShareMappingActionResult::TargetIsSelf => { + eprintln!("{}", md(t!("jv.result.share.share_mapping.target_is_self"))); + } + ShareMappingActionResult::MappingNotFound(path_buf) => { + eprintln!( + "{}", + md(t!( + "jv.result.share.share_mapping.mapping_not_found", + mapping = path_buf.display() + )) + ); + } + ShareMappingActionResult::Unknown => { + eprintln!("{}", md(t!("jv.result.share.share_mapping.unknown"))); + } + }, + Err(e) => handle_err(e), + } +} + +async fn start_share_editor( + workspace: &LocalWorkspace, + shared_files: &mut Vec, + holder: &MemberId, +) -> Option { + let config = workspace.config().lock().await.clone(); + + let sheet_name = config.sheet_in_use().clone(); + let Some(sheet_name) = sheet_name else { + eprintln!("{}", md(t!("jv.fail.status.no_sheet_in_use")).trim()); + return None; + }; + + let account = config.current_account(); + let Ok(local_sheet) = workspace.local_sheet(&account, &sheet_name).await else { + eprintln!( + "{}", + md(t!( + "jv.fail.cfg_not_found.local_sheet", + account = &account, + sheet = &sheet_name + )) + ); + return None; + }; + + // Generate shared files and automatically comment out lost mappings + let shared_files_str: String = shared_files + .iter() + .map(|p| { + if local_sheet.mapping_data(p).is_err() { + format!("# {}", p.display().to_string()) + } else { + p.display().to_string() + } + }) + .collect::>() + .join("\n"); + let shared_files_str = shared_files_str.trim(); + + let editor_content = t!( + "editor.share_editor", + sheet = sheet_name, + holder = holder, + shared_files = shared_files_str + ); + + let path = workspace + .local_path() + .join(CLIENT_PATH_WORKSPACE_ROOT) + .join(".SHARE.md"); + + let result = input_with_editor(format!("{}\n", editor_content), path, "#") + .await + .unwrap_or_default(); + + // Find the last separator line (a line that, when trimmed, consists of at least 3 '-' characters) + let lines: Vec<&str> = result.lines().collect(); + let mut last_separator_index = None; + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.chars().all(|c| c == '-') && trimmed.len() >= 3 { + last_separator_index = Some(i); + } + } + + // Extract content after the last separator + let description = if let Some(sep_idx) = last_separator_index { + lines[sep_idx + 1..].join("\n").trim().to_string() + } else { + result.trim().to_string() + }; + + // Update shared_files with the first segment (before the first separator) + if let Some(sep_idx) = last_separator_index { + let first_segment: Vec = lines[..sep_idx] + .iter() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect(); + *shared_files = first_segment; + } + + if description.trim().is_empty() { + None + } else { + Some(description) + } +} + +async fn jv_account_add(user_dir: UserDirectory, args: AccountAddArgs) { + let member = Member::new(args.account_name.clone()); + + match user_dir.register_account(member).await { + Ok(_) => { + println!( + "{}", + t!("jv.success.account.add", account = args.account_name) + ); + } + Err(_) => { + eprintln!("{}", t!("jv.fail.account.add", account = args.account_name)); + return; + } + } + if args.keygen { + let output_path = current_dir().unwrap().join("tempkey.pem"); + + match Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "ed25519", + "-out", + &output_path.to_string_lossy(), + ]) + .status() + .await + { + Ok(status) if status.success() => { + jv_account_move_key( + user_dir, + MoveKeyToAccountArgs { + help: false, + account_name: args.account_name, + key_path: output_path, + }, + ) + .await + } + Ok(_) => { + eprintln!("{}", t!("jv.fail.account.keygen")); + } + Err(_) => { + eprintln!("{}", t!("jv.fail.account.keygen_exec")); + } + } + } +} + +async fn jv_account_remove(user_dir: UserDirectory, args: AccountRemoveArgs) { + match user_dir.remove_account(&args.account_name) { + Ok(_) => { + println!( + "{}", + t!("jv.success.account.remove", account = args.account_name) + ); + } + Err(_) => { + eprintln!( + "{}", + t!("jv.fail.account.remove", account = args.account_name) + ); + } + } +} + +async fn jv_account_list(user_dir: UserDirectory, args: AccountListArgs) { + let _ = correct_current_dir(); + + if args.json_output { + let Ok(account_ids) = user_dir.account_ids() else { + return; + }; + let mut result = HashMap::new(); + for account_id in account_ids { + let has_private_key = user_dir.has_private_key(&account_id); + result.insert(account_id, AccountItem { has_private_key }); + } + let json_result = AccountListJsonResult { result }; + print_json(json_result, args.pretty); + return; + } + + if args.raw { + let Ok(account_ids) = user_dir.account_ids() else { + return; + }; + account_ids.iter().for_each(|a| println!("host/{}", a)); + account_ids.iter().for_each(|a| println!("{}", a)); + return; + } + + match user_dir.account_ids() { + Ok(account_ids) => { + println!( + "{}", + md(t!( + "jv.success.account.list.header", + num = account_ids.len() + )) + ); + + let mut i = 0; + for account_id in account_ids { + println!("{}. {} {}", i + 1, &account_id, { + if user_dir.has_private_key(&account_id) { + t!("jv.success.account.list.status_has_key") + } else { + std::borrow::Cow::Borrowed("") + } + }); + i += 1; + } + } + Err(_) => { + eprintln!("{}", t!("jv.fail.account.list")); + } + } +} + +async fn jv_account_as(user_dir: UserDirectory, args: SetLocalWorkspaceAccountArgs) { + let (account, is_host_mode) = process_account_parameter(args.account_name); + + // Account exist + let Ok(member) = user_dir.account(&account).await else { + eprintln!("{}", t!("jv.fail.account.not_found", account = &account)); + return; + }; + + let Some(_local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Ok(mut local_cfg) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + if let Err(_) = local_cfg.set_current_account(member.id()) { + eprintln!("{}", md(t!("jv.fail.account.as"))); + return; + }; + + local_cfg.set_host_mode(is_host_mode); + + match LocalConfig::write(&local_cfg).await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e))); + return; + } + }; + + if is_host_mode { + println!( + "{}", + md(t!("jv.success.account.as_host", account = member.id())) + ); + } else { + println!( + "{}", + t!("jv.success.account.as", account = member.id()).trim() + ); + } +} + +/// Input account, get MemberId and whether it's a host +/// If input is host/xxx, output is xxx, true +/// If input is xxx, output is xxx, false +fn process_account_parameter(account_input: String) -> (MemberId, bool) { + let is_host = account_input.starts_with("host/"); + let account_id = if is_host { + account_input.strip_prefix("host/").unwrap().to_string() + } else { + account_input + }; + (snake_case!(account_id), is_host) +} + +async fn jv_account_move_key(user_dir: UserDirectory, args: MoveKeyToAccountArgs) { + // Key file exist + if !args.key_path.exists() { + eprintln!( + "{}", + t!("jv.fail.path_not_found", path = args.key_path.display()) + ); + return; + } + + // Account exist + let Ok(_member) = user_dir.account(&args.account_name).await else { + eprintln!( + "{}", + t!("jv.fail.account.not_found", account = args.account_name) + ); + return; + }; + + // Rename key file + match move_across_partitions( + args.key_path, + user_dir.account_private_key_path(&args.account_name), + ) + .await + { + Ok(_) => println!("{}", t!("jv.success.account.move_key")), + Err(_) => eprintln!("{}", t!("jv.fail.account.move_key")), + } +} + +async fn jv_account_generate_pub_key(user_dir: UserDirectory, args: GeneratePublicKeyArgs) { + let private_key_path = user_dir.account_private_key_path(&args.account_name); + let target_path = args + .output_dir + .unwrap_or(current_dir().unwrap()) + .join(format!("{}.pem", args.account_name)); + + match Command::new("openssl") + .args([ + "pkey", + "-in", + &private_key_path.to_string_lossy(), + "-pubout", + "-out", + &target_path.to_string_lossy(), + ]) + .status() + .await + { + Ok(status) if status.success() => { + println!( + "{}", + t!( + "jv.success.account.generate_pub_key", + export = target_path.display() + ) + ); + } + Ok(_) => { + eprintln!("{}", t!("jv.fail.account.keygen")); + } + Err(_) => { + eprintln!("{}", t!("jv.fail.account.keygen_exec")); + } + } +} + +async fn jv_update(update_file_args: UpdateArgs) { + let local_config = match precheck().await { + Some(config) => config, + None => return, + }; + + let (pool, ctx, _output) = match build_pool_and_ctx(&local_config).await { + Some(result) => result, + None => return, + }; + + match proc_update_to_latest_info_action(&pool, ctx, ()).await { + Err(e) => handle_err(e), + Ok(result) => { + if !update_file_args.silent { + match result { + UpdateToLatestInfoResult::Success => { + println!("{}", md(t!("jv.result.update.success"))); + } + UpdateToLatestInfoResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + UpdateToLatestInfoResult::SyncCachedSheetFail( + sync_cached_sheet_fail_reason, + ) => match sync_cached_sheet_fail_reason { + SyncCachedSheetFailReason::PathAlreadyExist(path_buf) => { + eprintln!( + "{}", + md(t!( + "jv.result.update.fail.sync_cached_sheet_fail.path_already_exist", + path = path_buf.display() + )) + ); + } + }, + } + } + } + } +} + +async fn jv_direct(args: DirectArgs) { + let Some(upstream) = args.upstream else { + println!("{}", md(t!("jv.direct"))); + return; + }; + + if !args.confirm { + println!( + "{}", + t!("jv.confirm.direct", upstream = upstream).trim().yellow() + ); + confirm_hint_or(t!("common.confirm"), || exit(1)).await; + } + + let pool = client_registry::client_action_pool(); + let upstream = match socket_addr_helper::get_socket_addr(&upstream, PORT).await { + Ok(result) => result, + Err(e) => { + eprintln!( + "{}", + md(t!( + "jv.fail.parse.str_to_sockaddr", + str = &upstream.trim(), + err = e + )) + ); + return; + } + }; + + let Some(instance) = connect(upstream).await else { + // Since connect() function already printed error messages, we only handle the return here + return; + }; + + let ctx = ActionContext::local().insert_instance(instance); + + match proc_set_upstream_vault_action(&pool, ctx, upstream).await { + Err(e) => handle_err(e), + Ok(result) => match result { + SetUpstreamVaultActionResult::DirectedAndStained => { + println!( + "{}", + md(t!( + "jv.result.direct.directed_and_stained", + upstream = upstream + )) + ); + insert_recent_ip_address(upstream.to_string().trim()).await; + } + SetUpstreamVaultActionResult::Redirected => { + println!( + "{}", + md(t!("jv.result.direct.redirected", upstream = upstream)) + ); + insert_recent_ip_address(upstream.to_string().trim()).await; + } + SetUpstreamVaultActionResult::AlreadyStained => { + eprintln!("{}", md(t!("jv.result.direct.already_stained"))) + } + SetUpstreamVaultActionResult::AuthorizeFailed(e) => { + eprintln!("{}", md(t!("jv.result.common.authroize_failed", err = e))) + } + SetUpstreamVaultActionResult::RedirectFailed(e) => { + eprintln!("{}", md(t!("jv.result.direct.redirect_failed", err = e))) + } + SetUpstreamVaultActionResult::SameUpstream => { + eprintln!("{}", md(t!("jv.result.direct.same_upstream"))) + } + _ => {} + }, + }; +} + +async fn jv_unstain(args: UnstainArgs) { + let Some(_local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + }; + + let Ok(mut local_cfg) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return; + }; + + if !local_cfg.stained() { + eprintln!("{}", md(t!("jv.fail.unstain"))); + return; + } + + if !args.confirm { + println!( + "{}", + md(t!("jv.confirm.unstain", upstream = local_cfg.vault_addr())).yellow() + ); + confirm_hint_or(t!("common.confirm"), || exit(1)).await; + } + + local_cfg.unstain(); + match LocalConfig::write(&local_cfg).await { + Ok(_) => {} + Err(e) => { + eprintln!("{}", md(t!("jv.fail.write_cfg", error = e.to_string()))); + return; + } + }; + + println!("{}", md(t!("jv.success.unstain"))); +} + +async fn jv_docs(args: DocsArgs) { + let Some(docs_name) = args.docs_name else { + if !args.raw { + println!("{}", md(t!("jv.docs"))); + } + return; + }; + + if docs_name.trim() == "ls" || docs_name.trim() == "list" { + let docs = documents(); + + if args.raw { + docs.iter().for_each(|d| { + if d.starts_with("docs_") { + println!("{}", d.trim_start_matches("docs_")) + } + }); + return; + } + + println!("{}", md(t!("jv.success.docs.list.header"))); + let mut i = 0; + for doc in docs { + if doc.starts_with("docs_") { + println!( + "{}", + md(t!( + "jv.success.docs.list.item", + num = i + 1, + docs_name = doc.trim_start_matches("docs_") + )) + ); + i += 1; + } + } + println!("{}", md(t!("jv.success.docs.list.footer"))); + + return; + } + + let name = format!("docs_{}", snake_case!(docs_name.clone())); + let Some(document) = document(name) else { + eprintln!( + "{}", + md(t!("jv.fail.docs.not_found", docs_name = docs_name)) + ); + return; + }; + + if args.direct { + println!("{}", document.trim()); + } else { + let Some(doc_dir) = current_cfg_dir() else { + eprintln!( + "{}", + md(t!("jv.fail.docs.no_doc_dir", docs_name = docs_name)) + ); + return; + }; + let file = doc_dir.join("DOCS.MD"); + if let Err(e) = show_in_pager(document, file).await { + eprintln!( + "{}", + md(t!( + "jv.fail.docs.open_editor", + err = e, + docs_name = docs_name + )) + ); + } + } +} + +async fn jv_debug_glob(glob_args: DebugGlobArgs) { + let local_dir = match current_local_path() { + Some(dir) => dir, + None => { + // No, dont print anything + // eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return; + } + }; + + for path in glob(glob_args.pattern, &local_dir).await.keys() { + println!("{}", path); + } +} + +async fn glob(pattern: impl Into, local_dir: &PathBuf) -> BTreeMap { + let pattern = pattern.into(); + let globber = match get_globber(&pattern, true).await { + Ok(g) => g, + Err(_) => match get_globber(&pattern, false).await { + Ok(g) => g, + Err(_) => return BTreeMap::new(), + }, + }; + + let result = globber.names(); + let base_dir = globber.base(); + + let relative_path = base_dir + .strip_prefix(local_dir) + .unwrap_or(local_dir.as_path()); + + let mut filtered_paths: Vec = result + .into_iter() + .filter_map(|name| Some(relative_path.join(name).display().to_string())) + .collect(); + + let path_map: BTreeMap = filtered_paths.drain(..).map(|path| (path, ())).collect(); + path_map +} + +async fn get_globber( + pattern: impl Into, + with_current_sheet: bool, +) -> Result { + // Build globber + let globber = Globber::from(pattern.into()); + + let globber = if with_current_sheet { + // Get necessary informations + let Some(local_dir) = current_local_path() else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Workspace not found", + )); + }; + + let Ok(local_cfg) = LocalConfig::read_from(local_dir.join(CLIENT_FILE_WORKSPACE)).await + else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Local Config read failed", + )); + }; + let Some(sheet_name) = local_cfg.sheet_in_use().clone() else { + return Err(Error::new(std::io::ErrorKind::NotFound, "No sheet in use")); + }; + + let Ok(cached_sheet) = CachedSheet::cached_sheet_data(&sheet_name).await else { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Cached sheet not found", + )); + }; + + let current_dir = current_dir()?; + + if !current_dir.starts_with(&local_dir) { + return Err(Error::new( + std::io::ErrorKind::NotFound, + "Not a local workspace", + )); + } + + // Sheet mode + globber.glob(|current_dir| { + let mut result = HashSet::new(); + + // First, add local files + get_local_files(¤t_dir, &mut result); + + // Start collecting sheet files + // Check if we're in the workspace directory (get current path relative to local workspace) + let Ok(relative_path_to_local) = current_dir.strip_prefix(&local_dir) else { + return result.into_iter().collect(); + }; + + let mut dirs = HashSet::new(); + + cached_sheet.mapping().iter().for_each(|(path, _)| { + let left = relative_path_to_local; + + // Skip: files that don't start with the current directory + let Ok(right) = path.strip_prefix(left) else { + return; + }; + + // File: starts with current directory and doesn't contain "/" + // (since we already filtered out files that don't start with current directory, + // here we just check if it contains a path separator) + let file_name = right.display().to_string(); + if !file_name.contains("/") { + result.insert(GlobItem::File(file_name)); + } else { + // Directory: contains separator, take the first part, add to dirs set + if let Some(first_part) = file_name.split('/').next() { + dirs.insert(first_part.to_string()); + } + } + }); + + dirs.into_iter().for_each(|dir| { + result.insert(GlobItem::Directory(dir)); + }); + + result.into_iter().collect() + }) + } else { + // Local mode + globber.glob(|current| { + let mut items = HashSet::new(); + get_local_files(¤t, &mut items); + items.iter().cloned().collect() + }) + }?; + + Ok(globber) +} + +fn get_local_files(current: &PathBuf, items: &mut HashSet) { + if let Ok(entries) = std::fs::read_dir(¤t) { + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + if path.is_file() { + items.insert(GlobItem::File(name)); + } else if path.is_dir() { + if name != CLIENT_FOLDER_WORKSPACE_ROOT_NAME { + items.insert(GlobItem::Directory(name)); + } + } + } + } +} + +pub fn handle_err(err: TcpTargetError) { + eprintln!("{}", md(t!("jv.fail.from_core", err = err))) +} + +async fn connect(upstream: SocketAddr) -> Option { + // Create Socket + let socket = if upstream.is_ipv4() { + match TcpSocket::new_v4() { + Ok(socket) => socket, + Err(_) => { + eprintln!("{}", t!("jv.fail.create_socket").trim()); + return None; + } + } + } else { + match TcpSocket::new_v6() { + Ok(socket) => socket, + Err(_) => { + eprintln!("{}", t!("jv.fail.create_socket").trim()); + return None; + } + } + }; + + // Connect + let Ok(stream) = socket.connect(upstream).await else { + eprintln!("{}", t!("jv.fail.connection_failed").trim()); + return None; + }; + + Some(ConnectionInstance::from(stream)) +} + +// Check if the workspace is stained and has a valid configuration +// Returns LocalConfig if valid, None otherwise +async fn precheck() -> Option { + let Some(local_dir) = current_local_path() else { + eprintln!("{}", t!("jv.fail.workspace_not_found").trim()); + return None; + }; + + if let Err(e) = set_current_dir(&local_dir) { + eprintln!( + "{}", + t!( + "jv.fail.std.set_current_dir", + dir = local_dir.display(), + error = e + ) + ); + return None; + } + + let Ok(local_config) = LocalConfig::read().await else { + eprintln!("{}", md(t!("jv.fail.cfg_not_found.local_config"))); + return None; + }; + + if !local_config.stained() { + eprintln!("{}", md(t!("jv.fail.not_stained"))); + return None; + } + + Some(local_config) +} + +/// Build action pool and context for upstream communication +/// Returns Some((ActionPool, ActionContext)) if successful, None otherwise +async fn build_pool_and_ctx( + local_config: &LocalConfig, +) -> Option<(ActionPool, ActionContext, Receiver)> { + let pool = client_registry::client_action_pool(); + let upstream = local_config.upstream_addr(); + + let instance = connect(upstream).await?; + + // Build context and insert instance + let mut ctx = ActionContext::local().insert_instance(instance); + + // Build channel for communication + let (tx, rx) = mpsc::channel::(100); + ctx.insert_arc_data(Arc::new(tx)); + + Some((pool, ctx, rx)) +} + +/// Sort paths in a vector of strings. +/// Paths are strings with structure A/B/C/D/E. +/// Paths with deeper levels (more '/' segments) are sorted first, followed by paths with shallower levels. +/// Within the same level, paths are sorted based on the first letter or digit encountered, with the order A-Z > a-z > 0-9. +fn sort_paths(paths: &mut Vec) { + quick_sort_with_cmp(paths, false, |a, b| { + let depth_a = a.matches('/').count(); + let depth_b = b.matches('/').count(); + + if depth_a != depth_b { + return if depth_a > depth_b { -1 } else { 1 }; + } + a.cmp(b) as i32 + }); +} + +/// Get paths that exist in the Cached Sheet under the current directory +fn mapping_names_here( + current_dir: &PathBuf, + local_dir: &PathBuf, + cached_sheet: &SheetData, +) -> std::collections::BTreeMap> { + let Ok(relative_path) = current_dir.strip_prefix(local_dir) else { + return std::collections::BTreeMap::new(); + }; + + // Collect files directly under current directory + let files_here: std::collections::BTreeMap> = cached_sheet + .mapping() + .iter() + .filter_map(|(f, mapping)| { + // Check if the file is directly under the current directory + f.parent() + .filter(|&parent| parent == relative_path) + .and_then(|_| f.file_name()) + .and_then(|name| name.to_str()) + .map(|s| (s.to_string(), Some(mapping.clone()))) + }) + .collect(); + + // Collect directories that appear in the mapping + let mut dirs_set = std::collections::BTreeSet::new(); + + for (f, _mapping) in cached_sheet.mapping().iter() { + // Get all parent directories of the file relative to the current directory + let mut current = f.as_path(); + + while let Some(parent) = current.parent() { + if parent == relative_path { + // This is a parent directory, not the file itself + if current != f.as_path() { + if let Some(dir_name) = current.file_name() { + if let Some(dir_str) = dir_name.to_str() { + dirs_set.insert(format!("{}/", dir_str)); + } + } + } + break; + } + current = parent; + } + } + + // Filter out directories that are actually files + let filtered_dirs: std::collections::BTreeMap> = dirs_set + .into_iter() + .filter(|dir_with_slash| { + let dir_name = dir_with_slash.trim_end_matches('/'); + !files_here.contains_key(dir_name) + }) + .map(|dir_name| (dir_name, None)) + .collect(); + + // Combine results + let mut result = files_here; + result.extend(filtered_dirs); + result +} + +/// Trims the content, takes the first line, and truncates it to a display width of 24 characters. +/// If the display width exceeds 24, it truncates and adds "...". +fn truncate_first_line(content: String) -> String { + let trimmed = content.trim(); + let first_line = trimmed.lines().next().unwrap_or(""); + let display_len = display_width(first_line); + if display_len > 24 { + let mut truncated = String::new(); + let mut current_len = 0; + for ch in first_line.chars() { + let ch_width = display_width(&ch.to_string()); + if current_len + ch_width > 24 { + break; + } + truncated.push(ch); + current_len += ch_width; + } + truncated.push_str("..."); + truncated + } else { + first_line.to_string() + } +} + +fn print_json(obj: T, pretty: bool) { + let result = if pretty { + serde_json::to_string_pretty(&obj) + } else { + serde_json::to_string(&obj) + }; + println!("{}", result.unwrap_or("{}".to_string())); +} -- cgit