Skip to main content

pptx_edit/
table.rs

1use pptx_parse::{GraphicFrameData, Table, TableRowSource, TableStructure};
2use serde::{Deserialize, Serialize};
3use yrs::types::{AsPrelim, text::YChange};
4use yrs::{Any, In, Map, Out, ReadTxn, Text, TextRef, Transact};
5
6use crate::deck::{map_number, map_string, map_string_array, required_map, shape_ref};
7use crate::story::{materialize_paragraph_joins, snapshot_story, story_ref};
8use crate::{DeckSession, EditCtx, EditError, EditResult, PARA_ID, STORIES, ShapeSnapshot};
9
10#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
11#[serde(rename_all = "camelCase")]
12pub enum TableInsertPosition {
13    Before,
14    After,
15}
16
17#[derive(Clone, Debug, Deserialize, Serialize)]
18#[serde(rename_all = "camelCase")]
19pub struct TableReceipt {
20    pub slide_id: String,
21    pub shape_id: String,
22    pub row_count: usize,
23    pub column_count: usize,
24    pub new_cell_refs: Vec<String>,
25}
26
27#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
28#[serde(rename_all = "camelCase")]
29pub enum TableEdit {
30    InsertRow,
31    DeleteRow,
32    InsertColumn,
33    DeleteColumn,
34}
35
36enum Action<'a> {
37    Insert(TableInsertPosition, &'a [String]),
38    Delete,
39}
40
41impl DeckSession {
42    pub fn insert_table_row(
43        &self,
44        context: &EditCtx,
45        slide_id: &str,
46        shape_id: &str,
47        index: u32,
48        position: TableInsertPosition,
49        cells: &[String],
50    ) -> EditResult<TableReceipt> {
51        self.edit_table(
52            context,
53            slide_id,
54            shape_id,
55            index,
56            true,
57            Action::Insert(position, cells),
58        )
59    }
60
61    pub fn delete_table_row(
62        &self,
63        context: &EditCtx,
64        slide_id: &str,
65        shape_id: &str,
66        index: u32,
67    ) -> EditResult<TableReceipt> {
68        self.edit_table(context, slide_id, shape_id, index, true, Action::Delete)
69    }
70
71    pub fn insert_table_column(
72        &self,
73        context: &EditCtx,
74        slide_id: &str,
75        shape_id: &str,
76        index: u32,
77        position: TableInsertPosition,
78        cells: &[String],
79    ) -> EditResult<TableReceipt> {
80        self.edit_table(
81            context,
82            slide_id,
83            shape_id,
84            index,
85            false,
86            Action::Insert(position, cells),
87        )
88    }
89
90    pub fn delete_table_column(
91        &self,
92        context: &EditCtx,
93        slide_id: &str,
94        shape_id: &str,
95        index: u32,
96    ) -> EditResult<TableReceipt> {
97        self.edit_table(context, slide_id, shape_id, index, false, Action::Delete)
98    }
99
100    /// Runs every check of a table structure edit without changing the deck.
101    pub fn check_table_edit(
102        &self,
103        edit: TableEdit,
104        slide_id: &str,
105        shape_id: &str,
106        index: u32,
107        position: TableInsertPosition,
108        cells: &[String],
109    ) -> EditResult<()> {
110        let (row_axis, action) = match edit {
111            TableEdit::InsertRow => (true, Action::Insert(position, cells)),
112            TableEdit::DeleteRow => (true, Action::Delete),
113            TableEdit::InsertColumn => (false, Action::Insert(position, cells)),
114            TableEdit::DeleteColumn => (false, Action::Delete),
115        };
116        self.plan_table_edit(
117            &EditCtx::local("check"),
118            slide_id,
119            shape_id,
120            index,
121            row_axis,
122            action,
123            false,
124        )
125        .map(|_| ())
126    }
127
128    fn edit_table(
129        &self,
130        context: &EditCtx,
131        slide_id: &str,
132        shape_id: &str,
133        index: u32,
134        row_axis: bool,
135        action: Action<'_>,
136    ) -> EditResult<TableReceipt> {
137        self.plan_table_edit(context, slide_id, shape_id, index, row_axis, action, true)?
138            .ok_or_else(|| EditError::InvalidState("table edit was not applied".into()))
139    }
140
141    #[allow(clippy::too_many_arguments)]
142    fn plan_table_edit(
143        &self,
144        context: &EditCtx,
145        slide_id: &str,
146        shape_id: &str,
147        index: u32,
148        row_axis: bool,
149        action: Action<'_>,
150        apply: bool,
151    ) -> EditResult<Option<TableReceipt>> {
152        if self.collaborative.get() {
153            return Err(EditError::CollaborativeStructureOperation);
154        }
155        let snapshot = self.snapshot()?;
156        let slide = snapshot
157            .slides
158            .iter()
159            .find(|slide| slide.id == slide_id)
160            .ok_or_else(|| EditError::SlideNotFound(slide_id.into()))?;
161        let shape = find_shape(&slide.shapes, shape_id)
162            .ok_or_else(|| EditError::ShapeNotFound(shape_id.into()))?;
163        let Some(GraphicFrameData::Table(mut table)) = shape.graphic.clone() else {
164            return Err(invalid("shape must identify a table from the outline"));
165        };
166        validate_table(&table)?;
167        validate_structure(shape)?;
168        let rows = table.rows.len();
169        let columns = table.grid.len();
170        if shape.text_stories.len() != rows * columns {
171            return Err(invalid("table cell text stories do not match its grid"));
172        }
173        let length = if row_axis { rows } else { columns };
174        let field = if row_axis { "row" } else { "column" };
175        let index = index as usize;
176        if index >= length {
177            return Err(invalid(format!(
178                "{field} index {index} is out of range; valid range is 0..{}",
179                length - 1
180            )));
181        }
182        let mut sources = shape
183            .table_structure
184            .clone()
185            .unwrap_or_else(|| TableStructure {
186                generation: String::new(),
187                story_keys: shape
188                    .text_stories
189                    .iter()
190                    .map(|story| story.id.clone())
191                    .collect(),
192                grid: table.grid.clone(),
193                columns: (0..columns).collect(),
194                copied_columns: vec![false; columns],
195                rows: table
196                    .rows
197                    .iter()
198                    .enumerate()
199                    .map(|(r, row)| TableRowSource {
200                        source_row: r,
201                        copied: false,
202                        copied_cells: vec![false; columns],
203                        height: row.height,
204                        cells: (0..columns).map(|c| [r, c]).collect(),
205                    })
206                    .collect(),
207            });
208        let mut donors: Vec<Vec<(usize, Option<&str>)>> = (0..rows)
209            .map(|r| (0..columns).map(|c| (r * columns + c, None)).collect())
210            .collect();
211        let delta = match action {
212            Action::Delete => {
213                if length == 1 {
214                    return Err(invalid(format!("cannot delete the last {field}")));
215                }
216                let delta = if row_axis {
217                    -table.rows[index].height
218                } else {
219                    -table.grid[index]
220                };
221                if row_axis {
222                    table.rows.remove(index);
223                    sources.rows.remove(index);
224                    donors.remove(index);
225                } else {
226                    table.grid.remove(index);
227                    sources.columns.remove(index);
228                    sources.copied_columns.remove(index);
229                    for ((row, source), donor) in table
230                        .rows
231                        .iter_mut()
232                        .zip(&mut sources.rows)
233                        .zip(&mut donors)
234                    {
235                        row.cells.remove(index);
236                        source.cells.remove(index);
237                        source.copied_cells.remove(index);
238                        donor.remove(index);
239                    }
240                }
241                delta
242            }
243            Action::Insert(position, cells) => {
244                let cell_count = if row_axis { columns } else { rows };
245                if cells.len() > cell_count {
246                    return Err(invalid(format!(
247                        "cells has {} entries; supply at most {cell_count}",
248                        cells.len()
249                    )));
250                }
251                for text in cells {
252                    if text.contains(['\r', '\n']) {
253                        return Err(invalid(
254                            "cells text must not contain line breaks; give each cell one line of text",
255                        ));
256                    }
257                    crate::model::validate_xml_text(text)?;
258                    if text.encode_utf16().count() > 16_000 {
259                        return Err(invalid("cells text must fit in 16000 UTF-16 units"));
260                    }
261                }
262                if rows
263                    .saturating_add(1)
264                    .saturating_mul(columns.saturating_add(1))
265                    > 100_000
266                {
267                    return Err(invalid("table would exceed the 100000-cell edit limit"));
268                }
269                let at = index + usize::from(matches!(position, TableInsertPosition::After));
270                if row_axis {
271                    let row = table.rows[index].clone();
272                    let mut source = sources.rows[index].clone();
273                    source.copied = true;
274                    source.copied_cells.fill(true);
275                    table.rows.insert(at, row);
276                    sources.rows.insert(at, source);
277                    donors.insert(
278                        at,
279                        (0..columns)
280                            .map(|c| {
281                                (
282                                    index * columns + c,
283                                    Some(cells.get(c).map_or("", String::as_str)),
284                                )
285                            })
286                            .collect(),
287                    );
288                } else {
289                    let width = table.grid[index];
290                    if width < 2 {
291                        return Err(invalid("column width must be at least 2 EMU to split it"));
292                    }
293                    table.grid[index] = width - width / 2;
294                    table.grid.insert(at, width / 2);
295                    sources.columns.insert(at, sources.columns[index]);
296                    sources.copied_columns.insert(at, true);
297                    for (r, ((row, source), donor)) in table
298                        .rows
299                        .iter_mut()
300                        .zip(&mut sources.rows)
301                        .zip(&mut donors)
302                        .enumerate()
303                    {
304                        row.cells.insert(at, row.cells[index].clone());
305                        source.cells.insert(at, source.cells[index]);
306                        source.copied_cells.insert(at, true);
307                        donor.insert(
308                            at,
309                            (
310                                r * columns + index,
311                                Some(cells.get(r).map_or("", String::as_str)),
312                            ),
313                        );
314                    }
315                }
316                if row_axis {
317                    table.rows[index].height
318                } else {
319                    0
320                }
321            }
322        };
323        sources.grid.clone_from(&table.grid);
324        let (width, height) = if row_axis {
325            (
326                shape.width,
327                resize(shape.height, delta, table.rows.iter().map(|row| row.height))?,
328            )
329        } else {
330            (
331                resize(shape.width, delta, table.grid.iter().copied())?,
332                shape.height,
333            )
334        };
335        let theme = pptx_parse::slide_theme(
336            self.package(),
337            slide.source_part_path.as_deref(),
338            slide.layout_part_path.as_deref(),
339        );
340        let txn = self.doc.transact();
341        let mut copies = Vec::new();
342        for row in &donors {
343            for &(donor, text) in row {
344                let source_id = &shape.text_stories[donor].id;
345                let key = storage_key(&txn, source_id)?;
346                let copy = if text.is_some() {
347                    let story = story_ref(&txn, source_id)?;
348                    let mut snapshot = snapshot_story(&story, &txn, source_id)?;
349                    if let Some(GraphicFrameData::Table(template)) = &shape.graphic {
350                        let body = &template.rows[donor / columns].cells[donor % columns].text;
351                        if body
352                            .paragraphs
353                            .iter()
354                            .flat_map(|p| &p.runs)
355                            .any(|run| run.field_id.is_some() || run.field_type.is_some())
356                        {
357                            return Err(invalid(
358                                "cannot copy a template cell containing text fields",
359                            ));
360                        }
361                        for (index, paragraph) in snapshot.paragraphs.iter_mut().enumerate() {
362                            if paragraph.runs.is_empty()
363                                && let Some(properties) = body
364                                    .paragraphs
365                                    .get(index)
366                                    .or_else(|| body.paragraphs.last())
367                                    .and_then(|p| {
368                                        p.runs
369                                            .first()
370                                            .map(|run| &run.properties)
371                                            .or(p.end_properties.as_ref())
372                                    })
373                            {
374                                paragraph.runs.push(crate::TextRunSnapshot {
375                                    text: String::new(),
376                                    style: crate::story::style_from_run_properties(
377                                        properties,
378                                        Some(&theme),
379                                    ),
380                                });
381                            }
382                        }
383                    }
384                    Some((In::Text(story.as_prelim(&txn)), snapshot))
385                } else {
386                    None
387                };
388                copies.push((source_id.clone(), key, text, copy));
389            }
390        }
391        let stories = required_map(&txn, STORIES)?;
392        let target = shape_ref(&txn, shape_id)?;
393        drop(txn);
394        if !apply {
395            return Ok(None);
396        }
397        let parent = shape
398            .table_structure
399            .as_ref()
400            .map_or("source", |structure| structure.generation.as_str());
401        let generation = self.next_id("table");
402        sources.generation.clone_from(&generation);
403        let ids: Vec<_> = (0..copies.len())
404            .map(|i| {
405                format!(
406                    "story:{shape_id}:table:{}:{}:{generation}",
407                    i / table.grid.len(),
408                    i % table.grid.len()
409                )
410            })
411            .collect();
412        sources.story_keys = copies
413            .iter()
414            .enumerate()
415            .map(|(i, (_, key, text, _))| {
416                if text.is_some() {
417                    format!("cell:{generation}:{i}")
418                } else {
419                    key.clone()
420                }
421            })
422            .collect();
423        let graphic_json = serde_json::to_string(&GraphicFrameData::Table(table.clone()))
424            .map_err(|error| EditError::Json(error.to_string()))?;
425        let source_json =
426            serde_json::to_string(&sources).map_err(|error| EditError::Json(error.to_string()))?;
427        let mut new_cell_refs = Vec::new();
428        let client = yrs::ClientID::new(self.client_id);
429        let start = self.doc.transact().state_vector().get(&client);
430        self.automatic_undo_barrier();
431        let mut txn = self.transact_for(context);
432        for (i, (source_id, source_key, text, copy)) in copies.into_iter().enumerate() {
433            let Some((value, snapshot)) = copy else {
434                continue;
435            };
436            let key = &sources.story_keys[i];
437            let story = stories
438                .insert(&mut txn, key.as_str(), value)
439                .cast::<TextRef>()
440                .map_err(|_| EditError::StoryNotFound(key.clone()))?;
441            materialize_paragraph_joins(&story, &mut txn, &source_id)?;
442            let prefix = format!("para:{source_key}:");
443            let mut paragraph_index = 0;
444            for diff in story.diff(&txn, YChange::identity) {
445                if let Out::YMap(marker) = diff.insert {
446                    let old = map_string(&marker, &txn, PARA_ID).unwrap_or_default();
447                    let suffix = old.strip_prefix(&prefix).unwrap_or(&old);
448                    marker.insert(&mut txn, PARA_ID, format!("para:{key}:{suffix}"));
449                    if text.is_some()
450                        && let Some(run) = snapshot.paragraphs[paragraph_index].runs.first()
451                    {
452                        marker.insert(
453                            &mut txn,
454                            "emptyStyleJson",
455                            serde_json::to_string(&run.style)
456                                .map_err(|error| EditError::Json(error.to_string()))?,
457                        );
458                    }
459                    paragraph_index += 1;
460                }
461            }
462            if let Some(text) = text {
463                let mut offset = 0;
464                let mut strings = Vec::new();
465                for diff in story.diff(&txn, YChange::identity) {
466                    match diff.insert {
467                        Out::Any(Any::String(value)) => {
468                            let length = value.encode_utf16().count() as u32;
469                            strings.push((offset, length));
470                            offset += length;
471                        }
472                        _ => offset += 1,
473                    }
474                }
475                for (start, length) in strings.into_iter().rev() {
476                    story.remove_range(&mut txn, start, length);
477                }
478                let paragraph_count = story.len(&txn);
479                if paragraph_count > 1 {
480                    story.remove_range(&mut txn, 1, paragraph_count - 1);
481                }
482                if !text.is_empty() {
483                    let style = snapshot
484                        .paragraphs
485                        .first()
486                        .and_then(|paragraph| paragraph.runs.first())
487                        .map(|run| run.style.clone())
488                        .unwrap_or_default();
489                    crate::story::insert_styled_text(&story, &mut txn, 0, text, &style);
490                }
491                let snapshot = snapshot_story(&story, &txn, key)?;
492                new_cell_refs.push(project_paragraph_id(
493                    &snapshot.paragraphs[0].id,
494                    key,
495                    &ids[i],
496                ));
497            }
498        }
499        target.insert(&mut txn, format!("tableEdit:{generation}"), parent);
500        target.insert(&mut txn, "tableGeneration", generation.as_str());
501        target.insert(&mut txn, "tableColumnCount", table.grid.len() as f64);
502        target.insert(
503            &mut txn,
504            "tableStoryKeys",
505            Any::Array(
506                sources
507                    .story_keys
508                    .iter()
509                    .map(|key| Any::from(key.as_str()))
510                    .collect::<Vec<_>>()
511                    .into(),
512            ),
513        );
514        target.insert(&mut txn, "graphicJson", graphic_json);
515        target.insert(&mut txn, "tableStructureJson", source_json);
516        target.insert(
517            &mut txn,
518            "textStories",
519            Any::Array(
520                ids.iter()
521                    .map(|id| Any::from(id.as_str()))
522                    .collect::<Vec<_>>()
523                    .into(),
524            ),
525        );
526        target.insert(&mut txn, "width", width as f64);
527        target.insert(&mut txn, "height", height as f64);
528        drop(txn);
529        self.automatic_undo_barrier();
530        let end = self.doc.transact().state_vector().get(&client);
531        if end > start {
532            let mut changes = yrs::IdSet::new();
533            changes.insert(yrs::ID::new(client, start), end - start);
534            self.undo.borrow_mut().track_table(shape_id, changes);
535        }
536        self.table_edits
537            .borrow_mut()
538            .entry(shape_id.to_owned())
539            .or_default()
540            .push(generation);
541        Ok(Some(TableReceipt {
542            slide_id: slide_id.into(),
543            shape_id: shape_id.into(),
544            row_count: table.rows.len(),
545            column_count: table.grid.len(),
546            new_cell_refs,
547        }))
548    }
549}
550
551fn resize(size: i64, delta: i64, parts: impl Iterator<Item = i64>) -> EditResult<i64> {
552    let floor = checked_sum(parts)?;
553    Ok(size
554        .checked_add(delta)
555        .filter(|size| *size <= 1_000_000_000_000_000)
556        .map_or(floor, |size| size.max(floor)))
557}
558
559fn checked_sum(mut values: impl Iterator<Item = i64>) -> EditResult<i64> {
560    values
561        .try_fold(0_i64, |sum, value| sum.checked_add(value))
562        .filter(|sum| *sum > 0 && *sum <= 1_000_000_000_000_000)
563        .ok_or_else(|| invalid("table geometry exceeds the supported range"))
564}
565
566pub(crate) fn validate_table(table: &Table) -> EditResult<()> {
567    if table.rows.is_empty()
568        || table.grid.is_empty()
569        || table.grid.iter().any(|w| *w <= 0)
570        || table
571            .rows
572            .iter()
573            .any(|row| row.height <= 0 || row.cells.len() != table.grid.len())
574    {
575        return Err(invalid(
576            "table must have a rectangular grid with positive column widths and row heights",
577        ));
578    }
579    if table
580        .rows
581        .iter()
582        .flat_map(|row| &row.cells)
583        .any(|cell| cell.merged || cell.grid_span != 1 || cell.row_span != 1)
584    {
585        return Err(invalid(
586            "merged table cells are unsupported for structural edits; unmerge the table first",
587        ));
588    }
589    Ok(())
590}
591
592fn find_shape<'a>(shapes: &'a [ShapeSnapshot], id: &str) -> Option<&'a ShapeSnapshot> {
593    shapes.iter().find_map(|shape| {
594        if shape.id == id {
595            Some(shape)
596        } else {
597            find_shape(&shape.children, id)
598        }
599    })
600}
601
602fn invalid(message: impl Into<String>) -> EditError {
603    EditError::InvalidTable(message.into())
604}
605
606pub(crate) fn source_cell(shape: &ShapeSnapshot, story_id: &str) -> Option<[usize; 2]> {
607    let pptx_parse::TextTarget::TableCell { row, cell } =
608        crate::save::text_target(story_id, &shape.id).ok()?
609    else {
610        return None;
611    };
612    match &shape.table_structure {
613        Some(structure) => structure.rows.get(row)?.cells.get(cell).copied(),
614        None => Some([row, cell]),
615    }
616}
617
618pub(crate) fn validate_structure(shape: &ShapeSnapshot) -> EditResult<()> {
619    let Some(structure) = &shape.table_structure else {
620        return Ok(());
621    };
622    let Some(GraphicFrameData::Table(table)) = &shape.graphic else {
623        return Err(invalid("table structure requires a table"));
624    };
625    validate_table(table)?;
626    if structure.grid != table.grid
627        || structure.columns.len() != table.grid.len()
628        || structure.copied_columns.len() != table.grid.len()
629        || structure.rows.len() != table.rows.len()
630        || structure.story_keys.len() != shape.text_stories.len()
631        || structure
632            .story_keys
633            .iter()
634            .collect::<std::collections::HashSet<_>>()
635            .len()
636            != structure.story_keys.len()
637        || shape.text_stories.len() != table.rows.len() * table.grid.len()
638        || structure.rows.iter().zip(&table.rows).any(|(source, row)| {
639            source.height != row.height
640                || source.cells.len() != row.cells.len()
641                || source.copied_cells.len() != row.cells.len()
642        })
643    {
644        return Err(invalid("table grid, rows and text stories must agree"));
645    }
646    for (i, story) in shape.text_stories.iter().enumerate() {
647        let expected = format!(
648            "story:{}:table:{}:{}:",
649            shape.id,
650            i / table.grid.len(),
651            i % table.grid.len()
652        );
653        if !story.id.starts_with(&expected) {
654            return Err(invalid(
655                "table text stories must match current cell positions",
656            ));
657        }
658    }
659    Ok(())
660}
661
662pub(crate) fn storage_key<T: ReadTxn>(txn: &T, story_id: &str) -> EditResult<String> {
663    let Some((shape_id, address)) = story_id
664        .strip_prefix("story:")
665        .and_then(|id| id.split_once(":table:"))
666    else {
667        return Ok(story_id.into());
668    };
669    let shape = shape_ref(txn, shape_id)?;
670    let Some(generation) = map_string(&shape, txn, "tableGeneration") else {
671        return Ok(story_id.into());
672    };
673    let missing = || EditError::StoryNotFound(story_id.into());
674    let (row, rest) = address.split_once(':').ok_or_else(missing)?;
675    let (column, suffix) = rest.split_once(':').ok_or_else(missing)?;
676    let row: usize = row.parse().map_err(|_| missing())?;
677    let column: usize = column.parse().map_err(|_| missing())?;
678    let columns = map_number(&shape, txn, "tableColumnCount")
679        .filter(|n| n.fract() == 0.0 && *n > 0.0)
680        .ok_or_else(missing)? as usize;
681    if suffix != generation
682        || column >= columns
683        || address != format!("{row}:{column}:{generation}")
684    {
685        return Err(missing());
686    }
687    let at = row
688        .checked_mul(columns)
689        .and_then(|at| at.checked_add(column))
690        .ok_or_else(missing)?;
691    let Some(Out::Any(Any::Array(keys))) = shape.get(txn, "tableStoryKeys") else {
692        return Err(missing());
693    };
694    match keys.get(at) {
695        Some(Any::String(key)) => Ok(key.to_string()),
696        _ => Err(missing()),
697    }
698}
699
700pub(crate) fn project_paragraph_id(id: &str, storage_key: &str, public_id: &str) -> String {
701    if storage_key == public_id {
702        return id.into();
703    }
704    match id.strip_prefix(&format!("para:{storage_key}:")) {
705        Some(suffix) => format!("para:{public_id}:{suffix}"),
706        None => format!("para:{public_id}:id:{id}"),
707    }
708}
709
710pub(crate) fn validate_history<T: ReadTxn>(txn: &T, shape: &ShapeSnapshot) -> EditResult<()> {
711    let Some(structure) = &shape.table_structure else {
712        return Ok(());
713    };
714    let map = shape_ref(txn, &shape.id)?;
715    if map_string(&map, txn, "tableGeneration").as_deref() != Some(structure.generation.as_str())
716        || map_number(&map, txn, "tableColumnCount") != Some(structure.grid.len() as f64)
717        || map_string_array(&map, txn, "tableStoryKeys")? != structure.story_keys
718    {
719        return Err(invalid("table story storage and geometry must agree"));
720    }
721    Ok(())
722}
723
724#[derive(Debug, Default, PartialEq)]
725pub(crate) struct EditLog {
726    generation: Option<String>,
727    pub(crate) edits: std::collections::BTreeMap<String, String>,
728}
729
730impl EditLog {
731    /// The current generation and every structural edit it descends from.
732    pub(crate) fn lineage(&self) -> std::collections::HashSet<&str> {
733        let mut lineage = std::collections::HashSet::new();
734        let mut current = self.generation.as_deref();
735        while let Some(generation) = current {
736            if !lineage.insert(generation) {
737                break;
738            }
739            current = self.edits.get(generation).map(String::as_str);
740        }
741        lineage
742    }
743}
744
745pub(crate) fn edit_log(doc: &yrs::Doc, shape_id: &str) -> EditLog {
746    let txn = doc.transact();
747    let Ok(shape) = shape_ref(&txn, shape_id) else {
748        return EditLog::default();
749    };
750    EditLog {
751        generation: map_string(&shape, &txn, "tableGeneration"),
752        edits: shape
753            .iter(&txn)
754            .filter_map(|(key, value)| {
755                let id = key.strip_prefix("tableEdit:")?;
756                let Out::Any(Any::String(parent)) = value else {
757                    return None;
758                };
759                Some((id.to_owned(), parent.to_string()))
760            })
761            .collect(),
762    }
763}