use super::api::SlidesApi;
use super::types::{CellLocation, Page, PageElement, Presentation};
use crate::cli::drive::format::{write_items_jsonl, JsonlSerialize};
use anyhow::Result;
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct ReadOptions {
pub presentation_id: String,
pub slides: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SlideElement {
pub slide_index: usize,
pub slide_object_id: String,
pub page_object_id: String,
pub element_object_id: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cell: Option<CellLocation>,
pub text: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReadOutcome {
pub presentation_id: String,
pub title: Option<String>,
pub revision_id: Option<String>,
pub elements: Vec<SlideElement>,
}
impl JsonlSerialize for ReadOutcome {
fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
write_items_jsonl(&self.elements, out)
}
}
pub async fn read(api: &SlidesApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
let presentation = api.get_presentation(&opts.presentation_id).await?;
let pages = selected_slide_ids(&presentation, &opts.slides)?;
Ok(ReadOutcome {
presentation_id: opts.presentation_id.clone(),
title: presentation.title.clone(),
revision_id: presentation.revision_id.clone(),
elements: flatten(&presentation, &pages, true),
})
}
pub(in crate::drive) fn selected_slide_ids(
p: &Presentation,
requested: &[String],
) -> Result<Vec<String>> {
for id in requested {
anyhow::ensure!(
!id.is_empty() && p.slides.iter().any(|page| &page.object_id == id),
"Unknown slide id '{id}'; use `omni-dev drive slides info` to list ordinary slides"
);
}
p.slides
.iter()
.filter(|page| requested.is_empty() || requested.contains(&page.object_id))
.map(|page| {
anyhow::ensure!(
!page.object_id.is_empty(),
"Slides returned an ordinary slide without an object id"
);
Ok(page.object_id.clone())
})
.collect()
}
pub fn flatten(p: &Presentation, pages: &[String], include_notes: bool) -> Vec<SlideElement> {
let mut rows = Vec::new();
for (index, slide) in p.slides.iter().enumerate() {
if !pages.contains(&slide.object_id) {
continue;
}
flatten_page(slide, index + 1, &slide.object_id, false, &mut rows);
if include_notes {
if let Some(notes) = slide
.slide_properties
.as_ref()
.and_then(|s| s.notes_page.as_deref())
{
flatten_page(notes, index + 1, &slide.object_id, true, &mut rows);
}
}
}
rows
}
fn flatten_page(page: &Page, index: usize, slide: &str, notes: bool, rows: &mut Vec<SlideElement>) {
for element in &page.page_elements {
flatten_element(element, page, index, slide, notes, rows);
}
}
fn flatten_element(
e: &PageElement,
page: &Page,
index: usize,
slide: &str,
notes: bool,
rows: &mut Vec<SlideElement>,
) {
let row = |kind: &str, text: String, cell| SlideElement {
slide_index: index,
slide_object_id: slide.into(),
page_object_id: page.object_id.clone(),
element_object_id: e.object_id.clone(),
kind: if notes { "notes" } else { kind }.into(),
cell,
text,
};
if let Some(shape) = &e.shape {
rows.push(row(
"shape",
shape
.text
.as_ref()
.map_or_else(String::new, super::types::Text::content),
None,
));
} else if let Some(table) = &e.table {
for (r, table_row) in table.table_rows.iter().enumerate() {
for (c, cell) in table_row.table_cells.iter().enumerate() {
rows.push(row(
"table-cell",
cell.text
.as_ref()
.map_or_else(String::new, super::types::Text::content),
Some(cell.location.clone().unwrap_or(CellLocation {
row_index: r,
column_index: c,
})),
));
}
}
} else if let Some(group) = &e.element_group {
rows.push(row("group", String::new(), None));
for child in &group.children {
flatten_element(child, page, index, slide, notes, rows);
}
} else {
rows.push(row(
if e.image.is_some() {
"image"
} else {
"unknown"
},
String::new(),
None,
));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn fixture() -> Presentation {
serde_json::from_value(serde_json::json!({"slides":[{"objectId":"s1","pageElements":[
{"objectId":"g","elementGroup":{"children":[{"objectId":"g2","elementGroup":{"children":[{"objectId":"shape","shape":{"text":{"textElements":[{"textRun":{"content":"Q"}},{"textRun":{"content":"3"}}]}}}]}}]}},
{"objectId":"table","table":{"tableRows":[{"tableCells":[{"location":{"rowIndex":0,"columnIndex":2},"text":{"textElements":[{"textRun":{"content":"cell"}}]}}]}]}},
{"objectId":"image","image":{}},{"objectId":"future","newElement":{}}
],"slideProperties":{"notesPage":{"objectId":"n1","pageElements":[{"objectId":"note","shape":{"text":{"textElements":[{"textRun":{"content":"speaker"}}]}}}]}}}],"layouts":[{"objectId":"layout"}]})).unwrap()
}
#[test]
fn object_graph_preserves_runs_groups_coordinates_and_notes() {
let p = fixture();
let rows = flatten(&p, &["s1".into()], true);
assert_eq!(
rows.iter().map(|r| r.kind.as_str()).collect::<Vec<_>>(),
[
"group",
"group",
"shape",
"table-cell",
"image",
"unknown",
"notes"
]
);
assert_eq!(rows[2].text, "Q3");
assert_eq!(rows[3].cell.as_ref().unwrap().column_index, 2);
assert_eq!(rows[6].page_object_id, "n1");
assert_eq!(rows[6].slide_object_id, "s1");
assert_eq!(flatten(&p, &["s1".into()], false).len(), 6);
}
#[test]
fn filters_reject_templates_notes_and_unknown_ids_and_deduplicate() {
let p = fixture();
for id in ["layout", "n1", "wrong", ""] {
assert!(selected_slide_ids(&p, &[id.into()]).is_err());
}
assert_eq!(
selected_slide_ids(&p, &["s1".into(), "s1".into()]).unwrap(),
["s1"]
);
}
#[test]
fn jsonl_emits_element_rows_and_preserves_prose() {
let p = fixture();
let outcome = ReadOutcome {
presentation_id: "p".into(),
title: None,
revision_id: None,
elements: flatten(&p, &["s1".into()], true),
};
let mut buf = Vec::new();
outcome.write_jsonl(&mut buf).unwrap();
let text = String::from_utf8(buf).unwrap();
assert_eq!(text.lines().count(), 7);
let row: serde_json::Value = serde_json::from_str(text.lines().nth(2).unwrap()).unwrap();
assert_eq!(row["text"], "Q3");
}
async fn serve_deck(server: &wiremock::MockServer) {
use wiremock::matchers::{method, path};
wiremock::Mock::given(method("GET"))
.and(path("/v1/presentations/p1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
"title": "Deck",
"revisionId": "rev-1",
"slides": [
{"objectId":"s1","pageElements":[{"objectId":"a","shape":{"text":{"textElements":[{"textRun":{"content":"hi"}}]}}}]},
{"objectId":"s2","pageElements":[]}
]
})))
.expect(1)
.mount(server)
.await;
}
#[tokio::test]
async fn read_returns_identity_and_rows_and_narrows_to_the_requested_slides() {
use crate::drive::slides::client::test_support::mock_clients;
for (filter, rows) in [
(vec![], 1),
(vec!["s1".to_string()], 1),
(vec!["s2".to_string()], 0),
] {
let server = wiremock::MockServer::start().await;
let (_drive, slides) = mock_clients(&server).await;
serve_deck(&server).await;
let outcome = read(
&SlidesApi::new(&slides),
&ReadOptions {
presentation_id: "p1".into(),
slides: filter,
},
)
.await
.unwrap();
assert_eq!(outcome.presentation_id, "p1");
assert_eq!(outcome.title.as_deref(), Some("Deck"));
assert_eq!(outcome.revision_id.as_deref(), Some("rev-1"));
assert_eq!(outcome.elements.len(), rows);
}
}
#[tokio::test]
async fn read_rejects_an_unknown_slide_filter_after_the_single_fetch() {
use crate::drive::slides::client::test_support::mock_clients;
let server = wiremock::MockServer::start().await;
let (_drive, slides) = mock_clients(&server).await;
serve_deck(&server).await;
let err = read(
&SlidesApi::new(&slides),
&ReadOptions {
presentation_id: "p1".into(),
slides: vec!["nope".into()],
},
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("nope"), "{err}");
}
}