Skip to main content

omni_dev/drive/slides/
read.rs

1//! Object-addressed Slides reads; ungated like the other Drive read engines.
2use 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/// Options for a full presentation fetch followed by client-side filtering.
9#[derive(Debug, Clone)]
10pub struct ReadOptions {
11    /// Presentation to fetch.
12    pub presentation_id: String,
13    /// Ordinary slide IDs; empty means all slides.
14    pub slides: Vec<String>,
15}
16
17/// One element (or table cell) with its object identities.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct SlideElement {
20    /// One-based slide position.
21    pub slide_index: usize,
22    /// Owning ordinary slide.
23    pub slide_object_id: String,
24    /// Page containing the element (may be a notes page).
25    pub page_object_id: String,
26    /// Shape, table, group, image or unknown element ID.
27    pub element_object_id: String,
28    /// Object kind; notes elements use the separate notes kind.
29    pub kind: String,
30    /// Cell grid location when this row is a table cell.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub cell: Option<CellLocation>,
33    /// Concatenated runs of one shape/cell, never neighboring objects.
34    pub text: String,
35}
36
37/// Full read result; JSONL emits one element per record.
38#[derive(Debug, Clone, Serialize)]
39pub struct ReadOutcome {
40    /// Presentation identity.
41    pub presentation_id: String,
42    /// Presentation title.
43    pub title: Option<String>,
44    /// Editor-only revision token.
45    pub revision_id: Option<String>,
46    /// Ordered object rows, including separately identified notes.
47    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
55/// Fetches the full snapshot and then applies ordinary-slide filtering.
56pub 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
67/// Validates and resolves a filter to unique ordinary-slide IDs in deck order.
68/// These same IDs constrain both the preview and the wire mutation.
69pub(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
92/// Flattens ordinary slides and optionally their notes; templates are excluded.
93pub 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}