univert 0.4.0

Universal file converter (library and CLI)
Documentation
//! xlsx / ods spreadsheet → csv / json.
//!
//! Uses `calamine` to read the first worksheet and materialises rows as strings.
//! For json output, the first row is treated as the header.

use std::fs;
use std::io::Write;
use std::path::Path;

use calamine::{Data, Reader, open_workbook_auto};
use serde_json::{Map, Value};

use crate::{Error, Job, Result};

pub fn to_csv(job: &Job) -> Result<()> {
  let rows = read_first_sheet(job.input)?;
  let mut writer = csv::Writer::from_path(job.output)?;
  for row in &rows {
    writer.write_record(row)?;
  }
  writer.flush()?;
  Ok(())
}

pub fn to_tsv(job: &Job) -> Result<()> {
  let rows = read_first_sheet(job.input)?;
  let mut writer = csv::WriterBuilder::new()
    .delimiter(b'\t')
    .from_path(job.output)?;
  for row in &rows {
    writer.write_record(row)?;
  }
  writer.flush()?;
  Ok(())
}

pub fn to_json(job: &Job) -> Result<()> {
  let rows = read_first_sheet(job.input)?;
  let (headers, records) = match rows.split_first() {
    Some(split) => split,
    None => {
      fs::write(job.output, "[]\n")?;
      return Ok(());
    }
  };

  let mut file = fs::File::create(job.output)?;
  writeln!(file, "[")?;
  let mut first = true;
  for record in records {
    let mut obj = Map::with_capacity(headers.len());
    for (h, v) in headers.iter().zip(record.iter()) {
      obj.insert(h.clone(), Value::String(v.clone()));
    }
    if !first {
      writeln!(file, ",")?;
    }
    first = false;
    let line = serde_json::to_string(&Value::Object(obj))
      .map_err(|e| Error::other(format!("json serialize: {e}")))?;
    write!(file, "{line}")?;
  }
  writeln!(file)?;
  writeln!(file, "]")?;
  Ok(())
}

fn read_first_sheet(input: &Path) -> Result<Vec<Vec<String>>> {
  let mut workbook = open_workbook_auto(input)
    .map_err(|e| Error::other(format!("spreadsheet open: {e}")))?;
  let sheet_name = workbook
    .sheet_names()
    .first()
    .cloned()
    .ok_or_else(|| Error::other("spreadsheet: no worksheets"))?;
  let range = workbook
    .worksheet_range(&sheet_name)
    .map_err(|e| Error::other(format!("spreadsheet read: {e}")))?;

  let mut rows: Vec<Vec<String>> = Vec::with_capacity(range.height());
  for row in range.rows() {
    let cells = row.iter().map(cell_to_string).collect();
    rows.push(cells);
  }
  Ok(rows)
}

fn cell_to_string(d: &Data) -> String {
  match d {
    Data::Empty => String::new(),
    Data::String(s) => s.clone(),
    Data::Float(f) => {
      // Whole-valued floats render without a decimal point.
      if f.fract() == 0.0 && f.is_finite() {
        format!("{}", *f as i64)
      } else {
        format!("{f}")
      }
    }
    Data::Int(i) => i.to_string(),
    Data::Bool(b) => b.to_string(),
    Data::DateTime(dt) => dt.to_string(),
    Data::DateTimeIso(s) => s.clone(),
    Data::DurationIso(s) => s.clone(),
    Data::Error(e) => format!("#ERROR: {e:?}"),
  }
}