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 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 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}