1use super::api::SlidesApi;
3use super::types::{CellLocation, Page, PageElement, Presentation};
4use crate::cli::drive::format::{write_items_jsonl, JsonlSerialize};
5use anyhow::Result;
6use serde::Serialize;
7
8#[derive(Debug, Clone)]
10pub struct ReadOptions {
11 pub presentation_id: String,
13 pub slides: Vec<String>,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct SlideElement {
20 pub slide_index: usize,
22 pub slide_object_id: String,
24 pub page_object_id: String,
26 pub element_object_id: String,
28 pub kind: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub cell: Option<CellLocation>,
33 pub text: String,
35}
36
37#[derive(Debug, Clone, Serialize)]
39pub struct ReadOutcome {
40 pub presentation_id: String,
42 pub title: Option<String>,
44 pub revision_id: Option<String>,
46 pub elements: Vec<SlideElement>,
48}
49impl JsonlSerialize for ReadOutcome {
50 fn write_jsonl(&self, out: &mut dyn std::io::Write) -> Result<()> {
51 write_items_jsonl(&self.elements, out)
52 }
53}
54
55pub async fn read(api: &SlidesApi<'_>, opts: &ReadOptions) -> Result<ReadOutcome> {
57 let presentation = api.get_presentation(&opts.presentation_id).await?;
58 let pages = selected_slide_ids(&presentation, &opts.slides)?;
59 Ok(ReadOutcome {
60 presentation_id: opts.presentation_id.clone(),
61 title: presentation.title.clone(),
62 revision_id: presentation.revision_id.clone(),
63 elements: flatten(&presentation, &pages, true),
64 })
65}
66
67pub(in crate::drive) fn selected_slide_ids(
70 p: &Presentation,
71 requested: &[String],
72) -> Result<Vec<String>> {
73 for id in requested {
74 anyhow::ensure!(
75 !id.is_empty() && p.slides.iter().any(|page| &page.object_id == id),
76 "Unknown slide id '{id}'; use `omni-dev drive slides info` to list ordinary slides"
77 );
78 }
79 p.slides
80 .iter()
81 .filter(|page| requested.is_empty() || requested.contains(&page.object_id))
82 .map(|page| {
83 anyhow::ensure!(
84 !page.object_id.is_empty(),
85 "Slides returned an ordinary slide without an object id"
86 );
87 Ok(page.object_id.clone())
88 })
89 .collect()
90}
91
92pub fn flatten(p: &Presentation, pages: &[String], include_notes: bool) -> Vec<SlideElement> {
94 let mut rows = Vec::new();
95 for (index, slide) in p.slides.iter().enumerate() {
96 if !pages.contains(&slide.object_id) {
97 continue;
98 }
99 flatten_page(slide, index + 1, &slide.object_id, false, &mut rows);
100 if include_notes {
101 if let Some(notes) = slide
102 .slide_properties
103 .as_ref()
104 .and_then(|s| s.notes_page.as_deref())
105 {
106 flatten_page(notes, index + 1, &slide.object_id, true, &mut rows);
107 }
108 }
109 }
110 rows
111}
112
113fn flatten_page(page: &Page, index: usize, slide: &str, notes: bool, rows: &mut Vec<SlideElement>) {
114 for element in &page.page_elements {
115 flatten_element(element, page, index, slide, notes, rows);
116 }
117}
118
119fn flatten_element(
120 e: &PageElement,
121 page: &Page,
122 index: usize,
123 slide: &str,
124 notes: bool,
125 rows: &mut Vec<SlideElement>,
126) {
127 let row = |kind: &str, text: String, cell| SlideElement {
128 slide_index: index,
129 slide_object_id: slide.into(),
130 page_object_id: page.object_id.clone(),
131 element_object_id: e.object_id.clone(),
132 kind: if notes { "notes" } else { kind }.into(),
133 cell,
134 text,
135 };
136 if let Some(shape) = &e.shape {
137 rows.push(row(
138 "shape",
139 shape
140 .text
141 .as_ref()
142 .map_or_else(String::new, super::types::Text::content),
143 None,
144 ));
145 } else if let Some(table) = &e.table {
146 for (r, table_row) in table.table_rows.iter().enumerate() {
147 for (c, cell) in table_row.table_cells.iter().enumerate() {
148 rows.push(row(
149 "table-cell",
150 cell.text
151 .as_ref()
152 .map_or_else(String::new, super::types::Text::content),
153 Some(cell.location.clone().unwrap_or(CellLocation {
154 row_index: r,
155 column_index: c,
156 })),
157 ));
158 }
159 }
160 } else if let Some(group) = &e.element_group {
161 rows.push(row("group", String::new(), None));
162 for child in &group.children {
163 flatten_element(child, page, index, slide, notes, rows);
164 }
165 } else {
166 rows.push(row(
167 if e.image.is_some() {
168 "image"
169 } else {
170 "unknown"
171 },
172 String::new(),
173 None,
174 ));
175 }
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used, clippy::expect_used)]
180mod tests {
181 use super::*;
182 fn fixture() -> Presentation {
183 serde_json::from_value(serde_json::json!({"slides":[{"objectId":"s1","pageElements":[
184 {"objectId":"g","elementGroup":{"children":[{"objectId":"g2","elementGroup":{"children":[{"objectId":"shape","shape":{"text":{"textElements":[{"textRun":{"content":"Q"}},{"textRun":{"content":"3"}}]}}}]}}]}},
185 {"objectId":"table","table":{"tableRows":[{"tableCells":[{"location":{"rowIndex":0,"columnIndex":2},"text":{"textElements":[{"textRun":{"content":"cell"}}]}}]}]}},
186 {"objectId":"image","image":{}},{"objectId":"future","newElement":{}}
187 ],"slideProperties":{"notesPage":{"objectId":"n1","pageElements":[{"objectId":"note","shape":{"text":{"textElements":[{"textRun":{"content":"speaker"}}]}}}]}}}],"layouts":[{"objectId":"layout"}]})).unwrap()
188 }
189 #[test]
190 fn object_graph_preserves_runs_groups_coordinates_and_notes() {
191 let p = fixture();
192 let rows = flatten(&p, &["s1".into()], true);
193 assert_eq!(
194 rows.iter().map(|r| r.kind.as_str()).collect::<Vec<_>>(),
195 [
196 "group",
197 "group",
198 "shape",
199 "table-cell",
200 "image",
201 "unknown",
202 "notes"
203 ]
204 );
205 assert_eq!(rows[2].text, "Q3");
206 assert_eq!(rows[3].cell.as_ref().unwrap().column_index, 2);
207 assert_eq!(rows[6].page_object_id, "n1");
208 assert_eq!(rows[6].slide_object_id, "s1");
209 assert_eq!(flatten(&p, &["s1".into()], false).len(), 6);
210 }
211 #[test]
212 fn filters_reject_templates_notes_and_unknown_ids_and_deduplicate() {
213 let p = fixture();
214 for id in ["layout", "n1", "wrong", ""] {
215 assert!(selected_slide_ids(&p, &[id.into()]).is_err());
216 }
217 assert_eq!(
218 selected_slide_ids(&p, &["s1".into(), "s1".into()]).unwrap(),
219 ["s1"]
220 );
221 }
222 #[test]
223 fn jsonl_emits_element_rows_and_preserves_prose() {
224 let p = fixture();
225 let outcome = ReadOutcome {
226 presentation_id: "p".into(),
227 title: None,
228 revision_id: None,
229 elements: flatten(&p, &["s1".into()], true),
230 };
231 let mut buf = Vec::new();
232 outcome.write_jsonl(&mut buf).unwrap();
233 let text = String::from_utf8(buf).unwrap();
234 assert_eq!(text.lines().count(), 7);
235 let row: serde_json::Value = serde_json::from_str(text.lines().nth(2).unwrap()).unwrap();
236 assert_eq!(row["text"], "Q3");
237 }
238
239 async fn serve_deck(server: &wiremock::MockServer) {
240 use wiremock::matchers::{method, path};
241 wiremock::Mock::given(method("GET"))
242 .and(path("/v1/presentations/p1"))
243 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
244 "title": "Deck",
245 "revisionId": "rev-1",
246 "slides": [
247 {"objectId":"s1","pageElements":[{"objectId":"a","shape":{"text":{"textElements":[{"textRun":{"content":"hi"}}]}}}]},
248 {"objectId":"s2","pageElements":[]}
249 ]
250 })))
251 .expect(1)
252 .mount(server)
253 .await;
254 }
255 #[tokio::test]
256 async fn read_returns_identity_and_rows_and_narrows_to_the_requested_slides() {
257 use crate::drive::slides::client::test_support::mock_clients;
258 for (filter, rows) in [
259 (vec![], 1),
260 (vec!["s1".to_string()], 1),
261 (vec!["s2".to_string()], 0),
262 ] {
263 let server = wiremock::MockServer::start().await;
264 let (_drive, slides) = mock_clients(&server).await;
265 serve_deck(&server).await;
266 let outcome = read(
267 &SlidesApi::new(&slides),
268 &ReadOptions {
269 presentation_id: "p1".into(),
270 slides: filter,
271 },
272 )
273 .await
274 .unwrap();
275 assert_eq!(outcome.presentation_id, "p1");
276 assert_eq!(outcome.title.as_deref(), Some("Deck"));
277 assert_eq!(outcome.revision_id.as_deref(), Some("rev-1"));
278 assert_eq!(outcome.elements.len(), rows);
279 }
280 }
281 #[tokio::test]
282 async fn read_rejects_an_unknown_slide_filter_after_the_single_fetch() {
283 use crate::drive::slides::client::test_support::mock_clients;
284 let server = wiremock::MockServer::start().await;
285 let (_drive, slides) = mock_clients(&server).await;
286 serve_deck(&server).await;
287 let err = read(
288 &SlidesApi::new(&slides),
289 &ReadOptions {
290 presentation_id: "p1".into(),
291 slides: vec!["nope".into()],
292 },
293 )
294 .await
295 .unwrap_err()
296 .to_string();
297 assert!(err.contains("nope"), "{err}");
298 }
299}