feat: wire spreadsheet artifact (#13362)
This commit is contained in:
parent
24ba01b9da
commit
8159f05dfd
13 changed files with 1180 additions and 23 deletions
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
|
|
@ -1802,6 +1802,7 @@ dependencies = [
|
|||
"codex-apply-patch",
|
||||
"codex-arg0",
|
||||
"codex-artifact-presentation",
|
||||
"codex-artifact-spreadsheet",
|
||||
"codex-async-utils",
|
||||
"codex-client",
|
||||
"codex-config",
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ pub struct SpreadsheetChart {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SpreadsheetChartLookup<'a> {
|
||||
pub struct SpreadsheetChartLookup {
|
||||
pub id: Option<u32>,
|
||||
pub index: Option<usize>,
|
||||
}
|
||||
|
|
@ -101,7 +101,6 @@ impl SpreadsheetSheet {
|
|||
.transpose()
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.is_some_and(|chart_range| chart_range.intersects(target))
|
||||
})
|
||||
})
|
||||
|
|
@ -112,7 +111,7 @@ impl SpreadsheetSheet {
|
|||
pub fn get_chart(
|
||||
&self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
lookup: SpreadsheetChartLookup,
|
||||
) -> Result<&SpreadsheetChart, SpreadsheetArtifactError> {
|
||||
if let Some(id) = lookup.id {
|
||||
return self
|
||||
|
|
@ -221,7 +220,7 @@ impl SpreadsheetSheet {
|
|||
pub fn add_chart_series(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
lookup: SpreadsheetChartLookup,
|
||||
mut series: SpreadsheetChartSeries,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
validate_chart_series(action, &series)?;
|
||||
|
|
@ -235,7 +234,7 @@ impl SpreadsheetSheet {
|
|||
pub fn delete_chart(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
lookup: SpreadsheetChartLookup,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let index = if let Some(id) = lookup.id {
|
||||
self.charts
|
||||
|
|
@ -267,7 +266,7 @@ impl SpreadsheetSheet {
|
|||
pub fn set_chart_properties(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
lookup: SpreadsheetChartLookup,
|
||||
properties: SpreadsheetChartProperties,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let chart = self.get_chart_mut(action, lookup)?;
|
||||
|
|
@ -307,7 +306,7 @@ impl SpreadsheetSheet {
|
|||
fn get_chart_mut(
|
||||
&mut self,
|
||||
action: &str,
|
||||
lookup: SpreadsheetChartLookup<'_>,
|
||||
lookup: SpreadsheetChartLookup,
|
||||
) -> Result<&mut SpreadsheetChart, SpreadsheetArtifactError> {
|
||||
if let Some(id) = lookup.id {
|
||||
return self
|
||||
|
|
@ -320,11 +319,12 @@ impl SpreadsheetSheet {
|
|||
});
|
||||
}
|
||||
if let Some(index) = lookup.index {
|
||||
let len = self.charts.len();
|
||||
return self.charts.get_mut(index).ok_or_else(|| {
|
||||
SpreadsheetArtifactError::IndexOutOfRange {
|
||||
action: action.to_string(),
|
||||
index,
|
||||
len: self.charts.len(),
|
||||
len,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,18 @@ impl SpreadsheetConditionalFormatCollection {
|
|||
}
|
||||
|
||||
impl SpreadsheetArtifact {
|
||||
pub fn validate_conditional_formats(
|
||||
&self,
|
||||
action: &str,
|
||||
sheet_name: &str,
|
||||
) -> Result<(), SpreadsheetArtifactError> {
|
||||
let sheet = self.sheet_lookup(action, Some(sheet_name), None)?;
|
||||
for format in &sheet.conditional_formats {
|
||||
validate_conditional_format(self, format, action)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_conditional_format(
|
||||
&mut self,
|
||||
action: &str,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,26 @@
|
|||
mod address;
|
||||
mod chart;
|
||||
mod conditional;
|
||||
mod error;
|
||||
mod formula;
|
||||
mod manager;
|
||||
mod model;
|
||||
mod pivot;
|
||||
mod render;
|
||||
mod style;
|
||||
mod table;
|
||||
mod xlsx;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use address::*;
|
||||
pub use chart::*;
|
||||
pub use conditional::*;
|
||||
pub use error::*;
|
||||
pub use manager::*;
|
||||
pub use model::*;
|
||||
pub use pivot::*;
|
||||
pub use render::*;
|
||||
pub use style::*;
|
||||
pub use table::*;
|
||||
|
|
|
|||
|
|
@ -113,6 +113,26 @@ impl SpreadsheetArtifactManager {
|
|||
"list_sheets" => self.list_sheets(request),
|
||||
"get_sheet" => self.get_sheet(request),
|
||||
"inspect" => self.inspect(request),
|
||||
"list_charts" => self.list_charts(request),
|
||||
"get_chart" => self.get_chart(request),
|
||||
"create_chart" => self.create_chart(request),
|
||||
"add_chart_series" => self.add_chart_series(request),
|
||||
"set_chart_properties" => self.set_chart_properties(request),
|
||||
"delete_chart" => self.delete_chart(request),
|
||||
"list_tables" => self.list_tables(request),
|
||||
"get_table" => self.get_table(request),
|
||||
"create_table" => self.create_table(request),
|
||||
"set_table_style" => self.set_table_style(request),
|
||||
"clear_table_filters" => self.clear_table_filters(request),
|
||||
"reapply_table_filters" => self.reapply_table_filters(request),
|
||||
"rename_table_column" => self.rename_table_column(request),
|
||||
"set_table_column_totals" => self.set_table_column_totals(request),
|
||||
"delete_table" => self.delete_table(request),
|
||||
"list_conditional_formats" => self.list_conditional_formats(request),
|
||||
"add_conditional_format" => self.add_conditional_format(request),
|
||||
"delete_conditional_format" => self.delete_conditional_format(request),
|
||||
"list_pivot_tables" => self.list_pivot_tables(request),
|
||||
"get_pivot_table" => self.get_pivot_table(request),
|
||||
"create_sheet" => self.create_sheet(request),
|
||||
"rename_sheet" => self.rename_sheet(request),
|
||||
"delete_sheet" => self.delete_sheet(request),
|
||||
|
|
@ -493,6 +513,632 @@ impl SpreadsheetArtifactManager {
|
|||
Ok(response)
|
||||
}
|
||||
|
||||
fn list_charts(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SheetLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let range = args.range.as_deref().map(CellRange::parse).transpose()?;
|
||||
let charts = sheet.list_charts(range.as_ref())?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Listed {} charts on `{}`", charts.len(), sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.range_ref = range
|
||||
.as_ref()
|
||||
.map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry));
|
||||
response.chart_list = Some(charts);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn get_chart(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: ChartLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let chart = sheet
|
||||
.get_chart(&request.action, chart_lookup_from_args(&args))?
|
||||
.clone();
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Retrieved chart `{}` from `{}`", chart.id, sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.chart_list = Some(vec![chart]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn create_chart(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: CreateChartArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
if let Some(source_sheet_name) = args.source_sheet_name.as_deref() {
|
||||
artifact.sheet_lookup(&request.action, Some(source_sheet_name), None)?;
|
||||
}
|
||||
let source_range = CellRange::parse(&args.source_range)?;
|
||||
let chart = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let chart_id = sheet.create_chart(
|
||||
&request.action,
|
||||
args.chart_type,
|
||||
args.source_sheet_name.or_else(|| Some(sheet.name.clone())),
|
||||
&source_range,
|
||||
args.options.unwrap_or_default(),
|
||||
)?;
|
||||
sheet
|
||||
.get_chart(
|
||||
&request.action,
|
||||
crate::SpreadsheetChartLookup {
|
||||
id: Some(chart_id),
|
||||
index: None,
|
||||
},
|
||||
)?
|
||||
.clone()
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Created chart `{}`", chart.id),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.chart_list = Some(vec![chart]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn add_chart_series(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: AddChartSeriesArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let chart = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let series_id = sheet.add_chart_series(
|
||||
&request.action,
|
||||
crate::SpreadsheetChartLookup {
|
||||
id: args.chart_id,
|
||||
index: args.chart_index.map(|value| value as usize),
|
||||
},
|
||||
args.series,
|
||||
)?;
|
||||
let chart = sheet.get_chart(
|
||||
&request.action,
|
||||
crate::SpreadsheetChartLookup {
|
||||
id: args.chart_id.or(Some(series_id).and(None)),
|
||||
index: args.chart_index.map(|value| value as usize),
|
||||
},
|
||||
);
|
||||
match chart {
|
||||
Ok(chart) => chart.clone(),
|
||||
Err(_) => sheet
|
||||
.get_chart(
|
||||
&request.action,
|
||||
crate::SpreadsheetChartLookup {
|
||||
id: None,
|
||||
index: args.chart_index.map(|value| value as usize),
|
||||
},
|
||||
)?
|
||||
.clone(),
|
||||
}
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Added chart series to chart `{}`", chart.id),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.chart_list = Some(vec![chart]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn set_chart_properties(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SetChartPropertiesArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let chart = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.lookup.sheet_name.as_deref(),
|
||||
args.lookup.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let lookup = chart_lookup_from_args(&args.lookup);
|
||||
sheet.set_chart_properties(&request.action, lookup.clone(), args.properties)?;
|
||||
sheet.get_chart(&request.action, lookup)?.clone()
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Updated chart `{}`", chart.id),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.chart_list = Some(vec![chart]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn delete_chart(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: ChartLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let sheet_name = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let name = sheet.name.clone();
|
||||
sheet.delete_chart(&request.action, chart_lookup_from_args(&args))?;
|
||||
name
|
||||
};
|
||||
let action = request.action.clone();
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
action.clone(),
|
||||
format!("Deleted chart from `{sheet_name}`"),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_list = Some(vec![
|
||||
artifact
|
||||
.sheet_lookup(&action, Some(&sheet_name), None)?
|
||||
.summary(),
|
||||
]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn list_tables(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SheetLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let range = args.range.as_deref().map(CellRange::parse).transpose()?;
|
||||
let tables = sheet.list_tables(range.as_ref())?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Listed {} tables on `{}`", tables.len(), sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.range_ref = range
|
||||
.as_ref()
|
||||
.map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry));
|
||||
response.table_list = Some(tables);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn get_table(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: TableLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let table = sheet.get_table_view(&request.action, table_lookup_from_args(&args))?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Retrieved table `{}` from `{}`", table.name, sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.table_list = Some(vec![table]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn create_table(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: CreateTableArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let range = CellRange::parse(&args.range)?;
|
||||
let table = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let table_id = sheet.create_table(&request.action, &range, args.options)?;
|
||||
sheet.get_table_view(
|
||||
&request.action,
|
||||
crate::SpreadsheetTableLookup {
|
||||
name: None,
|
||||
display_name: None,
|
||||
id: Some(table_id),
|
||||
},
|
||||
)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Created table `{}`", table.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.table_list = Some(vec![table]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn set_table_style(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SetTableStyleArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let table = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.lookup.sheet_name.as_deref(),
|
||||
args.lookup.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let lookup = table_lookup_from_args(&args.lookup);
|
||||
sheet.set_table_style(&request.action, lookup.clone(), args.options)?;
|
||||
sheet.get_table_view(&request.action, lookup)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Updated table style for `{}`", table.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.table_list = Some(vec![table]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn clear_table_filters(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: TableLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let table = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let lookup = table_lookup_from_args(&args);
|
||||
sheet.clear_table_filters(&request.action, lookup.clone())?;
|
||||
sheet.get_table_view(&request.action, lookup)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Cleared table filters for `{}`", table.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.table_list = Some(vec![table]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn reapply_table_filters(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: TableLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let table = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let lookup = table_lookup_from_args(&args);
|
||||
sheet.reapply_table_filters(&request.action, lookup.clone())?;
|
||||
sheet.get_table_view(&request.action, lookup)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Reapplied table filters for `{}`", table.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.table_list = Some(vec![table]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn rename_table_column(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: RenameTableColumnArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let column = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.lookup.sheet_name.as_deref(),
|
||||
args.lookup.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
sheet.rename_table_column(
|
||||
&request.action,
|
||||
table_lookup_from_args(&args.lookup),
|
||||
args.column_id,
|
||||
args.column_name.as_deref(),
|
||||
args.new_name,
|
||||
)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Renamed table column to `{}`", column.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.serialized_dict = Some(to_serialized_value(column)?);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn set_table_column_totals(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SetTableColumnTotalsArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let column = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.lookup.sheet_name.as_deref(),
|
||||
args.lookup.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
sheet.set_table_column_totals(
|
||||
&request.action,
|
||||
table_lookup_from_args(&args.lookup),
|
||||
args.column_id,
|
||||
args.column_name.as_deref(),
|
||||
args.totals_row_label,
|
||||
args.totals_row_function,
|
||||
)?
|
||||
};
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Updated totals metadata for column `{}`", column.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.serialized_dict = Some(to_serialized_value(column)?);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn delete_table(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: TableLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let sheet_name = {
|
||||
let sheet = artifact.sheet_lookup_mut(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let name = sheet.name.clone();
|
||||
sheet.delete_table(&request.action, table_lookup_from_args(&args))?;
|
||||
name
|
||||
};
|
||||
let action = request.action.clone();
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
action.clone(),
|
||||
format!("Deleted table from `{sheet_name}`"),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_list = Some(vec![
|
||||
artifact
|
||||
.sheet_lookup(&action, Some(&sheet_name), None)?
|
||||
.summary(),
|
||||
]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn list_conditional_formats(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SheetLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let range = args.range.as_deref().map(CellRange::parse).transpose()?;
|
||||
let formats = sheet.list_conditional_formats(range.as_ref());
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!(
|
||||
"Listed {} conditional formats on `{}`",
|
||||
formats.len(),
|
||||
sheet.name
|
||||
),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.range_ref = range
|
||||
.as_ref()
|
||||
.map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry));
|
||||
response.conditional_format_list = Some(formats);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn add_conditional_format(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: AddConditionalFormatArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let sheet_name = artifact
|
||||
.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?
|
||||
.name
|
||||
.clone();
|
||||
let format_id =
|
||||
artifact.add_conditional_format(&request.action, &sheet_name, args.format)?;
|
||||
let format = artifact
|
||||
.sheet_lookup(&request.action, Some(&sheet_name), None)?
|
||||
.conditional_formats
|
||||
.iter()
|
||||
.find(|entry| entry.id == format_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| SpreadsheetArtifactError::Serialization {
|
||||
message: format!("created conditional format `{format_id}` was not available"),
|
||||
})?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Created conditional format `{format_id}`"),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.conditional_format_list = Some(vec![format]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn delete_conditional_format(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: DeleteConditionalFormatArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
|
||||
let sheet_name = artifact
|
||||
.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?
|
||||
.name
|
||||
.clone();
|
||||
artifact.delete_conditional_format(&request.action, &sheet_name, args.id)?;
|
||||
let action = request.action.clone();
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
action.clone(),
|
||||
format!("Deleted conditional format `{}`", args.id),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_list = Some(vec![
|
||||
artifact
|
||||
.sheet_lookup(&action, Some(&sheet_name), None)?
|
||||
.summary(),
|
||||
]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn list_pivot_tables(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: SheetLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let range = args.range.as_deref().map(CellRange::parse).transpose()?;
|
||||
let pivots = sheet.list_pivot_tables(range.as_ref())?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!("Listed {} pivot tables on `{}`", pivots.len(), sheet.name),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.range_ref = range
|
||||
.as_ref()
|
||||
.map(|entry| SpreadsheetCellRangeRef::new(sheet.name.clone(), entry));
|
||||
response.pivot_table_list = Some(pivots);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn get_pivot_table(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let args: PivotTableLookupArgs = parse_args(&request.action, &request.args)?;
|
||||
let artifact_id = required_artifact_id(&request)?;
|
||||
let artifact = self.get_artifact(&artifact_id, &request.action)?;
|
||||
let sheet = artifact.sheet_lookup(
|
||||
&request.action,
|
||||
args.sheet_name.as_deref(),
|
||||
args.sheet_index.map(|value| value as usize),
|
||||
)?;
|
||||
let pivot = sheet
|
||||
.get_pivot_table(&request.action, pivot_lookup_from_args(&args))?
|
||||
.clone();
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
format!(
|
||||
"Retrieved pivot table `{}` from `{}`",
|
||||
pivot.name, sheet.name
|
||||
),
|
||||
snapshot_for_artifact(artifact),
|
||||
);
|
||||
response.sheet_ref = Some(sheet_reference(sheet));
|
||||
response.pivot_table_list = Some(vec![pivot]);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn rename_sheet(
|
||||
&mut self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
|
|
@ -776,6 +1422,7 @@ impl SpreadsheetArtifactManager {
|
|||
sheet.cleanup_and_validate_sheet()?;
|
||||
sheet.summary()
|
||||
};
|
||||
artifact.validate_conditional_formats(&request.action, &sheet_summary.name)?;
|
||||
let mut response = SpreadsheetArtifactResponse::new(
|
||||
artifact_id,
|
||||
request.action,
|
||||
|
|
@ -2006,6 +2653,14 @@ pub struct SpreadsheetArtifactResponse {
|
|||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sheet_list: Option<Vec<SpreadsheetSheetSummary>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chart_list: Option<Vec<crate::SpreadsheetChart>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub table_list: Option<Vec<crate::SpreadsheetTableView>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub conditional_format_list: Option<Vec<crate::SpreadsheetConditionalFormat>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pivot_table_list: Option<Vec<crate::SpreadsheetPivotTable>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sheet_ref: Option<SpreadsheetSheetRef>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cell_ref: Option<SpreadsheetCellRef>,
|
||||
|
|
@ -2056,6 +2711,10 @@ impl SpreadsheetArtifactResponse {
|
|||
artifact_snapshot: Some(artifact_snapshot),
|
||||
workbook_summary: None,
|
||||
sheet_list: None,
|
||||
chart_list: None,
|
||||
table_list: None,
|
||||
conditional_format_list: None,
|
||||
pivot_table_list: None,
|
||||
sheet_ref: None,
|
||||
cell_ref: None,
|
||||
range_ref: None,
|
||||
|
|
@ -2097,6 +2756,10 @@ pub struct SpreadsheetSheetSnapshot {
|
|||
pub filled_columns: usize,
|
||||
pub minimum_range_filled: String,
|
||||
pub merged_range_count: usize,
|
||||
pub chart_count: usize,
|
||||
pub table_count: usize,
|
||||
pub conditional_format_count: usize,
|
||||
pub pivot_table_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -2137,6 +2800,105 @@ struct SheetLookupArgs {
|
|||
range: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartLookupArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
chart_id: Option<u32>,
|
||||
chart_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateChartArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
chart_type: crate::SpreadsheetChartType,
|
||||
source_sheet_name: Option<String>,
|
||||
source_range: String,
|
||||
options: Option<crate::SpreadsheetChartCreateOptions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddChartSeriesArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
chart_id: Option<u32>,
|
||||
chart_index: Option<u32>,
|
||||
series: crate::SpreadsheetChartSeries,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetChartPropertiesArgs {
|
||||
#[serde(flatten)]
|
||||
lookup: ChartLookupArgs,
|
||||
properties: crate::SpreadsheetChartProperties,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TableLookupArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
name: Option<String>,
|
||||
display_name: Option<String>,
|
||||
id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateTableArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
range: String,
|
||||
options: crate::SpreadsheetCreateTableOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetTableStyleArgs {
|
||||
#[serde(flatten)]
|
||||
lookup: TableLookupArgs,
|
||||
options: crate::SpreadsheetTableStyleOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RenameTableColumnArgs {
|
||||
#[serde(flatten)]
|
||||
lookup: TableLookupArgs,
|
||||
column_id: Option<u32>,
|
||||
column_name: Option<String>,
|
||||
new_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SetTableColumnTotalsArgs {
|
||||
#[serde(flatten)]
|
||||
lookup: TableLookupArgs,
|
||||
column_id: Option<u32>,
|
||||
column_name: Option<String>,
|
||||
totals_row_label: Option<String>,
|
||||
totals_row_function: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AddConditionalFormatArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
format: crate::SpreadsheetConditionalFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeleteConditionalFormatArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PivotTableLookupArgs {
|
||||
sheet_name: Option<String>,
|
||||
sheet_index: Option<u32>,
|
||||
name: Option<String>,
|
||||
index: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateSheetArgs {
|
||||
name: String,
|
||||
|
|
@ -2444,11 +3206,37 @@ fn snapshot_for_artifact(artifact: &SpreadsheetArtifact) -> SpreadsheetArtifactS
|
|||
filled_columns: sheet.filled_columns(),
|
||||
minimum_range_filled: sheet.minimum_range_filled(),
|
||||
merged_range_count: sheet.merged_ranges.len(),
|
||||
chart_count: sheet.charts.len(),
|
||||
table_count: sheet.tables.len(),
|
||||
conditional_format_count: sheet.conditional_formats.len(),
|
||||
pivot_table_count: sheet.pivot_tables.len(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_lookup_from_args(args: &ChartLookupArgs) -> crate::SpreadsheetChartLookup {
|
||||
crate::SpreadsheetChartLookup {
|
||||
id: args.chart_id,
|
||||
index: args.chart_index.map(|value| value as usize),
|
||||
}
|
||||
}
|
||||
|
||||
fn table_lookup_from_args(args: &TableLookupArgs) -> crate::SpreadsheetTableLookup<'_> {
|
||||
crate::SpreadsheetTableLookup {
|
||||
name: args.name.as_deref(),
|
||||
display_name: args.display_name.as_deref(),
|
||||
id: args.id,
|
||||
}
|
||||
}
|
||||
|
||||
fn pivot_lookup_from_args(args: &PivotTableLookupArgs) -> crate::SpreadsheetPivotTableLookup<'_> {
|
||||
crate::SpreadsheetPivotTableLookup {
|
||||
name: args.name.as_deref(),
|
||||
index: args.index.map(|value| value as usize),
|
||||
}
|
||||
}
|
||||
|
||||
fn sheet_reference(sheet: &crate::SpreadsheetSheet) -> SpreadsheetSheetRef {
|
||||
SpreadsheetSheetRef {
|
||||
sheet_name: sheet.name.clone(),
|
||||
|
|
|
|||
|
|
@ -457,6 +457,10 @@ pub struct SpreadsheetSheetSummary {
|
|||
pub default_column_width: Option<f64>,
|
||||
pub show_grid_lines: bool,
|
||||
pub merged_range_count: usize,
|
||||
pub chart_count: usize,
|
||||
pub table_count: usize,
|
||||
pub conditional_format_count: usize,
|
||||
pub pivot_table_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
|
|
@ -504,6 +508,14 @@ pub struct SpreadsheetSheet {
|
|||
pub cells: BTreeMap<CellAddress, SpreadsheetCell>,
|
||||
#[serde(default)]
|
||||
pub merged_ranges: Vec<CellRange>,
|
||||
#[serde(default)]
|
||||
pub charts: Vec<crate::SpreadsheetChart>,
|
||||
#[serde(default)]
|
||||
pub tables: Vec<crate::SpreadsheetTable>,
|
||||
#[serde(default)]
|
||||
pub conditional_formats: Vec<crate::SpreadsheetConditionalFormat>,
|
||||
#[serde(default)]
|
||||
pub pivot_tables: Vec<crate::SpreadsheetPivotTable>,
|
||||
pub default_row_height: Option<f64>,
|
||||
pub default_column_width: Option<f64>,
|
||||
pub show_grid_lines: bool,
|
||||
|
|
@ -569,6 +581,10 @@ impl SpreadsheetSheet {
|
|||
name,
|
||||
cells: BTreeMap::new(),
|
||||
merged_ranges: Vec::new(),
|
||||
charts: Vec::new(),
|
||||
tables: Vec::new(),
|
||||
conditional_formats: Vec::new(),
|
||||
pivot_tables: Vec::new(),
|
||||
default_row_height: None,
|
||||
default_column_width: None,
|
||||
show_grid_lines: true,
|
||||
|
|
@ -679,6 +695,10 @@ impl SpreadsheetSheet {
|
|||
default_column_width: self.default_column_width,
|
||||
show_grid_lines: self.show_grid_lines,
|
||||
merged_range_count: self.merged_ranges.len(),
|
||||
chart_count: self.charts.len(),
|
||||
table_count: self.tables.len(),
|
||||
conditional_format_count: self.conditional_formats.len(),
|
||||
pivot_table_count: self.pivot_tables.len(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1303,6 +1323,9 @@ impl SpreadsheetSheet {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.validate_tables("cleanup_and_validate_sheet")?;
|
||||
self.validate_charts("cleanup_and_validate_sheet")?;
|
||||
self.validate_pivot_tables("cleanup_and_validate_sheet")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1718,7 +1741,24 @@ impl SpreadsheetArtifact {
|
|||
}
|
||||
|
||||
match selected.as_str() {
|
||||
"xlsx" => write_xlsx(self, path),
|
||||
"xlsx" => {
|
||||
for sheet in &self.sheets {
|
||||
if !sheet.charts.is_empty()
|
||||
|| !sheet.tables.is_empty()
|
||||
|| !sheet.conditional_formats.is_empty()
|
||||
|| !sheet.pivot_tables.is_empty()
|
||||
{
|
||||
return Err(SpreadsheetArtifactError::ExportFailed {
|
||||
path: path.to_path_buf(),
|
||||
message: format!(
|
||||
"xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead",
|
||||
sheet.name
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
write_xlsx(self, path)
|
||||
}
|
||||
"json" => {
|
||||
let json = self.to_json()?;
|
||||
std::fs::write(path, json).map_err(|error| {
|
||||
|
|
|
|||
|
|
@ -181,7 +181,12 @@ impl SpreadsheetSheet {
|
|||
range: &CellRange,
|
||||
options: SpreadsheetCreateTableOptions,
|
||||
) -> Result<u32, SpreadsheetArtifactError> {
|
||||
validate_table_geometry(action, range, options.header_row_count, options.totals_row_count)?;
|
||||
validate_table_geometry(
|
||||
action,
|
||||
range,
|
||||
options.header_row_count,
|
||||
options.totals_row_count,
|
||||
)?;
|
||||
for table in &self.tables {
|
||||
let table_range = table.range()?;
|
||||
if table_range.intersects(range) {
|
||||
|
|
@ -368,7 +373,12 @@ impl SpreadsheetSheet {
|
|||
let mut seen_display_names = BTreeSet::new();
|
||||
for table in &self.tables {
|
||||
let range = table.range()?;
|
||||
validate_table_geometry(action, &range, table.header_row_count, table.totals_row_count)?;
|
||||
validate_table_geometry(
|
||||
action,
|
||||
&range,
|
||||
table.header_row_count,
|
||||
table.totals_row_count,
|
||||
)?;
|
||||
if !seen_names.insert(table.name.clone()) {
|
||||
return Err(SpreadsheetArtifactError::InvalidArgs {
|
||||
action: action.to_string(),
|
||||
|
|
@ -565,7 +575,8 @@ fn build_table_columns(
|
|||
|
||||
fn unique_table_column_names(names: Vec<String>) -> Vec<String> {
|
||||
let mut seen = BTreeMap::<String, u32>::new();
|
||||
names.into_iter()
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let entry = seen.entry(name.clone()).or_insert(0);
|
||||
*entry += 1;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ codex-keyring-store = { workspace = true }
|
|||
codex-network-proxy = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-artifact-presentation = { workspace = true }
|
||||
codex-artifact-spreadsheet = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-rmcp-client = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ use chrono::Utc;
|
|||
use codex_artifact_presentation::PresentationArtifactError;
|
||||
use codex_artifact_presentation::PresentationArtifactExecutionRequest;
|
||||
use codex_artifact_presentation::PresentationArtifactResponse;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactError;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactRequest;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactResponse;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
|
|
@ -1786,7 +1789,16 @@ impl Session {
|
|||
cwd: &Path,
|
||||
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
|
||||
let mut state = self.state.lock().await;
|
||||
state.presentation_artifacts.execute_requests(request, cwd)
|
||||
state.artifacts.presentation.execute_requests(request, cwd)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute_spreadsheet_artifact(
|
||||
&self,
|
||||
request: SpreadsheetArtifactRequest,
|
||||
cwd: &Path,
|
||||
) -> Result<SpreadsheetArtifactResponse, SpreadsheetArtifactError> {
|
||||
let mut state = self.state.lock().await;
|
||||
state.artifacts.spreadsheet.execute(request, cwd)
|
||||
}
|
||||
|
||||
async fn record_initial_history(&self, conversation_history: InitialHistory) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Session-wide mutable state.
|
||||
|
||||
use codex_artifact_presentation::PresentationArtifactManager;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactManager;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
|
@ -17,6 +18,12 @@ use crate::tasks::RegularTask;
|
|||
use crate::truncate::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SessionArtifacts {
|
||||
pub(crate) presentation: PresentationArtifactManager,
|
||||
pub(crate) spreadsheet: SpreadsheetArtifactManager,
|
||||
}
|
||||
|
||||
/// Persistent, session-scoped state previously stored directly on `Session`.
|
||||
pub(crate) struct SessionState {
|
||||
pub(crate) session_configuration: SessionConfiguration,
|
||||
|
|
@ -33,7 +40,7 @@ pub(crate) struct SessionState {
|
|||
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
|
||||
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
|
||||
pub(crate) active_connector_selection: HashSet<String>,
|
||||
pub(crate) presentation_artifacts: PresentationArtifactManager,
|
||||
pub(crate) artifacts: SessionArtifacts,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
|
|
@ -51,7 +58,7 @@ impl SessionState {
|
|||
startup_regular_task: None,
|
||||
active_mcp_tool_selection: None,
|
||||
active_connector_selection: HashSet::new(),
|
||||
presentation_artifacts: PresentationArtifactManager::default(),
|
||||
artifacts: SessionArtifacts::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mod read_file;
|
|||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
mod shell;
|
||||
mod spreadsheet_artifact;
|
||||
mod test_sync;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
|
@ -48,6 +49,7 @@ pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
|
|||
pub use search_tool_bm25::SearchToolBm25Handler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub use shell::ShellHandler;
|
||||
pub use spreadsheet_artifact::SpreadsheetArtifactHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
|
|
|||
235
codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs
Normal file
235
codex-rs/core/src/tools/handlers/spreadsheet_artifact.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
use async_trait::async_trait;
|
||||
use codex_artifact_spreadsheet::PathAccessKind;
|
||||
use codex_artifact_spreadsheet::PathAccessRequirement;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactError;
|
||||
use codex_artifact_spreadsheet::SpreadsheetArtifactRequest;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use serde_json::to_string;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::path_utils::resolve_symlink_write_paths;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::sandboxing::with_cached_approval;
|
||||
|
||||
pub struct SpreadsheetArtifactHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for SpreadsheetArtifactHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.enabled(Feature::Artifact) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"spreadsheet_artifact is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"spreadsheet_artifact handler received unsupported payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let request: SpreadsheetArtifactRequest = parse_arguments(&arguments)?;
|
||||
for access in request
|
||||
.required_path_accesses(&turn.cwd)
|
||||
.map_err(spreadsheet_error)?
|
||||
{
|
||||
authorize_path_access(session.as_ref(), turn.as_ref(), &call_id, &access).await?;
|
||||
}
|
||||
|
||||
let response = session
|
||||
.execute_spreadsheet_artifact(request, &turn.cwd)
|
||||
.await
|
||||
.map_err(spreadsheet_error)?;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::Text(to_string(&response).map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to serialize spreadsheet_artifact response: {error}"
|
||||
))
|
||||
})?),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spreadsheet_error(error: SpreadsheetArtifactError) -> FunctionCallError {
|
||||
FunctionCallError::RespondToModel(error.to_string())
|
||||
}
|
||||
|
||||
async fn authorize_path_access(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
call_id: &str,
|
||||
access: &PathAccessRequirement,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let effective_path = match access.kind {
|
||||
PathAccessKind::Read => effective_read_path(&access.path),
|
||||
PathAccessKind::Write => effective_write_path(&access.path),
|
||||
};
|
||||
let allowed = match access.kind {
|
||||
PathAccessKind::Read => path_is_readable(turn, &effective_path),
|
||||
PathAccessKind::Write => path_is_writable(turn, &effective_path),
|
||||
};
|
||||
if allowed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let approval_policy = turn.approval_policy.value();
|
||||
if !matches!(
|
||||
approval_policy,
|
||||
AskForApproval::OnRequest | AskForApproval::UnlessTrusted
|
||||
) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` is outside the current sandbox policy",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let key = format!(
|
||||
"spreadsheet_artifact:{:?}:{}",
|
||||
access.kind,
|
||||
effective_path.display()
|
||||
);
|
||||
let path = access.path.clone();
|
||||
let action = access.action.clone();
|
||||
let decision =
|
||||
with_cached_approval(&session.services, "spreadsheet_artifact", vec![key], || {
|
||||
let path = path.clone();
|
||||
let action = action.clone();
|
||||
async move {
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
call_id.to_string(),
|
||||
None,
|
||||
vec![
|
||||
"spreadsheet_artifact".to_string(),
|
||||
action,
|
||||
path.display().to_string(),
|
||||
],
|
||||
turn.cwd.clone(),
|
||||
Some(format!(
|
||||
"Allow spreadsheet_artifact to {} `{}`?",
|
||||
access_kind_verb(access.kind),
|
||||
path.display()
|
||||
)),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
if matches!(
|
||||
decision,
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedForSession
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
|
||||
) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"{} path `{}` was not approved",
|
||||
access_kind_label(access.kind),
|
||||
access.path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn path_is_readable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_read_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_readable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| path.starts_with(root.as_path()))
|
||||
}
|
||||
|
||||
fn path_is_writable(turn: &TurnContext, path: &Path) -> bool {
|
||||
if turn.sandbox_policy.has_full_disk_write_access() {
|
||||
return true;
|
||||
}
|
||||
|
||||
turn.sandbox_policy
|
||||
.get_writable_roots_with_cwd(&turn.cwd)
|
||||
.iter()
|
||||
.any(|root| root.is_path_writable(path))
|
||||
}
|
||||
|
||||
fn effective_read_path(path: &Path) -> PathBuf {
|
||||
normalize_for_path_comparison(path).unwrap_or_else(|_| normalize_without_fs(path))
|
||||
}
|
||||
|
||||
fn effective_write_path(path: &Path) -> PathBuf {
|
||||
let write_path = resolve_symlink_write_paths(path)
|
||||
.map(|paths| paths.write_path)
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
normalize_for_path_comparison(&write_path).unwrap_or_else(|_| normalize_without_fs(&write_path))
|
||||
}
|
||||
|
||||
fn normalize_without_fs(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::CurDir => {}
|
||||
other => normalized.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
fn access_kind_label(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read",
|
||||
PathAccessKind::Write => "write",
|
||||
}
|
||||
}
|
||||
|
||||
fn access_kind_verb(kind: PathAccessKind) -> &'static str {
|
||||
match kind {
|
||||
PathAccessKind::Read => "read from",
|
||||
PathAccessKind::Write => "write to",
|
||||
}
|
||||
}
|
||||
|
|
@ -35,8 +35,6 @@ use std::collections::HashMap;
|
|||
|
||||
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/tools/presentation_artifact.md");
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
|
||||
pub enum ShellCommandBackendConfig {
|
||||
|
|
@ -57,7 +55,7 @@ pub(crate) struct ToolsConfig {
|
|||
pub js_repl_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
pub presentation_artifact: bool,
|
||||
pub artifact_tools: bool,
|
||||
pub default_mode_request_user_input: bool,
|
||||
pub experimental_supported_tools: Vec<String>,
|
||||
pub agent_jobs_tools: bool,
|
||||
|
|
@ -87,7 +85,7 @@ impl ToolsConfig {
|
|||
let include_default_mode_request_user_input =
|
||||
features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let include_presentation_artifact = features.enabled(Feature::Artifact);
|
||||
let include_artifact_tools = features.enabled(Feature::Artifact);
|
||||
let include_agent_jobs = include_collab_tools && features.enabled(Feature::Sqlite);
|
||||
let request_permission_enabled = features.enabled(Feature::RequestPermissions);
|
||||
let shell_command_backend =
|
||||
|
|
@ -143,7 +141,7 @@ impl ToolsConfig {
|
|||
js_repl_enabled: include_js_repl,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
presentation_artifact: include_presentation_artifact,
|
||||
artifact_tools: include_artifact_tools,
|
||||
default_mode_request_user_input: include_default_mode_request_user_input,
|
||||
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
|
||||
agent_jobs_tools: include_agent_jobs,
|
||||
|
|
@ -609,7 +607,7 @@ fn create_presentation_artifact_tool() -> ToolSpec {
|
|||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "presentation_artifact".to_string(),
|
||||
description: PRESENTATION_ARTIFACT_DESCRIPTION_TEMPLATE.to_string(),
|
||||
description: "Create or edit a presentation artifact for the current thread.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
|
|
@ -619,6 +617,44 @@ fn create_presentation_artifact_tool() -> ToolSpec {
|
|||
})
|
||||
}
|
||||
|
||||
fn create_spreadsheet_artifact_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"artifact_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Artifact id returned by an earlier spreadsheet_artifact call.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"action".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Action name to run for this request.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"args".to_string(),
|
||||
JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(true.into()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "spreadsheet_artifact".to_string(),
|
||||
description: "Create or edit a spreadsheet artifact for the current thread.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["action".to_string(), "args".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_collab_input_items_schema() -> JsonSchema {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
|
|
@ -1722,6 +1758,7 @@ pub(crate) fn build_specs(
|
|||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::SpreadsheetArtifactHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
|
|
@ -1745,6 +1782,7 @@ pub(crate) fn build_specs(
|
|||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let presentation_artifact_handler = Arc::new(PresentationArtifactHandler);
|
||||
let spreadsheet_artifact_handler = Arc::new(SpreadsheetArtifactHandler);
|
||||
let request_permission_enabled = config.request_permission_enabled;
|
||||
|
||||
match &config.shell_type {
|
||||
|
|
@ -1882,9 +1920,11 @@ pub(crate) fn build_specs(
|
|||
builder.push_spec_with_parallel_support(create_view_image_tool(), true);
|
||||
builder.register_handler("view_image", view_image_handler);
|
||||
|
||||
if config.presentation_artifact {
|
||||
if config.artifact_tools {
|
||||
builder.push_spec(create_presentation_artifact_tool());
|
||||
builder.push_spec(create_spreadsheet_artifact_tool());
|
||||
builder.register_handler("presentation_artifact", presentation_artifact_handler);
|
||||
builder.register_handler("spreadsheet_artifact", spreadsheet_artifact_handler);
|
||||
}
|
||||
|
||||
if config.collab_tools {
|
||||
|
|
@ -2201,7 +2241,7 @@ mod tests {
|
|||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["presentation_artifact"]);
|
||||
assert_contains_tool_names(&tools, &["presentation_artifact", "spreadsheet_artifact"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue