Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/app/src/ui/file_dialogs/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat
.map(str::to_owned);
source.metadata.insert(
"space.nmrtist.plotium.import.worksheet".into(),
serde_json::Value::String(sheet.name.clone()),
serde_json::Value::String(sheet.worksheet.clone()),
);
source.metadata.insert(
"space.nmrtist.plotium.import.formula_policy".into(),
Expand Down Expand Up @@ -129,7 +129,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat
)
.with_source("core.xlsx")
.with_context("path", path.display().to_string())
.with_context("worksheet", sheet.name.clone()),
.with_context("worksheet", sheet.worksheet.clone()),
);
for diagnostic in sheet.diagnostics {
report = report.with_diagnostic(
Expand All @@ -140,7 +140,7 @@ pub(super) fn import_xlsx_table_path(app: &mut PlotiumApp, path: &std::path::Pat
)
.with_source("core.xlsx")
.with_context("path", path.display().to_string())
.with_context("worksheet", sheet.name.clone()),
.with_context("worksheet", sheet.worksheet.clone()),
);
}
}
Expand Down
40 changes: 31 additions & 9 deletions crates/core/src/automation/table_import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,11 @@ pub struct TableImportParams {
#[serde(default)]
pub header: ImportHeader,
pub format: Option<ImportFormat>,
/// XLSX worksheets to import by name; every visible worksheet when absent.
/// XLSX worksheets to import by name; every visible worksheet when
/// neither this nor `regions` is given.
pub sheets: Option<Vec<String>>,
/// Explicit XLSX ranges, each imported as one table.
pub regions: Option<Vec<crate::xlsx::XlsxRegion>>,
/// An explicit ordered schema, not display-name-based resource addressing.
pub schema: Option<TableSchema>,
pub schema_path: Option<PathBuf>,
Expand All @@ -65,7 +68,12 @@ pub fn table_import_schema() -> Value {
json!({"type":"object","additionalProperties":false,"required":["mode","paths"],"properties":{
"mode":{"const":"table_data_only"},"paths":{"type":"array","minItems":1,"maxItems":TABLE_IMPORT_FILES,"items":{"type":"string"}},
"header":{"enum":["auto","present","absent"],"default":"auto","description":"CSV/TSV only."},"format":{"enum":["csv","tsv","xlsx"]},
"sheets":{"type":"array","minItems":1,"items":{"type":"string"},"description":"XLSX worksheet names, each imported as its own table. Default: every visible worksheet."},
"sheets":{"type":"array","minItems":1,"items":{"type":"string"},"description":"XLSX worksheets whose tables are recognized automatically. Default: every visible worksheet, unless regions is given."},
"regions":{"type":"array","minItems":1,"items":{"type":"object","additionalProperties":false,"required":["sheet","range"],"properties":{
"sheet":{"type":"string"},"range":{"type":"string","description":"A1 range such as J2:AC4411, or columns such as J:AC; include the header rows."},
"header_rows":{"type":"integer","minimum":0,"description":"Header rows at the top of range. Default: rows above the first value."},
"name":{"type":"string"},"split_segments":{"type":"boolean","default":true,"description":"Split a column where text rows separate stacked runs of values."}}},
"description":"XLSX ranges imported as one table each, for tables recognized wrongly; each preflight item reports its sheet and range."},
"schema":table_declaration_schema(),
"schema_path":{"type":"string","description":"Explicit PlotiumDelimitedSchemaV1 JSON file for CSV/TSV. Never discovered automatically."}},
"not":{"required":["schema","schema_path"]}})
Expand Down Expand Up @@ -106,6 +114,9 @@ impl TableImportParams {
if self.sheets.as_ref().is_some_and(Vec::is_empty) {
return Err(invalid("sheets must name at least one worksheet."));
}
if self.regions.as_ref().is_some_and(Vec::is_empty) {
return Err(invalid("regions must list at least one range."));
}
if self.schema.is_some() && self.schema_path.is_some() {
return Err(invalid("Supply schema or schema_path, not both."));
}
Expand All @@ -126,7 +137,7 @@ impl TableImportParams {
struct ImportItem {
path: PathBuf,
/// The source worksheet of an XLSX table.
sheet: Option<String>,
sheet: Option<super::table_import_xlsx::SourceSheet>,
dataset: Option<Dataset>,
summary: Value,
error: Option<String>,
Expand All @@ -136,7 +147,10 @@ impl ImportItem {
let mut value =
json!({"path":self.path,"status":status,"metadata":self.summary,"error":self.error});
if let Some(sheet) = &self.sheet {
value["sheet"] = json!(sheet);
value["sheet"] = json!(sheet.worksheet);
if let Some(range) = &sheet.range {
value["range"] = json!(range);
}
}
value
}
Expand Down Expand Up @@ -216,7 +230,7 @@ impl PreparedTableImport {
let mut workbooks: Vec<(&Path, Vec<String>)> = Vec::new();
for item in &prepared.items {
let Some(sheet) = &item.sheet else { continue };
let name = format!("'{sheet}'");
let name = format!("'{}'", sheet.worksheet);
match workbooks.iter_mut().find(|(path, _)| *path == item.path) {
Some((_, names)) => names.push(name),
None => workbooks.push((&item.path, vec![name])),
Expand Down Expand Up @@ -300,7 +314,10 @@ impl PreparedTableImport {
TargetOutcome::Failed
},
message: item.error.clone().unwrap_or_else(|| match &item.sheet {
Some(sheet) => format!("Imported worksheet '{sheet}' as native table data."),
Some(sheet) => format!(
"Imported worksheet '{}' as native table data.",
sheet.worksheet
),
None => "Imported native table data.".into(),
}),
skip_reason: None,
Expand Down Expand Up @@ -400,10 +417,15 @@ fn prepare_file(
);
}
let bytes = read_selected(path, budget, TABLE_IMPORT_FILE_BYTES)?;
return super::table_import_xlsx::prepare(path, bytes, params.sheets.as_deref());
return super::table_import_xlsx::prepare(
path,
bytes,
params.sheets.as_deref(),
params.regions.as_deref(),
);
}
if params.sheets.is_some() {
return Err("sheets applies to XLSX workbooks only.".into());
if params.sheets.is_some() || params.regions.is_some() {
return Err("sheets and regions apply to XLSX workbooks only.".into());
}
let bytes = read_selected(path, budget, TABLE_IMPORT_FILE_BYTES)?;
let input = std::str::from_utf8(&bytes).map_err(|e| format!("CSV/TSV must be UTF-8: {e}"))?;
Expand Down
182 changes: 122 additions & 60 deletions crates/core/src/automation/table_import_xlsx.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
//! XLSX workbooks import one native table per worksheet, as the desktop does.
//! XLSX workbooks import one native table per recognized worksheet table, as
//! the desktop does, plus any explicitly named regions.
use super::table_import::{TABLE_IMPORT_COLUMNS, TABLE_IMPORT_ROWS, TABLE_IMPORT_XLSX_CELLS};
use crate::{
state::{Dataset, TableDataset, TableImportSource, TypedTableState},
xlsx::ImportedXlsxSheet,
xlsx::{ImportedXlsxSheet, XlsxRegion},
};
use plotium_data::{CodecRegistry, MemoryBlockStore};
use plotium_io::xlsx::{XlsxIoError, XlsxReader, XlsxWorkbook};
use std::{path::Path, sync::Arc};

const XLSX_MEDIA_TYPE: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";

/// The worksheet, and range within it, that an XLSX table was read from.
#[derive(Clone, Debug)]
pub(super) struct SourceSheet {
pub worksheet: String,
pub range: Option<String>,
}

/// A prepared table, or why it failed, with the worksheet it came from.
pub(super) type PreparedTable = (Option<String>, Result<Dataset, String>);
pub(super) type PreparedTable = (Option<SourceSheet>, Result<Dataset, String>);

/// Parses the retained bytes worksheet by worksheet, so one worksheet that
/// fails or exceeds the cell budget leaves the others importable. `sheets`
/// selects worksheets by name; otherwise every visible worksheet with cells is
/// imported.
/// Parses the retained bytes worksheet by worksheet, so one worksheet or
/// region that fails or exceeds the cell budget leaves the others importable.
/// `sheets` selects worksheets for recognition and `regions` adds explicit
/// ranges; without either, every visible worksheet with cells is recognized.
pub(super) fn prepare(
path: &Path,
bytes: Vec<u8>,
sheets: Option<&[String]>,
regions: Option<&[XlsxRegion]>,
) -> Result<Vec<PreparedTable>, String> {
let bytes = Arc::<[u8]>::from(bytes);
let mut reader =
Expand All @@ -31,85 +40,138 @@ pub(super) fn prepare(
.filter(|sheet| !sheet.hidden)
.map(|sheet| sheet.name.clone())
.collect::<Vec<_>>();
let selected = match sheets {
Some(names) => {
if let Some(missing) = names.iter().find(|name| !visible.contains(name)) {
let available = visible
.iter()
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"Worksheet '{missing}' is not a visible worksheet of this workbook. Visible worksheets: {available}."
));
}
visible
let named = sheets
.into_iter()
.flatten()
.chain(regions.into_iter().flatten().map(|region| &region.sheet));
for name in named {
if !visible.contains(name) {
let available = visible
.iter()
.filter(|name| names.contains(name))
.cloned()
.collect()
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(", ");
return Err(format!(
"Worksheet '{name}' is not a visible worksheet of this workbook. Visible worksheets: {available}."
));
}
None => visible,
}
let recognized = |name: &String| match (sheets, regions) {
(Some(names), _) => names.contains(name),
(None, Some(_)) => false,
(None, None) => true,
};
let in_region =
|name: &String| regions.is_some_and(|regions| regions.iter().any(|r| r.sheet == *name));
let mut remaining = TABLE_IMPORT_XLSX_CELLS;
let mut read = Vec::new();
for name in selected {
match reader.read_sheet(&name, remaining) {
for name in visible
.iter()
.filter(|name| recognized(name) || in_region(name))
{
let sheet = match reader.read_sheet(name, remaining) {
Ok(sheet) => {
let width = sheet.rows.iter().map(Vec::len).max().unwrap_or(0);
remaining = remaining.saturating_sub(sheet.rows.len() * width);
// Workbooks often keep blank default sheets, which hold no table.
if sheets.is_some() || !sheet.rows.is_empty() {
read.push((name, Ok(sheet)));
if sheets.is_none() && regions.is_none() && sheet.rows.is_empty() {
continue;
}
Ok(sheet)
}
Err(XlsxIoError::SheetOverBudget { .. }) => read.push((
name,
Err(format!(
"The worksheet exceeds the cell budget: one XLSX import reads at most {TABLE_IMPORT_XLSX_CELLS} cells across its worksheets. Name fewer worksheets in sheets."
)),
Err(XlsxIoError::SheetOverBudget { .. }) => Err(format!(
"The worksheet exceeds the cell budget: one XLSX import reads at most {TABLE_IMPORT_XLSX_CELLS} cells across its worksheets. Name fewer worksheets in sheets."
)),
Err(error) => read.push((name, Err(error.to_string()))),
}
Err(error) => Err(error.to_string()),
};
read.push((name.clone(), sheet));
}
if read.is_empty() {
return Err("The workbook has no visible worksheet with cells.".into());
}
// Each worksheet is imported as a workbook of its own so it fails alone;
// the schema sidecar and cell diagnostics are shared.
let shared = reader.into_workbook(Vec::new());
let file_stem = (sheets.is_none() && read.len() == 1).then(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
});
let read = read
.into_iter()
.map(|(name, sheet)| {
let workbook = sheet.map(|sheet| XlsxWorkbook {
sheets: vec![sheet],
..shared.clone()
});
(name, workbook)
})
.collect::<Vec<_>>();
let retained = Retained {
bytes,
file_name: path.file_name().map(|s| s.to_string_lossy().into_owned()),
store: Arc::new(MemoryBlockStore::default()),
};
let codecs = CodecRegistry::with_arrow_ipc();
let mut prepared = Vec::new();
for (name, sheet) in read {
let tables = sheet.and_then(|sheet| {
let workbook = XlsxWorkbook {
sheets: vec![sheet],
..shared.clone()
};
crate::xlsx::import_xlsx_workbook(&workbook, retained.store.as_ref(), &codecs)
let mut tables: Vec<(SourceSheet, Result<ImportedXlsxSheet, String>)> = Vec::new();
for (name, sheet) in read.iter().filter(|(name, _)| recognized(name)) {
let imported = sheet.as_ref().map_err(Clone::clone).and_then(|workbook| {
crate::xlsx::import_xlsx_workbook(workbook, retained.store.as_ref(), &codecs)
.map_err(|e| e.to_string())
});
match tables {
Ok(tables) => prepared.extend(tables.into_iter().map(|table| {
let dataset_name = file_stem.clone().unwrap_or_else(|| table.name.clone());
(
Some(table.name.clone()),
retained.dataset(table, dataset_name),
)
match imported {
Ok(imported) => tables.extend(imported.into_iter().map(|table| {
let source = SourceSheet {
worksheet: table.worksheet.clone(),
range: table.range.clone(),
};
(source, Ok(table))
})),
Err(error) => prepared.push((Some(name), Err(error))),
Err(error) => tables.push((
SourceSheet {
worksheet: name.clone(),
range: None,
},
Err(error),
)),
}
}
Ok(prepared)
for region in regions.into_iter().flatten() {
let sheet = read
.iter()
.find(|(name, _)| *name == region.sheet)
.map(|(_, sheet)| sheet)
.ok_or("The region's worksheet was not read.")?;
let imported = sheet.as_ref().map_err(Clone::clone).and_then(|workbook| {
crate::xlsx::import_xlsx_regions(
workbook,
std::slice::from_ref(region),
retained.store.as_ref(),
&codecs,
)
.map_err(|e| e.to_string())?
.pop()
.ok_or_else(|| "The region produced no table.".to_owned())
});
let source = SourceSheet {
worksheet: region.sheet.clone(),
range: imported
.as_ref()
.map_or(Some(region.range.clone()), |table| table.range.clone()),
};
tables.push((source, imported));
}
let file_stem = (sheets.is_none() && regions.is_none() && tables.len() == 1).then(|| {
path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
});
Ok(tables
.into_iter()
.map(|(source, table)| {
let dataset = table.and_then(|table| {
let name = file_stem.clone().unwrap_or_else(|| table.name.clone());
retained.dataset(table, name)
});
(Some(source), dataset)
})
.collect())
}

/// What every table of one workbook shares.
Expand All @@ -125,7 +187,7 @@ impl Retained {
|| sheet.snapshot.row_count > TABLE_IMPORT_ROWS as u64
{
return Err(format!(
"The worksheet exceeds the budget of {TABLE_IMPORT_COLUMNS} columns and {TABLE_IMPORT_ROWS} rows per table."
"The table exceeds the budget of {TABLE_IMPORT_COLUMNS} columns and {TABLE_IMPORT_ROWS} rows per table; import a narrower range with regions."
));
}
let typed = TypedTableState::imported_with_operation(
Expand All @@ -140,7 +202,7 @@ impl Retained {
source.name = self.file_name.clone();
source.metadata.insert(
"space.nmrtist.plotium.import.worksheet".into(),
serde_json::Value::String(sheet.name),
serde_json::Value::String(sheet.worksheet),
);
source.metadata.insert(
"space.nmrtist.plotium.import.formula_policy".into(),
Expand Down
Loading
Loading