mod args;
use std::fs::File;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
#[cfg(unix)]
use std::process::{Command, Stdio};
use clap::{CommandFactory, Parser};
use args::*;
use spreadsheet_to_json::error::GenericError;
use spreadsheet_to_json::heck::ToSnakeCase;
use spreadsheet_to_json::indexmap::IndexMap;
use spreadsheet_to_json::serde_json::{Value, to_string_pretty};
use spreadsheet_to_json::tokio::time::Instant;
use std::io::Write;
use uuid::Uuid;
use spreadsheet_to_json::{tokio, serde_json::json};
use spreadsheet_to_json::{process_spreadsheet_async, process_spreadsheet_immediate, OptionSet, RowOptionSet, PathData, ResultSet};
use std::fs::OpenOptions;
type RowCallback = Box<dyn Fn(IndexMap<String, Value>) -> Result<(), GenericError> + Send + Sync>;
#[tokio::main]
async fn main() -> ExitCode {
let args = Args::parse();
if args.path.is_none() {
Args::command().print_help().ok();
println!();
return ExitCode::SUCCESS;
}
let debug_mode = args.debug;
let opts = match OptionSet::from_args(&args) {
Ok(opts) => opts,
Err(msg) => {
print_error(args.json, &msg);
return ExitCode::from(2);
}
};
if let Err(msg) = validate_path(args.path.as_deref().unwrap()) {
print_error(args.json, &msg);
return ExitCode::from(2);
}
if opts.is_async() && !args.background_worker {
if let Some(launch_result) = try_launch_background_export(&args) {
return match launch_result {
Ok(export_path) => {
let log_path = format!("{}.log", export_path);
if args.json {
println!("{}", to_string_pretty(&json!({
"output_reference": export_path,
"log_file": log_path,
"background": true
})).unwrap());
} else {
println!("exporting to {} in the background (see {} for progress and errors)", export_path, log_path);
}
ExitCode::SUCCESS
},
Err(msg) => {
print_error(args.json, &describe_error(&msg));
ExitCode::FAILURE
}
};
}
}
let start = if debug_mode {
Some(Instant::now()) } else {
None
};
let mut lines: Option<String> = None;
let result = if opts.is_async() {
match resolve_export_file(args.output.as_deref()) {
Ok((pb, file_ref)) => {
let callback: RowCallback = Box::new(move |row: IndexMap<String, Value>| {
append_line_to_file(&pb, &json!(row).to_string())
});
process_spreadsheet_async(&opts, callback, Some(&file_ref)).await
},
Err(msg) => Err(msg)
}
} else {
process_spreadsheet_immediate(&opts).await
};
let data_set = match result {
Err(msg) => {
print_error(args.json, &describe_error(&msg));
if debug_mode {
eprintln!("details: {}", msg);
for line in opts.to_lines() {
eprintln!(" {}", line);
}
}
return ExitCode::FAILURE;
},
Ok(data_set) => data_set
};
let rows_only = (args.lines && !args.preview) || args.rows;
if rows_only && !opts.is_async() {
if opts.multimode() {
let blocks = multimode_sheet_blocks(&data_set, !args.exclude_cells);
if args.lines {
lines = Some(blocks.iter().map(|b| b.to_string()).collect::<Vec<_>>().join("\n"));
} else if args.json {
lines = Some(to_string_pretty(&blocks).unwrap());
} else {
let compact: Vec<String> = blocks.iter().map(|b| b.to_string()).collect();
lines = Some(build_indented_json_rows(&compact));
}
} else if args.lines {
lines = Some(data_set.rows().join("\n"));
} else if args.json {
lines = Some(to_string_pretty(&data_set.to_vec()).unwrap());
} else {
lines = Some(build_indented_json_rows(&data_set.rows()));
}
}
if rows_only {
if opts.is_async() {
if let Some(out_ref) = data_set.out_ref.clone() {
if args.json {
println!("{}", to_string_pretty(&json!({ "output_reference": out_ref })).unwrap());
} else {
println!("exporting to {}", out_ref);
}
}
} else if let Some(lines_string) = lines {
println!("{}", lines_string);
}
print_debug_timing(debug_mode, start, true);
} else if args.json {
let mut json_result = build_json_result(&data_set, &opts, args.exclude_cells);
if debug_mode {
if let Some(start_timer) = start {
json_result["processing_time_ms"] = json!(start_timer.elapsed().as_secs_f64() * 1000.0);
}
}
println!("{}", to_string_pretty(&json_result).unwrap());
} else {
let result_lines = if args.exclude_cells {
opts.to_lines()
} else {
data_set.to_output_lines(args.lines)
};
for line in result_lines {
println!("{}", line);
}
print_debug_timing(debug_mode, start, false);
}
ExitCode::SUCCESS
}
fn print_debug_timing(debug_mode: bool, start: Option<Instant>, to_stderr: bool) {
if !debug_mode {
return;
}
let Some(start_timer) = start else {
return;
};
let duration = start_timer.elapsed();
if to_stderr {
eprintln!("Total processing time: {:?}", duration);
} else {
println!("Total processing time: {:?}", duration);
}
}
fn validate_path(path_str: &str) -> Result<(), String> {
let path = Path::new(path_str);
if !path.exists() {
return Err(format!("file not found: {}", path.display()));
}
if path.is_dir() {
return Err(format!("expected a file but found a directory: {}", path.display()));
}
let path_data = PathData::new(path);
if !path_data.is_valid() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("(none)");
return Err(format!(
"incompatible file format '.{}'. Supported formats: xlsx, xlsm, xls, xlsb, ods, csv, tsv",
ext
));
}
Ok(())
}
fn print_error(json_mode: bool, msg: &str) {
if json_mode {
eprintln!("{}", json!({ "error": msg }));
} else {
eprintln!("error: {}", msg);
}
}
fn multimode_sheet_blocks(result: &ResultSet, include_rows: bool) -> Vec<Value> {
result.data.sheets().iter().map(|sheet| {
if include_rows {
json!({
"sheet": sheet.key(),
"row_count": sheet.num_rows,
"rows": sheet.rows
})
} else {
json!({
"sheet": sheet.key(),
"row_count": sheet.num_rows
})
}
}).collect()
}
fn multimode_columns(result: &ResultSet) -> Value {
let mut columns: IndexMap<String, Value> = IndexMap::new();
for sheet in result.data.sheets() {
columns.insert(sheet.key(), json!(sheet.keys));
}
json!(columns)
}
fn multimode_row_counts(result: &ResultSet) -> Value {
let mut counts: IndexMap<String, Value> = IndexMap::new();
for sheet in result.data.sheets() {
counts.insert(sheet.key(), json!(sheet.num_rows));
}
json!(counts)
}
fn build_json_result(result: &ResultSet, opts: &OptionSet, exclude_cells: bool) -> Value {
let is_workbook = result.extension != "csv" && result.extension != "tsv";
let mut out: IndexMap<String, Value> = IndexMap::new();
out.insert("extension".to_string(), json!(result.extension));
if is_workbook {
let sheets: Vec<String> = result.sheets.iter().map(|s| s.to_snake_case()).collect();
out.insert("sheets".to_string(), json!(sheets));
out.insert("column_style".to_string(), json!(opts.field_mode.to_string()));
}
if is_workbook {
if let Some(selected) = &result.selected {
let selected_sheet = selected.first().map(|s| s.to_snake_case()).unwrap_or_default();
out.insert("selected_sheet".to_string(), json!(selected_sheet));
}
}
out.insert("row_count".to_string(), json!(result.num_rows));
if result.multimode() {
out.insert("columns".to_string(), multimode_columns(result));
} else {
out.insert("fields".to_string(), json!(result.keys));
}
out.insert("multimode".to_string(), json!(result.multimode()));
if is_workbook
&& result.selected.is_some() {
out.insert("sheet_indices".to_string(), json!(opts.indices.first().copied().unwrap_or(0)));
}
out.insert("file name".to_string(), json!(result.filename));
out.insert("max_rows".to_string(), json!(opts.max_rows()));
out.insert("mode".to_string(), json!(opts.row_mode()));
out.insert("headers".to_string(), json!(opts.header_mode()));
out.insert("header_row".to_string(), json!(result.header_row_index.map(|idx| idx + 1)));
out.insert("header_index".to_string(), json!(result.header_row_index));
out.insert("body_start".to_string(), json!(result.body_start_index + 1));
out.insert("body_index".to_string(), json!(result.body_start_index));
out.insert("decimal_separator".to_string(), json!(opts.rows.decimal_separator()));
out.insert("date_mode".to_string(), json!(opts.rows.date_mode()));
if let Some(out_ref) = &result.out_ref {
out.insert("output_reference".to_string(), json!(out_ref));
}
if result.multimode() {
if exclude_cells {
out.insert("row_counts".to_string(), multimode_row_counts(result));
} else {
out.insert("data".to_string(), json!(multimode_sheet_blocks(result, true)));
}
} else if !exclude_cells {
out.insert("data".to_string(), json!(result.to_vec()));
}
json!(out)
}
fn describe_error(err: &GenericError) -> String {
match err.0 {
"file_unavailable" => "file not found.".to_string(),
"unsupported_format" => "incompatible file format. Supported formats: xlsx, xlsm, xls, xlsb, ods, csv, tsv.".to_string(),
"no_filepath_specified" => "no spreadsheet file specified.".to_string(),
"workbook_with_no_sheets" => "the workbook has no readable worksheets.".to_string(),
"cannot_open_workbook" => "could not open the workbook; the file may be corrupt or not a valid spreadsheet.".to_string(),
"unreadable_csv_file" => "could not read the CSV file.".to_string(),
"unreadable_tsv_file" => "could not read the TSV file.".to_string(),
"xlsx_error" => "the Excel file appears to be corrupt or invalid.".to_string(),
"ods_error" => "the OpenDocument file appears to be corrupt or invalid.".to_string(),
"file_not_found" => "file not found.".to_string(),
"permission_denied" => "permission denied while accessing the file.".to_string(),
"io_error" => "an I/O error occurred while reading the file.".to_string(),
"write_error" => "could not create the export file. Check the path and permissions.".to_string(),
"exec_path_error" => "could not determine this program's own executable path, needed to launch the background export worker.".to_string(),
"spawn_error" => "could not launch the background export worker process.".to_string(),
other => format!("an unexpected error occurred ({}).", other),
}
}
pub fn build_indented_json_rows(rows: &[String]) -> String {
format!("[\n\t{}\n]", rows.join(",\n\t"))
}
pub fn resolve_export_file(explicit_path: Option<&str>) -> Result<(PathBuf, String), GenericError> {
let dir_path = if let Some(explicit) = explicit_path {
PathBuf::from(explicit)
} else {
let file_directory = dotenv::var("EXPORT_FILE_DIRECTORY").unwrap_or_else(|_| "./".to_string());
let mut dir_path = PathBuf::from(file_directory);
dir_path.push(format!("{}.jsonl", Uuid::new_v4()));
dir_path
};
if let Some(parent) = dir_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
if let Ok(mut file) = File::create(&dir_path) {
file.write_all(b"").map_err(|_| GenericError("write_error"))?;
} else {
return Err(GenericError("write_error"));
}
let display_path = dir_path.to_string_lossy().to_string();
Ok((dir_path, display_path))
}
#[cfg(unix)]
fn try_launch_background_export(args: &Args) -> Option<Result<String, GenericError>> {
Some(launch_background_export(args))
}
#[cfg(not(unix))]
fn try_launch_background_export(_args: &Args) -> Option<Result<String, GenericError>> {
None
}
#[cfg(unix)]
fn launch_background_export(args: &Args) -> Result<String, GenericError> {
use std::os::unix::process::CommandExt;
let (_, export_path) = resolve_export_file(args.output.as_deref())?;
let exe = std::env::current_exe().map_err(|_| GenericError("exec_path_error"))?;
let mut cmd = Command::new(exe);
for arg in args_without_output_flag() {
cmd.arg(arg);
}
cmd.arg("--output").arg(&export_path);
cmd.arg("--background-worker");
let log_path = format!("{}.log", export_path);
let log_out = File::create(&log_path).map_err(|_| GenericError("write_error"))?;
let log_err = log_out.try_clone().map_err(|_| GenericError("write_error"))?;
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::from(log_out));
cmd.stderr(Stdio::from(log_err));
cmd.process_group(0);
cmd.spawn().map_err(|_| GenericError("spawn_error"))?;
Ok(export_path)
}
#[cfg(unix)]
fn args_without_output_flag() -> Vec<String> {
let mut out = Vec::new();
let mut skip_next = false;
for arg in std::env::args().skip(1) {
if skip_next {
skip_next = false;
continue;
}
if arg == "--output" || arg == "-o" {
skip_next = true;
continue;
}
if arg.starts_with("--output=") || arg.starts_with("-o=") {
continue;
}
out.push(arg);
}
out
}
fn append_line_to_file(file_path: &PathBuf, line: &str) -> Result<(), GenericError> {
if let Ok(mut file) = OpenOptions::new().append(true)
.create(true)
.open(file_path) {
file.write_all(format!("{}\n", line).as_bytes())?;
Ok(())
} else {
Err(GenericError("file_error"))
}
}