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