diff --git a/dexcel/Cargo.toml b/dexcel/Cargo.toml index 88618d2..0c80b0b 100644 --- a/dexcel/Cargo.toml +++ b/dexcel/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "dexcel" -version = "0.1.0" -edition = "2024" +version = "0.4.0" +edition = "2021" [dependencies] -calamine = { version = "0.26", features = ["dates"] } -clap = { version = "4.5", features = ["derive"] } -rust_xlsxwriter = "0.80" -tabled = "0.16" chrono = "0.4" +clap = { version = "4.6", features = ["derive"] } +umya-spreadsheet = "2.3" +tabled = "0.20" +thiserror = "2.0" +tempfile = "3.27" \ No newline at end of file diff --git a/dexcel/README.md b/dexcel/README.md index 845990a..2ff7114 100644 --- a/dexcel/README.md +++ b/dexcel/README.md @@ -1,22 +1,25 @@ # dexcel - Excel 读写工具 📊 -一个简单易用的命令行 Excel 处理工具,支持查询、统计、插入、更新和删除操作。 +一个简单易用的命令行 Excel 处理工具,支持查询、统计、插入、更新、删除和样式设置操作。 + +**当前版本**: v0.4.0 ## ✨ 功能特性 ### 核心操作 -- 🔍 **query**: 灵活查询 Excel 数据,支持行范围选择和自定义分隔符 +- 🔍 **query**: 灵活查询 Excel 数据,支持行范围选择 - 📊 **count**: 快速统计行数,适合脚本使用 - ➕ **insert**: 插入新行,支持指定位置或追加到末尾 - ✏️ **update**: 更新现有数据,支持部分列更新 -- ❌ **delete**: 删除行或单元格,带安全确认机制 +- ❌ **delete**: 删除行,带安全确认机制 +- 🎨 **style**: 设置样式(行高、列宽、自动换行) ### 通用特性 - 📑 **多 Sheet 支持**: 所有操作都支持 `--sheet` 参数指定工作表 - 🔢 **负数索引**: 支持负数行号(-1 表示最后一行) -- 🆕 **自动创建**: insert 操作在文件不存在时自动创建 - ⚠️ **安全确认**: delete 操作需要用户确认 -- 📤 **灵活输出**: query 支持自定义分隔符(默认 |) +- 💾 **样式保留**: 使用 umya-spreadsheet 库,完整保留 Excel 样式 +- 📤 **表格输出**: query 以美观的表格格式显示数据 ## 📦 安装 @@ -89,13 +92,26 @@ dexcel data.xlsx count --sheet "Sales" **输出**: 只输出一个数字,表示总行数,适合脚本中使用。 +### 3. 创建新文件 (new) + +```bash +# 创建新的 Excel 文件 +dexcel new mydata.xlsx + +# 指定 Sheet 名称 +dexcel new mydata.xlsx --sheet "数据表" +``` + +#### new 参数说明 +- ``: 文件名(必填)**必需** +- `--sheet `: Sheet 名称(可选,默认为 Sheet1) + +**注意**:如果文件已存在,会报错提示。 + ### 3. 插入数据 (insert) ```bash -# 创建新文件并插入表头 -dexcel new-file.xlsx insert "姓名|年龄|城市" - -# 追加数据到末尾 +# 追加数据到末尾(文件必须已存在) dexcel data.xlsx insert "张三|18|北京" # 在指定位置插入(第2行) @@ -114,6 +130,8 @@ dexcel data.xlsx insert "数据" --sheet "Sheet2" - `--sheet `: Sheet 名称(可选) - `--split `: 分隔符(默认 |) +**注意**:insert 命令要求文件必须已存在,如果文件不存在会报错。 + ### 4. 更新数据 (update) ```bash @@ -140,26 +158,54 @@ dexcel data.xlsx update "新值" --row 1 --sheet "Sheet2" ### 5. 删除数据 (delete) ```bash -# 删除整行(带确认) -dexcel data.xlsx delete --row 5 +# 删除前10行 +dexcel data.xlsx delete 10 -# 删除最后一行 -dexcel data.xlsx delete --row -1 +# 删除后5行 +dexcel data.xlsx delete -5 -# 删除单元格(第3行第2列) -dexcel data.xlsx delete --row 3 --cell 2 +# 删除第3到第8行 +dexcel data.xlsx delete 3 8 # 指定 Sheet -dexcel data.xlsx delete --row 1 --sheet "Sheet2" +dexcel data.xlsx delete 1 --sheet "Sheet2" ``` #### delete 参数说明 -- `[COUNT]`: 删除数量(默认1) -- `--row `: 行号(必需,支持负数) -- `--cell `: 列号(可选,不指定则删除整行) +- ``: 删除范围,支持以下格式: + - `N` - 删除前 N 行(如 `delete 10` 删除前10行) + - `-N` - 删除后 N 行(如 `delete -10` 删除后10行) + - `START END` - 删除从 START 到 END 行(如 `delete 1 10` 删除第1到第10行) - `--sheet `: Sheet 名称(可选) -**注意**: delete 操作需要用户确认(输入 y 继续) +**注意**: delete 操作需要用户确认(输入 y 继续),请谨慎操作! + +### 6. 设置样式 (style) + +```bash +# 设置第1行的行高 +dexcel data.xlsx style --rows 1 --row-height 30 + +# 设置A列的列宽 +dexcel data.xlsx style --cols 1 --col-width 20 + +# 设置自动换行 +dexcel data.xlsx style --rows 1 --cols 1 --wrap-text + +# 组合设置 +dexcel data.xlsx style --rows 1 2 3 --cols 1 2 --row-height 25 --col-width 15 --wrap-text + +# 指定 Sheet +dexcel data.xlsx style --rows 1 --row-height 20 --sheet "Sheet2" +``` + +#### style 参数说明 +- `--rows `: 行号列表(支持负数,不指定则应用到所有行) +- `--cols `: 列号列表(不指定则应用到所有列) +- `--row-height `: 行高(像素) +- `--col-width `: 列宽(字符数) +- `--wrap-text`: 启用自动换行 +- `--sheet `: Sheet 名称(可选) ## 💡 使用示例 @@ -218,14 +264,14 @@ done ### 示例 4: 数据清理 ```bash -# 删除错误数据行 -dexcel data.xlsx delete --row 10 +# 删除前5行 +dexcel data.xlsx delete 5 -# 删除最后一行 -dexcel data.xlsx delete --row -1 +# 删除最后3行 +dexcel data.xlsx delete -3 -# 删除特定单元格 -dexcel data.xlsx delete --row 5 --cell 3 +# 删除第10到第20行 +dexcel data.xlsx delete 10 20 ``` ### 示例 5: 多 Sheet 操作 @@ -242,6 +288,25 @@ dexcel data.xlsx query --sheet "Sales" dexcel data.xlsx count --sheet "Sales" ``` +### 示例 6: 设置样式 + +```bash +# 设置第1行的行高为30像素 +dexcel data.xlsx style --rows 1 --row-height 30 + +# 设置A列的列宽为20字符 +dexcel data.xlsx style --cols 1 --col-width 20 + +# 设置自动换行 +dexcel data.xlsx style --rows 1 --cols 1 --wrap-text + +# 组合设置:多行多列,设置行高、列宽和自动换行 +dexcel data.xlsx style --rows 1 2 3 --cols 1 2 --row-height 25 --col-width 15 --wrap-text + +# 指定 Sheet +dexcel data.xlsx style --rows 1 --row-height 20 --sheet "Sheet2" +``` + ## 📊 输出格式 ### query 输出(表格格式) @@ -290,8 +355,8 @@ dexcel data.xlsx count --sheet "Sales" **delete 成功**: ``` -✓ 操作成功:已删除第 5 行 - 剩余行数: 9 +✓ 操作成功:已删除第 5 行到第 10 行(共 6 行) + 剩余行数: 94 文件: data.xlsx, Sheet: Sheet1 ``` @@ -299,25 +364,19 @@ dexcel data.xlsx count --sheet "Sales" ### 依赖库 -- **calamine**: Excel 文件读取库,支持 .xlsx 格式 -- **rust_xlsxwriter**: Excel 文件写入库,功能强大 +- **umya-spreadsheet**: Excel 读写库,完整保留样式,支持 .xlsx 格式 - **clap**: 命令行参数解析库 +- **tabled**: 表格格式化输出库 - **chrono**: 日期时间处理库 +- **thiserror**: 错误处理库 ### 工作原理 -#### 查询流程 -1. 打开 Excel 文件 +#### 工作流程 +1. 打开 Excel 文件(使用 umya-spreadsheet) 2. 选择指定的 Sheet(默认第一个) -3. 根据参数计算读取范围 -4. 遍历单元格并提取数据 -5. 以指定分隔符格式输出 - -#### 写入流程 -1. 检查文件是否存在(insert 可自动创建) -2. 读取现有数据到新工作簿 -3. 执行插入/更新/删除操作 -4. 保存文件 +3. 执行相应操作(查询/插入/更新/删除/样式设置) +4. 保存文件(保留原有样式) ### 数据类型支持 @@ -332,32 +391,39 @@ dexcel data.xlsx count --sheet "Sales" **写入时:** - 所有数据都作为字符串写入 +- 样式完全保留 ## ⚠️ 注意事项 1. **索引从 1 开始**: 行和列的索引都是从 1 开始,不是 0 2. **负数索引**: `-1` = 最后一行/列,`-2` = 倒数第二 -3. **文件自动创建**: insert 操作在文件不存在时自动创建 +3. **文件必须存在**: insert/update/delete 操作要求文件必须已存在 4. **分隔符默认**: 默认使用 `|` 分隔列 5. **文件格式**: 仅支持 `.xlsx`,不支持 `.xls` 6. **删除需确认**: delete 操作必须用户确认才能执行 7. **日期自动识别**: 自动识别并格式化日期单元格 8. **不可撤销**: delete 操作不可撤销,请谨慎操作! +9. **删除范围**: delete 支持多种删除模式(前N行、后N行、指定范围) +10. **样式保留**: 使用 umya-spreadsheet 库,完整保留 Excel 样式 +11. **样式设置**: style 命令可设置行高、列宽、自动换行等样式 ### 限制 **读取限制:** - 公式: 不计算公式,只读取原始值 -- 样式: 不读取单元格样式(颜色、字体等) - 图表: 不支持读取图表 - 宏: 不支持 VBA 宏 - 大文件: > 10MB 的文件可能较慢 **写入限制:** -- 无样式: 不能设置颜色、字体等样式 - 无公式: 不能写入公式 -- 覆盖警告: update 会覆盖目标单元格的原有内容 -- insert 下移: insert 会将原有数据下移 +- update 会覆盖目标单元格的原有内容 +- insert 会将原有数据下移 + +**优势:** +- ✅ 完整保留 Excel 样式(颜色、字体、边框等) +- ✅ 支持行高、列宽、自动换行等样式设置 +- ✅ 插入/删除时自动调整样式 ## 🔧 常见问题 @@ -379,7 +445,7 @@ dexcel data.xlsx insert "数据3|数据4" ```bash dexcel data.xlsx query -5 # 最后5行 -dexcel data.xlsx delete --row -1 # 删除最后一行 +dexcel data.xlsx delete -1 # 删除最后一行 ``` ### Q4: 如何处理中文内容? @@ -436,7 +502,11 @@ dexcel data.xlsx update "新值" --row 6 --cell 2 ### 5. 数据清理 删除错误数据: ```bash -dexcel data.xlsx delete --row 10 +# 删除前5行 +dexcel data.xlsx delete 5 + +# 删除第10到第20行 +dexcel data.xlsx delete 10 20 ``` ### 6. 自动化脚本 @@ -490,12 +560,13 @@ Write-Host "处理完成!" -ForegroundColor Green 欢迎提交 Issue 和 Pull Request! 可能的改进方向: -- 支持更多数据类型(公式等) -- 支持单元格样式(颜色、字体等) +- 支持公式计算 +- 支持更多数据类型 - 支持批量写入优化 - 支持复制 Sheet - 添加数据验证功能 - 支持更多输出格式(CSV, JSON 等) +- 支持条件格式化 ## 📄 许可证 @@ -504,10 +575,11 @@ Write-Host "处理完成!" -ForegroundColor Green ## 🙏 致谢 感谢以下开源项目: -- [calamine](https://github.com/tafia/calamine) - 优秀的 Excel 读取库 -- [rust_xlsxwriter](https://github.com/jmcnamara/rust_xlsxwriter) - 强大的 Excel 写入库 +- [umya-spreadsheet](https://github.com/nomiddlename/umya-spreadsheet) - 优秀的 Excel 读写库,完整保留样式 +- [tabled](https://github.com/zhiburt/tabled) - 美观的表格格式化库 - [clap](https://github.com/clap-rs/clap) - 优秀的命令行参数解析库 - [chrono](https://github.com/chronotope/chrono) - 日期时间处理库 +- [thiserror](https://github.com/dtolnay/thiserror) - 错误处理库 --- diff --git a/dexcel/example.bat b/dexcel/example.bat deleted file mode 100644 index 3779bfc..0000000 --- a/dexcel/example.bat +++ /dev/null @@ -1,48 +0,0 @@ -@echo off -set DEXCEL=%~dp0target\release\dexcel.exe -echo ==================================== -echo dexcel 工具使用示例 -echo ==================================== -echo. - -echo 1. 创建测试 Excel 文件 -echo ------------------- -%DEXCEL% write -f example.xlsx -x 1 -y 1 -v "姓名" -%DEXCEL% write -f example.xlsx -x 1 -y 2 -v "年龄" -%DEXCEL% write -f example.xlsx -x 1 -y 3 -v "城市" -%DEXCEL% write -f example.xlsx -x 2 -y 1 -v "张三" -%DEXCEL% write -f example.xlsx -x 2 -y 2 -v "25" -%DEXCEL% write -f example.xlsx -x 2 -y 3 -v "北京" -%DEXCEL% write -f example.xlsx -x 3 -y 1 -v "李四" -%DEXCEL% write -f example.xlsx -x 3 -y 2 -v "30" -%DEXCEL% write -f example.xlsx -x 3 -y 3 -v "上海" -echo. - -echo 2. 读取整个文件 -echo ------------------- -%DEXCEL% read -f example.xlsx -echo. - -echo 3. 读取第2行开始的数据(2行) -echo ------------------- -%DEXCEL% read -f example.xlsx -r 2 -n 2 -echo. - -echo 4. 只读取姓名列(第1列) -echo ------------------- -%DEXCEL% read -f example.xlsx -c 1 -m 1 -echo. - -echo 5. 读取最后一列(城市列) -echo ------------------- -%DEXCEL% read -f example.xlsx -c -1 -m 1 -echo. - -echo 6. 读取特定单元格(第2行,第2列) -echo ------------------- -%DEXCEL% read -f example.xlsx -r 2 -n 1 -c 2 -m 1 -echo. - -echo ==================================== -echo 示例完成! -echo ==================================== diff --git a/dexcel/src/main.rs b/dexcel/src/main.rs index ab044b2..71f3de0 100644 --- a/dexcel/src/main.rs +++ b/dexcel/src/main.rs @@ -1,14 +1,33 @@ -use calamine::{open_workbook, Data, DataType, Reader, Xlsx}; -use chrono::{Datelike, Timelike}; use clap::{Parser, Subcommand}; use std::path::PathBuf; +use umya_spreadsheet::*; +use std::fs; +use std::io::Write; + +#[derive(Debug, thiserror::Error)] +enum DexcelError { + #[error("IO错误: {0}")] + Io(#[from] std::io::Error), + #[error("Excel错误: {0}")] + Umya(String), + #[error("操作错误: {0}")] + Operation(String), +} + +type Result = std::result::Result; #[derive(Parser)] #[command(name = "dexcel")] -#[command(about = "Excel 读写工具", long_about = None)] +#[command(about = "Excel 读写工具 (样式保留版)", long_about = None)] struct Cli { /// Excel 文件路径 - file: PathBuf, + #[arg(required_if_eq("command", "query"))] + #[arg(required_if_eq("command", "count"))] + #[arg(required_if_eq("command", "insert"))] + #[arg(required_if_eq("command", "update"))] + #[arg(required_if_eq("command", "delete"))] + #[arg(required_if_eq("command", "style"))] + file: Option, #[command(subcommand)] command: Commands, @@ -16,249 +35,201 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// 查询数据 + /// 查询数据 (只读) Query { - /// 获取行数(正数从前获取,负数从后获取) #[arg(default_value_t = 0, allow_hyphen_values = true)] count: i32, - - /// 开始行号(从1开始,负数表示倒数) #[arg(long, allow_hyphen_values = true)] start: Option, - - /// 获取最后一行 #[arg(long)] last: bool, - - /// Sheet 名称(可选,默认为第一个sheet) #[arg(long)] sheet: Option, }, /// 统计行数 Count { - /// Sheet 名称(可选,默认为第一个sheet) #[arg(long)] sheet: Option, }, - /// 插入行 + /// 创建新文件 + New { + name: String, + #[arg(long)] + sheet: Option, + }, + /// 插入行 (保留样式) Insert { - /// 要插入的值(使用 split 分隔列) + /// 要插入的值 (用 | 分隔) value: String, - - /// 插入位置(行号,不指定则追加到末尾) + /// 插入位置 (空则追加) #[arg(long, allow_hyphen_values = true)] row: Option, - - /// Sheet 名称(可选,默认为第一个sheet) #[arg(long)] sheet: Option, - - /// 分割符号(默认 |) #[arg(long, default_value = "|")] split: String, }, - /// 更新数据 + /// 更新数据 (保留样式) Update { - /// 要更新的值(使用 split 分隔列) + /// 新值 value: String, - - /// 行号(必需) + /// 行号 #[arg(long, allow_hyphen_values = true)] row: i32, - - /// 起始列号(从1开始,不指定则从第1列开始) + /// 起始列号 (默认1) #[arg(long)] cell: Option, - - /// Sheet 名称(可选,默认为第一个sheet) #[arg(long)] sheet: Option, - - /// 分割符号(默认 |) #[arg(long, default_value = "|")] split: String, }, - /// 删除数据 + /// 删除行 Delete { - /// 删除数量(单元格数或行数) - #[arg(default_value_t = 1)] - count: u32, - - /// 行号(必需) - #[arg(long, allow_hyphen_values = true)] - row: i32, - - /// 列号(可选,不指定则删除整行) + #[arg(num_args = 1..=2, allow_hyphen_values = true, value_delimiter = ' ')] + range: Vec, #[arg(long)] - cell: Option, - - /// Sheet 名称(可选,默认为第一个sheet) + sheet: Option, + }, + /// 设置样式 + Style { + #[arg(long, allow_hyphen_values = true)] + rows: Vec, + #[arg(long)] + cols: Vec, + #[arg(long)] + row_height: Option, + #[arg(long)] + col_width: Option, + #[arg(long)] + wrap_text: bool, #[arg(long)] sheet: Option, }, } -fn main() { - let cli = Cli::parse(); - - match cli.command { - Commands::Query { - count, - start, - last, - sheet, - } => { - if let Err(e) = query_excel(&cli.file, count, start, last, &sheet) { - eprintln!("错误: {}", e); - std::process::exit(1); - } - } - Commands::Count { sheet } => { - if let Err(e) = count_excel(&cli.file, &sheet) { - eprintln!("错误: {}", e); - std::process::exit(1); - } - } - Commands::Insert { - value, - row, - sheet, - split, - } => { - if let Err(e) = insert_excel(&cli.file, &value, row, &sheet, &split) { - eprintln!("错误: {}", e); - std::process::exit(1); - } - } - Commands::Update { - value, - row, - cell, - sheet, - split, - } => { - if let Err(e) = update_excel(&cli.file, &value, row, cell, &sheet, &split) { - eprintln!("错误: {}", e); - std::process::exit(1); - } - } - Commands::Delete { - count, - row, - cell, - sheet, - } => { - if let Err(e) = delete_excel(&cli.file, count, row, cell, &sheet) { - eprintln!("错误: {}", e); - std::process::exit(1); - } - } +// 安全保存辅助:利用 umya 直接保存,但为了安全,依然先备份 +fn safe_save_umya(book: &mut Spreadsheet, target: &PathBuf) -> Result<()> { + // 1. 备份原文件 + if target.exists() { + let backup = target.with_extension("xlsx.bak"); + let _ = fs::remove_file(&backup); + fs::copy(target, &backup)?; } -} -// 格式化单元格值为字符串 -fn format_cell(cell: &Data) -> String { - match cell { - Data::Empty => String::new(), - Data::Int(v) => v.to_string(), - Data::Float(v) => { - if *v > 1.0 && *v < 2958466.0 && (*v - v.floor()).abs() < 0.0001 { - let temp_data = Data::Float(*v); - if let Some(dt) = temp_data.as_datetime() { - let year = dt.year(); - if year >= 1900 && year <= 2100 { - return dt.format("%Y-%m-%d").to_string(); - } - } - } - v.to_string() - } - Data::String(s) => s.clone(), - Data::Bool(b) => b.to_string(), - Data::DateTime(d) => { - if let Some(dt) = d.as_datetime() { - if dt.hour() == 0 && dt.minute() == 0 && dt.second() == 0 { - dt.format("%Y-%m-%d").to_string() - } else { - dt.format("%Y-%m-%d %H:%M:%S").to_string() - } - } else { - d.to_string() - } - } - Data::DateTimeIso(s) => s.clone(), - Data::DurationIso(s) => s.clone(), - Data::Error(e) => format!("#{}", e), - _ => String::new(), - } -} - -// 统计行数(count 命令) -fn count_excel( - file: &PathBuf, - sheet_name: &Option, -) -> Result<(), Box> { - let mut workbook: Xlsx<_> = open_workbook(file)?; - - let sheet = match sheet_name { - Some(name) => name.clone(), - None => { - let sheets = workbook.sheet_names(); - if sheets.is_empty() { - return Err("Excel 文件中没有 sheet".into()); - } - sheets[0].clone() - } - }; - - let range = workbook - .worksheet_range(&sheet) - .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; - - let total_rows = range.height(); - - println!("{}", total_rows); + // 2. 直接写入 (umya 内部处理了 zip 结构,保留所有内容) + writer::xlsx::write(book, target).map_err(|e| DexcelError::Umya(e.to_string()))?; Ok(()) } -// 查询数据(query 命令) -fn query_excel( - file: &PathBuf, - count: i32, - start: Option, - last: bool, - sheet_name: &Option, -) -> Result<(), Box> { - let mut workbook: Xlsx<_> = open_workbook(file)?; +fn get_sheet_mut<'a>(book: &'a mut Spreadsheet, name: &Option) -> Result<&'a mut Worksheet> { + match name { + Some(n) => book.get_sheet_by_name_mut(n).ok_or_else(|| DexcelError::Operation(format!("找不到Sheet: {}", n))), + None => Ok(book.get_sheet_mut(&0).ok_or_else(|| DexcelError::Operation("找不到Sheet".to_string()))?), + } +} - let sheet = match sheet_name { - Some(name) => name.clone(), - None => { - let sheets = workbook.sheet_names(); - if sheets.is_empty() { - return Err("Excel 文件中没有 sheet".into()); +fn get_sheet<'a>(book: &'a Spreadsheet, name: &Option) -> Result<&'a Worksheet> { + match name { + Some(n) => book.get_sheet_by_name(n).ok_or_else(|| DexcelError::Operation(format!("找不到Sheet: {}", n))), + None => Ok(book.get_sheet(&0).ok_or_else(|| DexcelError::Operation("找不到Sheet".to_string()))?), + } +} + +// 解析行号 (1-based -> 0-based) +fn resolve_row(input: i32, max: usize) -> Result { + let max_i = max as i32; + let idx = if input < 0 { max_i + input } else { input - 1 }; + + if idx < 0 || idx >= max_i { + Err(DexcelError::Operation(format!("行号 {} 超出范围", input))) + } else { + Ok(idx as usize) + } +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + let result = match &cli.command { + Commands::Query { count, start, last, sheet } => { + let file = cli.file.as_ref().unwrap(); + let book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + query_excel(&book, *count, *start, *last, sheet) + } + Commands::Count { sheet } => { + let file = cli.file.as_ref().unwrap(); + let book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + count_excel(&book, sheet) + } + Commands::New { name, sheet } => { + let path = if name.ends_with(".xlsx") { PathBuf::from(name) } else { PathBuf::from(format!("{}.xlsx", name)) }; + let mut book = new_file(); + if let Some(s) = sheet { + book.get_sheet_mut(&0).unwrap().set_name(s); } - sheets[0].clone() + writer::xlsx::write(&book, &path).map_err(|e| DexcelError::Umya(e.to_string()))?; + println!("✓ 已创建: {}", path.display()); + Ok(()) + } + Commands::Insert { value, row, sheet, split } => { + let file = cli.file.as_ref().unwrap(); + let mut book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + insert_excel(&mut book, value, *row, sheet, split)?; + safe_save_umya(&mut book, file) + } + Commands::Update { value, row, cell, sheet, split } => { + let file = cli.file.as_ref().unwrap(); + let mut book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + update_excel(&mut book, value, *row, *cell, sheet, split)?; + safe_save_umya(&mut book, file) + } + Commands::Delete { range, sheet } => { + let file = cli.file.as_ref().unwrap(); + let mut book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + delete_excel(&mut book, range, sheet)?; + safe_save_umya(&mut book, file) + } + Commands::Style { rows, cols, row_height, col_width, wrap_text, sheet } => { + let file = cli.file.as_ref().unwrap(); + let mut book = reader::xlsx::read(file).map_err(|e| DexcelError::Umya(e.to_string()))?; + style_excel(&mut book, rows, cols, *row_height, *col_width, *wrap_text, sheet)?; + safe_save_umya(&mut book, file) } }; - let range = workbook - .worksheet_range(&sheet) - .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; + if let Err(e) = result { + eprintln!("❌ 错误: {}", e); + std::process::exit(1); + } + + Ok(()) +} - let total_rows = range.height(); - let _total_cols = range.width(); +// --- 具体实现 --- + +fn get_cell_value(sheet: &Worksheet, row: usize, col: usize) -> String { + // umya 的坐标是 (col, row) 且从 1 开始 + if let Some(c) = sheet.get_cell((col as u32 + 1, row as u32 + 1)) { + c.get_value().to_string() + } else { + String::new() + } +} + +fn query_excel(book: &Spreadsheet, count: i32, start: Option, last: bool, sheet_name: &Option) -> Result<()> { + let sheet = get_sheet(book, sheet_name)?; + let (h, _) = sheet.get_highest_column_and_row(); + let total_rows = h as usize; if total_rows == 0 { println!("表格为空"); return Ok(()); } - // 计算要读取的行范围 + // 计算范围逻辑 (保持与之前一致) let (start_row, end_row) = if last { (total_rows - 1, total_rows) } else if let Some(s) = start { - // 使用 start 参数 let actual_start = if s < 0 { let pos = total_rows as i32 + s; if pos < 0 { 0 } else { pos as usize } @@ -269,438 +240,220 @@ fn query_excel( if count > 0 { (actual_start, (actual_start + count as usize).min(total_rows)) } else if count < 0 { - let abs_count = (-count) as usize; - let end = (actual_start + abs_count).min(total_rows); - (actual_start, end) + (actual_start, (actual_start + (-count) as usize).min(total_rows)) } else { - // count 为 0,显示从 start 到末尾(最多20行) - let max_display = 20; - let end = (actual_start + max_display).min(total_rows); - if total_rows - actual_start > max_display { - eprintln!("提示: 从第 {} 行开始共有 {} 行数据,仅显示 {} 行", - actual_start + 1, total_rows - actual_start, max_display); - } - (actual_start, end) + let max = 20; + (actual_start, (actual_start + max).min(total_rows)) } } else if count > 0 { (0, count as usize) } else if count < 0 { - let abs_count = (-count) as usize; - let start = if abs_count >= total_rows { 0 } else { total_rows - abs_count }; + let abs = (-count) as usize; + let start = if abs >= total_rows { 0 } else { total_rows - abs }; (start, total_rows) } else { - // 默认显示最后20行 - let max_display = 20; - if total_rows > max_display { - eprintln!("提示: 共有 {} 行数据,仅显示最后 {} 行", total_rows, max_display); - eprintln!("如需查看更多数据,请使用 dexcel query <行数> 或 --last\n"); - (total_rows - max_display, total_rows) - } else { - (0, total_rows) - } + let max = 20; + ((total_rows - max).max(0), total_rows) }; - let end_row = end_row.min(total_rows); - - // 收集所有数据 - let mut data: Vec> = Vec::new(); - for r in start_row..end_row { - let mut row_values = Vec::new(); - for c in 0..range.width() { - let cell = range.get((r, c)); - if let Some(cell_data) = cell { - row_values.push(format_cell(cell_data)); - } else { - row_values.push(String::new()); - } - } - data.push(row_values); - } - - if data.is_empty() { - println!("没有数据"); - return Ok(()); - } - - // 使用 tabled 输出表格 - let output_count = data.len(); - let col_count = data[0].len(); + let (max_col, _) = sheet.get_highest_column_and_row(); - // 使用 Builder API 创建动态表格 use tabled::builder::Builder; let mut builder = Builder::default(); - // 添加所有行 - for row in &data { - builder.push_record(row.iter().map(|s| s.as_str())); + for r in start_row..end_row.min(total_rows) { + let row: Vec = (0..max_col as usize) + .map(|c| get_cell_value(sheet, r, c)) + .collect(); + builder.push_record(row); } - + let mut table = builder.build(); - - // 设置样式 table.with(tabled::settings::Style::modern()); - println!("{}", table); - eprintln!("\n共输出 {} 行数据,{} 列", output_count, col_count); + Ok(()) } -// 插入行(insert 命令) +fn count_excel(book: &Spreadsheet, sheet_name: &Option) -> Result<()> { + let sheet = get_sheet(book, sheet_name)?; + let (_, row) = sheet.get_highest_column_and_row(); + println!("{}", row); + Ok(()) +} + +// 核心:插入行 (umya 支持 insert_row,会自动下移并保留样式) fn insert_excel( - file: &PathBuf, + book: &mut Spreadsheet, value: &str, row: Option, sheet_name: &Option, split: &str, -) -> Result<(), Box> { - use rust_xlsxwriter::*; - - let sheet = match sheet_name { - Some(name) => name.clone(), - None => "Sheet1".to_string(), - }; - - // 如果文件不存在,创建新文件 - if !file.exists() { - let mut workbook = Workbook::new(); - let worksheet = workbook.add_worksheet(); - worksheet.set_name(&sheet)?; - - let values: Vec<&str> = value.split(split).collect(); - for (col_idx, val) in values.iter().enumerate() { - let trimmed = val.trim(); - if !trimmed.is_empty() { - worksheet.write_string(0, col_idx as u16, trimmed)?; - } - } - - workbook.save(file.as_path())?; - println!("✓ 操作成功:已创建新文件并插入一行数据"); - eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); - return Ok(()); - } - - let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; - - let range = workbook - .worksheet_range(&sheet) - .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; - - let total_rows = range.height(); - let total_cols = range.width(); - - // 确定插入位置 - let insert_row = match row { +) -> Result<()> { + let sheet = get_sheet_mut(book, sheet_name)?; + let (_, max_row) = sheet.get_highest_column_and_row(); + + // 确定插入位置 (umya 是 1-based) + let insert_idx_1based = match row { Some(r) => { if r < 0 { - let pos = total_rows as i32 + r + 1; - if pos < 0 || pos > total_rows as i32 { - return Err(format!("行号无效: {}", r).into()); - } - pos as usize + (max_row as i32 + r + 2).max(1) as u32 // 负数逻辑微调 } else { - let pos = r as usize - 1; - if pos > total_rows { - return Err(format!("行号超出范围: {} (总行数: {})", r, total_rows).into()); - } - pos + r.max(1) as u32 } } - None => total_rows, // 默认追加到末尾 + None => max_row + 1, }; - // 创建新工作簿 - let mut new_workbook = Workbook::new(); - let new_worksheet = new_workbook.add_worksheet(); - new_worksheet.set_name(&sheet)?; - - let values: Vec<&str> = value.split(split).collect(); - let mut new_row = 0u32; - - // 复制插入行之前的数据 - for r in 0..insert_row { - for c in 0..range.width() { - if let Some(cell_data) = range.get((r, c)) { - write_cell(new_worksheet, new_row, c as u16, cell_data)?; - } - } - new_row += 1; + // 关键操作:如果不是追加到最末尾,调用库的插入方法 + // 这会自动将下方的行下移,并且复制样式! + if insert_idx_1based <= max_row { + sheet.insert_new_row(&insert_idx_1based, &1); } - // 写入新行 - for (col_idx, val) in values.iter().enumerate() { - let trimmed = val.trim(); - if !trimmed.is_empty() { - new_worksheet.write_string(new_row, col_idx as u16, trimmed)?; - } - } - new_row += 1; - - // 复制插入行之后的数据 - for r in insert_row..total_rows { - for c in 0..range.width() { - if let Some(cell_data) = range.get((r, c)) { - write_cell(new_worksheet, new_row, c as u16, cell_data)?; - } - } - new_row += 1; + // 写入数据 + let vals: Vec<&str> = value.split(split).collect(); + for (i, val) in vals.iter().enumerate() { + let col = (i + 1) as u32; + sheet.get_cell_mut((col, insert_idx_1based)).set_value(val.trim()); } - // 保存文件 - new_workbook.save(file.as_path())?; - - println!("✓ 操作成功:已在第 {} 行插入一行数据", insert_row + 1); - eprintln!(" 文件: {}, Sheet: {}, 总行数: {}", file.display(), sheet, total_rows + 1); - + println!("✓ 已在第 {} 行插入数据 (样式已保留)", insert_idx_1based); Ok(()) } -// 更新数据(update 命令) +// 核心:更新数据 (只改值,不动格式) fn update_excel( - file: &PathBuf, + book: &mut Spreadsheet, value: &str, row: i32, cell: Option, sheet_name: &Option, split: &str, -) -> Result<(), Box> { - use rust_xlsxwriter::*; +) -> Result<()> { + let sheet = get_sheet_mut(book, sheet_name)?; + let (_, max_row) = sheet.get_highest_column_and_row(); + + let target_row = resolve_row(row, max_row as usize)? as u32 + 1; // 转为 1-based + let start_col = cell.unwrap_or(1); - if !file.exists() { - return Err(format!("文件不存在: {}", file.display()).into()); - } - - let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; - - let sheet = match sheet_name { - Some(name) => name.clone(), - None => { - let sheets = workbook.sheet_names(); - if sheets.is_empty() { - return Err("Excel 文件中没有 sheet".into()); - } - sheets[0].clone() - } - }; - - let range = workbook - .worksheet_range(&sheet) - .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; - - let total_rows = range.height(); - let total_cols = range.width(); - - // 确定行号 - let target_row = if row < 0 { - let pos = total_rows as i32 + row; - if pos < 0 || pos >= total_rows as i32 { - return Err(format!("行号无效: {}", row).into()); - } - pos as usize - } else { - let pos = row as usize - 1; - if pos >= total_rows { - return Err(format!("行号超出范围: {} (总行数: {})", row, total_rows).into()); - } - pos - }; - - // 确定起始列 - let start_col = cell.map(|c| (c - 1) as usize).unwrap_or(0); - - // 创建新工作簿并复制所有数据 - let mut new_workbook = Workbook::new(); - let new_worksheet = new_workbook.add_worksheet(); - new_worksheet.set_name(&sheet)?; - - let values: Vec<&str> = value.split(split).collect(); - - for r in 0..total_rows { - for c in 0..range.width() { - if r == target_row && c >= start_col { - // 更新这一行的指定列 - let col_offset = c - start_col; - if col_offset < values.len() { - let trimmed = values[col_offset].trim(); - if !trimmed.is_empty() { - new_worksheet.write_string(r as u32, c as u16, trimmed)?; - } - } else { - // 超出提供的值,保持原样 - if let Some(cell_data) = range.get((r, c)) { - write_cell(new_worksheet, r as u32, c as u16, cell_data)?; - } - } - } else { - // 复制原有数据 - if let Some(cell_data) = range.get((r, c)) { - write_cell(new_worksheet, r as u32, c as u16, cell_data)?; - } - } + let vals: Vec<&str> = value.split(split).collect(); + + for (i, val) in vals.iter().enumerate() { + let c = start_col + i as u32; + // 获取已存在的单元格对象 (它包含样式) + let cell_obj = sheet.get_cell_mut((c, target_row)); + // 仅设置值,不触碰格式属性 + if !val.trim().is_empty() { + cell_obj.set_value(val.trim()); } } - // 保存文件 - new_workbook.save(file.as_path())?; - - println!("✓ 操作成功:已更新第 {} 行,从第 {} 列开始", target_row + 1, start_col + 1); - eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); - + println!("✓ 已更新第 {} 行 (样式未改变)", row); Ok(()) } -// 删除数据(delete 命令) fn delete_excel( - file: &PathBuf, - count: u32, - row: i32, - cell: Option, + book: &mut Spreadsheet, + range: &[i32], sheet_name: &Option, -) -> Result<(), Box> { - use rust_xlsxwriter::*; +) -> Result<()> { + let sheet = get_sheet_mut(book, sheet_name)?; + let (_, max_row) = sheet.get_highest_column_and_row(); + let _total = max_row as usize; - if !file.exists() { - return Err(format!("文件不存在: {}", file.display()).into()); - } - - let mut workbook: Xlsx<_> = open_workbook(file.as_path())?; - - let sheet = match sheet_name { - Some(name) => name.clone(), - None => { - let sheets = workbook.sheet_names(); - if sheets.is_empty() { - return Err("Excel 文件中没有 sheet".into()); - } - sheets[0].clone() + // 解析范围 (转为 1-based) + let (start_del, end_del) = match range { + [n] if n > &0 => (1, *n as u32), + [n] if n < &0 => { + let count = (-n) as u32; + (max_row - count + 1, max_row) + }, + [a, b] => { + if a <= &0 || b < a { return Err(DexcelError::Operation("范围无效".into())); } + (*a as u32, *b as u32) } + _ => return Err(DexcelError::Operation("参数错误".into())), }; - let range = workbook - .worksheet_range(&sheet) - .map_err(|e| format!("找不到 sheet: {}, 错误: {}", sheet, e))?; - - let total_rows = range.height(); - let total_cols = range.width(); - - // 确定行号 - let target_row = if row < 0 { - let pos = total_rows as i32 + row; - if pos < 0 || pos >= total_rows as i32 { - return Err(format!("行号无效: {}", row).into()); - } - pos as usize - } else { - let pos = row as usize - 1; - if pos >= total_rows { - return Err(format!("行号超出范围: {} (总行数: {})", row, total_rows).into()); - } - pos - }; - - // 显示确认信息 - if let Some(c) = cell { - eprintln!("⚠ 警告:即将删除单元格"); - eprintln!(" 文件: {}", file.display()); - eprintln!(" Sheet: {}", sheet); - eprintln!(" 位置: 第 {} 行, 第 {} 列", target_row + 1, c); - } else { - eprintln!("⚠ 警告:即将删除以下数据:"); - eprintln!(" 文件: {}", file.display()); - eprintln!(" Sheet: {}", sheet); - eprintln!(" 删除行: 第 {} 行(共 {} 行)", target_row + 1, total_rows); - - // 显示要删除的行的内容 - eprint!(" 内容: "); - for c in 0..range.width() { - if let Some(cell_data) = range.get((target_row, c)) { - eprint!("{}", format_cell(cell_data)); - if c < range.width() - 1 { - eprint!(" | "); - } - } - } - eprintln!(); - } - eprintln!(); - - // 请求用户确认 - eprint!("是否继续?(y/N): "); - use std::io::{self, Write}; - io::stdout().flush().unwrap(); - + // 确认 + eprintln!("⚠ 即将删除第 {} 至 {} 行", start_del, end_del); + eprint!("确认? (y/N): "); + std::io::stdout().flush()?; let mut input = String::new(); - io::stdin().read_line(&mut input)?; - let input = input.trim().to_lowercase(); - - if input != "y" && input != "yes" { - println!("✗ 操作已取消"); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + println!("操作已取消"); return Ok(()); } - // 创建新工作簿 - let mut new_workbook = Workbook::new(); - let new_worksheet = new_workbook.add_worksheet(); - new_worksheet.set_name(&sheet)?; - - if let Some(c) = cell { - // 删除单元格:将该单元格及其右侧的单元格左移 - let target_col = (c - 1) as usize; - let mut new_row = 0u32; - - for r in 0..total_rows { - let mut new_col = 0u16; - for col in 0..total_cols { - if r == target_row && col == target_col { - continue; // 跳过要删除的单元格 - } - if let Some(cell_data) = range.get((r, col)) { - write_cell(new_worksheet, new_row, new_col, cell_data)?; - new_col += 1; - } - } - new_row += 1; - } - - println!("✓ 操作成功:已删除第 {} 行第 {} 列的单元格", target_row + 1, c); - } else { - // 删除整行 - let mut new_row = 0u32; - for r in 0..total_rows { - if r == target_row { - continue; // 跳过要删除的行 - } - for c in 0..range.width() { - if let Some(cell_data) = range.get((r, c)) { - write_cell(new_worksheet, new_row, c as u16, cell_data)?; - } - } - new_row += 1; - } - - println!("✓ 操作成功:已删除第 {} 行", target_row + 1); - eprintln!(" 剩余行数: {}", total_rows - 1); - } - - // 保存文件 - new_workbook.save(file.as_path())?; - eprintln!(" 文件: {}, Sheet: {}", file.display(), sheet); + // 调用 umya 的删除方法 (会自动上移下方内容) + // 注意:umya 似乎没有直接的 range delete,我们循环删除或者从后往前删 + // 最稳妥的是从后往前删 + let num_to_del = end_del - start_del + 1; + sheet.remove_row(&start_del, &num_to_del); + println!("✓ 删除完成"); Ok(()) } -// 辅助函数:写入单元格 -fn write_cell( - worksheet: &mut rust_xlsxwriter::Worksheet, - row: u32, - col: u16, - data: &Data, -) -> Result<(), rust_xlsxwriter::XlsxError> { - match data { - Data::String(s) => worksheet.write_string(row, col, s.as_str()).map(|_| ()), - Data::Int(v) => worksheet.write_number(row, col, *v as f64).map(|_| ()), - Data::Float(v) => worksheet.write_number(row, col, *v).map(|_| ()), - Data::Bool(b) => worksheet.write_boolean(row, col, *b).map(|_| ()), - _ => Ok(()), +fn style_excel( + book: &mut Spreadsheet, + rows: &[i32], + cols: &[u32], + row_height: Option, + col_width: Option, + wrap_text: bool, + sheet_name: &Option, +) -> Result<()> { + let sheet = get_sheet_mut(book, sheet_name)?; + let (_, max_row) = sheet.get_highest_column_and_row(); + let (max_col, _) = sheet.get_highest_column_and_row(); + + // 确定目标范围 + let target_rows: Vec = if rows.is_empty() { + (1..=max_row).collect() + } else { + rows.iter().filter_map(|&r| resolve_row(r, max_row as usize).ok()).map(|x| x as u32 + 1).collect() + }; + + let target_cols: Vec = if cols.is_empty() { + (1..=max_col).collect() + } else { + cols.to_vec() + }; + + // 应用样式 + for &r in &target_rows { + if let Some(h) = row_height { + let row = sheet.get_row_dimension_mut(&r); + row.set_height(h); + } } -} + + for &c in &target_cols { + if let Some(w) = col_width { + let col = sheet.get_column_dimension_by_number_mut(&c); + col.set_width(w); + } + } + + if wrap_text { + // 需要对每个单元格设置 + for r in target_rows { + for c in &target_cols { + let cell = sheet.get_cell_mut((*c, r)); + let alignment = cell.get_style().get_alignment(); + if let Some(align) = alignment { + let mut new_alignment = align.clone(); + new_alignment.set_horizontal(HorizontalAlignmentValues::Left); + new_alignment.set_wrap_text(true); + cell.get_style_mut().set_alignment(new_alignment); + } + } + } + } + + println!("✓ 样式应用成功"); + Ok(()) +} \ No newline at end of file diff --git a/dexcel/测试表.xlsx b/dexcel/测试表.xlsx new file mode 100644 index 0000000..589f021 Binary files /dev/null and b/dexcel/测试表.xlsx differ