1use zenith_core::{
7 Diagnostic, Dimension, Document, KdlAdapter, KdlSource, Node, Severity, Unit, validate,
8};
9
10use crate::op::{Op, Transaction};
11use crate::result::{TxError, TxResult, TxStatus};
12
13mod asset;
14mod flags;
15mod geometry;
16mod pattern;
17mod recipe;
18pub(crate) mod structure;
19mod style;
20mod token;
21
22use asset::{apply_add_asset, apply_set_asset};
23use flags::{apply_set_locked, apply_set_points, apply_set_visible};
24use geometry::{
25 GeometryDelta, apply_align_nodes, apply_align_to_edge, apply_distribute_nodes,
26 apply_set_geometry,
27};
28use pattern::apply_detach_pattern;
29use recipe::{RecipeScalars, apply_create_recipe, apply_delete_recipe, apply_update_recipe};
30use structure::{
31 ReorderKind, apply_add_node, apply_add_page, apply_delete_page, apply_duplicate_node,
32 apply_duplicate_page, apply_group, apply_remove_node, apply_reorder, apply_reorder_pages,
33 apply_reparent, apply_set_page_size, apply_ungroup,
34};
35use style::{
36 apply_find_replace_text, apply_replace_text, apply_set_fill, apply_set_opacity,
37 apply_set_stroke, apply_set_stroke_width, apply_set_style_property, apply_set_text_align,
38 apply_set_text_direction, apply_set_text_overflow,
39};
40use token::{apply_create_token, apply_update_token_value};
41
42pub fn run_transaction(doc: &Document, tx: &Transaction) -> Result<TxResult, TxError> {
50 let adapter = KdlAdapter;
51
52 let source_before_bytes = adapter.format(doc).map_err(|e| TxError {
54 message: format!("failed to format source document: {e}"),
55 })?;
56 let source_before = String::from_utf8(source_before_bytes).map_err(|e| TxError {
57 message: format!("source_before is not valid UTF-8: {e}"),
58 })?;
59
60 let mut candidate = doc.clone();
62
63 let mut diagnostics: Vec<Diagnostic> = Vec::new();
65 let mut affected: Vec<String> = Vec::new(); for op in &tx.ops {
68 if !tx.permissions.allow_locked {
76 let mut locked_hit = false;
77 for target in op_lock_targets(op) {
78 if node_is_locked(&candidate, target) {
79 locked_hit = true;
80 diagnostics.push(Diagnostic::error(
81 "node.locked",
82 format!(
83 "node '{}' is locked; unlock it or set \
84 permissions.allow_locked to edit it",
85 target
86 ),
87 None,
88 Some(target.to_owned()),
89 ));
90 }
91 }
92 if locked_hit {
93 continue;
94 }
95 }
96
97 apply_op(op, &mut candidate, &mut diagnostics, &mut affected);
98 }
99
100 let report = validate(&candidate);
102 diagnostics.extend(report.diagnostics);
103
104 let has_errors = diagnostics.iter().any(|d| d.severity == Severity::Error);
106 let has_warnings = diagnostics.iter().any(|d| d.severity == Severity::Warning);
107
108 let (status, source_after) = if has_errors {
109 (TxStatus::Rejected, source_before.clone())
111 } else {
112 let after_bytes = adapter.format(&candidate).map_err(|e| TxError {
113 message: format!("failed to format candidate document: {e}"),
114 })?;
115 let after = String::from_utf8(after_bytes).map_err(|e| TxError {
116 message: format!("source_after is not valid UTF-8: {e}"),
117 })?;
118 let status = if has_warnings {
119 TxStatus::AcceptedWithWarnings
120 } else {
121 TxStatus::Accepted
122 };
123 (status, after)
124 };
125
126 Ok(TxResult {
127 status,
128 diagnostics,
129 source_before,
130 source_after,
131 affected_node_ids: affected,
132 })
133}
134
135fn apply_op(
138 op: &Op,
139 doc: &mut Document,
140 diagnostics: &mut Vec<Diagnostic>,
141 affected: &mut Vec<String>,
142) {
143 match op {
144 Op::SetTextAlign {
145 node: node_id,
146 align,
147 } => {
148 apply_set_text_align(node_id, align, doc, diagnostics, affected);
149 }
150 Op::MoveForward { node: node_id } => {
151 apply_reorder(node_id, ReorderKind::Forward, doc, diagnostics, affected);
152 }
153 Op::MoveBackward { node: node_id } => {
154 apply_reorder(node_id, ReorderKind::Backward, doc, diagnostics, affected);
155 }
156 Op::MoveToFront { node: node_id } => {
157 apply_reorder(node_id, ReorderKind::ToFront, doc, diagnostics, affected);
158 }
159 Op::MoveToBack { node: node_id } => {
160 apply_reorder(node_id, ReorderKind::ToBack, doc, diagnostics, affected);
161 }
162 Op::SetFill {
163 node: node_id,
164 fill,
165 } => {
166 apply_set_fill(node_id, fill, doc, diagnostics, affected);
167 }
168 Op::SetStroke {
169 node: node_id,
170 stroke,
171 } => {
172 apply_set_stroke(node_id, stroke, doc, diagnostics, affected);
173 }
174 Op::SetStrokeWidth {
175 node: node_id,
176 stroke_width,
177 } => {
178 apply_set_stroke_width(node_id, stroke_width, doc, diagnostics, affected);
179 }
180 Op::SetVisible {
181 node: node_id,
182 visible,
183 } => {
184 apply_set_visible(node_id, *visible, doc, diagnostics, affected);
185 }
186 Op::SetLocked {
187 node: node_id,
188 locked,
189 } => {
190 apply_set_locked(node_id, *locked, doc, diagnostics, affected);
191 }
192 Op::SetGeometry {
193 node: node_id,
194 x,
195 y,
196 w,
197 h,
198 rotate,
199 } => {
200 apply_set_geometry(
201 node_id,
202 GeometryDelta {
203 x: *x,
204 y: *y,
205 w: *w,
206 h: *h,
207 rotate: *rotate,
208 },
209 doc,
210 diagnostics,
211 affected,
212 );
213 }
214 Op::SetPoints {
215 node: node_id,
216 points,
217 } => {
218 apply_set_points(node_id, points, doc, diagnostics, affected);
219 }
220 Op::AddNode {
221 parent,
222 position,
223 source,
224 } => {
225 apply_add_node(parent, position, source, doc, diagnostics, affected);
226 }
227 Op::RemoveNode { node: node_id } => {
228 apply_remove_node(node_id, doc, diagnostics, affected);
229 }
230 Op::SetOpacity {
231 node: node_id,
232 opacity,
233 } => {
234 apply_set_opacity(node_id, *opacity, doc, diagnostics, affected);
235 }
236 Op::ReplaceText {
237 node: node_id,
238 spans,
239 } => {
240 apply_replace_text(node_id, spans, doc, diagnostics, affected);
241 }
242 Op::DuplicateNode {
243 node: node_id,
244 new_id,
245 } => {
246 apply_duplicate_node(node_id, new_id, doc, diagnostics, affected);
247 }
248 Op::DuplicatePage {
249 page,
250 new_id,
251 id_suffix,
252 } => {
253 apply_duplicate_page(page, new_id, id_suffix, doc, diagnostics, affected);
254 }
255 Op::Group { node_ids, group_id } => {
256 apply_group(node_ids, group_id, doc, diagnostics, affected);
257 }
258 Op::Ungroup { group_id } => {
259 apply_ungroup(group_id, doc, diagnostics, affected);
260 }
261 Op::Reparent {
262 node: node_id,
263 new_parent,
264 position,
265 } => {
266 apply_reparent(node_id, new_parent, position, doc, diagnostics, affected);
267 }
268 Op::AlignNodes {
269 node_ids,
270 align,
271 anchor,
272 } => {
273 apply_align_nodes(node_ids, align, anchor, doc, diagnostics, affected);
274 }
275 Op::SetTextOverflow { node_id, overflow } => {
276 apply_set_text_overflow(node_id, overflow, doc, diagnostics, affected);
277 }
278 Op::DistributeNodes { node_ids, axis } => {
279 apply_distribute_nodes(node_ids, axis, doc, diagnostics, affected);
280 }
281 Op::AddPage {
282 id,
283 w,
284 h,
285 background,
286 index,
287 } => {
288 let spec = structure::AddPageSpec {
289 id,
290 w,
291 h,
292 background: background.as_deref(),
293 index: *index,
294 };
295 apply_add_page(&spec, doc, diagnostics, affected);
296 }
297 Op::DeletePage { page } => {
298 apply_delete_page(page, doc, diagnostics, affected);
299 }
300 Op::ReorderPages { order } => {
301 apply_reorder_pages(order, doc, diagnostics, affected);
302 }
303 Op::AddAsset {
304 id,
305 kind,
306 src,
307 sha256,
308 } => {
309 apply_add_asset(id, kind, src, sha256.as_deref(), doc, diagnostics, affected);
310 }
311 Op::SetAsset { node_id, asset_id } => {
312 apply_set_asset(node_id, asset_id, doc, diagnostics, affected);
313 }
314 Op::CreateToken {
315 id,
316 token_type,
317 value,
318 } => {
319 apply_create_token(id, token_type, value, doc, diagnostics, affected);
320 }
321 Op::UpdateTokenValue { id, value } => {
322 apply_update_token_value(id, value, doc, diagnostics, affected);
323 }
324 Op::SetStyleProperty {
325 style_id,
326 property,
327 value,
328 } => {
329 apply_set_style_property(style_id, property, value, doc, diagnostics, affected);
330 }
331 Op::SetTextDirection { node, direction } => {
332 apply_set_text_direction(node, direction, doc, diagnostics, affected);
333 }
334 Op::FindReplaceText {
335 find,
336 replace,
337 node,
338 } => {
339 apply_find_replace_text(find, replace, node.as_deref(), doc, diagnostics, affected);
340 }
341 Op::SetPageSize { page, w, h } => {
342 apply_set_page_size(page, w, h, doc, diagnostics, affected);
343 }
344 Op::AlignToEdge { node, edge, margin } => {
345 apply_align_to_edge(node, edge, *margin, doc, diagnostics, affected);
346 }
347 Op::CreateRecipe {
348 id,
349 kind,
350 seed,
351 generator,
352 bounds,
353 detached,
354 } => {
355 apply_create_recipe(
356 RecipeScalars {
357 id,
358 kind,
359 seed: *seed,
360 generator: generator.as_deref(),
361 bounds: bounds.as_deref(),
362 detached: *detached,
363 },
364 doc,
365 diagnostics,
366 affected,
367 );
368 }
369 Op::UpdateRecipe {
370 id,
371 kind,
372 seed,
373 generator,
374 bounds,
375 detached,
376 } => {
377 apply_update_recipe(
378 RecipeScalars {
379 id,
380 kind,
381 seed: *seed,
382 generator: generator.as_deref(),
383 bounds: bounds.as_deref(),
384 detached: *detached,
385 },
386 doc,
387 diagnostics,
388 affected,
389 );
390 }
391 Op::DeleteRecipe { id } => {
392 apply_delete_recipe(id, doc, diagnostics, affected);
393 }
394 Op::DetachPattern { node: node_id } => {
395 apply_detach_pattern(node_id, doc, diagnostics, affected);
396 }
397 }
398}
399
400fn op_lock_targets(op: &Op) -> Vec<&str> {
413 match op {
414 Op::SetTextAlign { node, .. }
415 | Op::SetFill { node, .. }
416 | Op::SetStroke { node, .. }
417 | Op::SetStrokeWidth { node, .. }
418 | Op::SetGeometry { node, .. }
419 | Op::SetPoints { node, .. }
420 | Op::SetOpacity { node, .. }
421 | Op::ReplaceText { node, .. }
422 | Op::RemoveNode { node }
423 | Op::MoveForward { node }
424 | Op::MoveBackward { node }
425 | Op::MoveToFront { node }
426 | Op::MoveToBack { node }
427 | Op::Reparent { node, .. }
428 | Op::SetTextOverflow { node_id: node, .. }
429 | Op::SetTextDirection { node, .. }
430 | Op::AlignToEdge { node, .. }
431 | Op::DetachPattern { node } => vec![node.as_str()],
432 Op::FindReplaceText { node, .. } => {
435 node.as_deref().map(|n| vec![n]).unwrap_or_default()
436 }
437 Op::AlignNodes { node_ids, .. } | Op::DistributeNodes { node_ids, .. } => {
438 node_ids.iter().map(String::as_str).collect()
439 }
440 Op::SetAsset { node_id, .. } => vec![node_id.as_str()],
441 Op::SetLocked { .. }
442 | Op::SetVisible { .. }
443 | Op::AddNode { .. }
444 | Op::DuplicateNode { .. }
445 | Op::DuplicatePage { .. }
446 | Op::Group { .. }
447 | Op::Ungroup { .. }
448 | Op::AddPage { .. }
452 | Op::DeletePage { .. }
453 | Op::ReorderPages { .. }
454 | Op::SetPageSize { .. }
455 | Op::AddAsset { .. }
457 | Op::CreateToken { .. }
459 | Op::UpdateTokenValue { .. }
460 | Op::SetStyleProperty { .. }
462 | Op::CreateRecipe { .. }
464 | Op::UpdateRecipe { .. }
465 | Op::DeleteRecipe { .. } => Vec::new(),
466 }
467}
468
469fn node_is_locked(doc: &Document, id: &str) -> bool {
475 fn locked_of(node: &Node) -> Option<bool> {
476 match node {
477 Node::Rect(n) => n.locked,
478 Node::Ellipse(n) => n.locked,
479 Node::Line(n) => n.locked,
480 Node::Text(n) => n.locked,
481 Node::Code(n) => n.locked,
482 Node::Frame(n) => n.locked,
483 Node::Group(n) => n.locked,
484 Node::Image(n) => n.locked,
485 Node::Polygon(n) => n.locked,
486 Node::Polyline(n) => n.locked,
487 Node::Instance(n) => n.locked,
488 Node::Field(n) => n.locked,
489 Node::Toc(n) => n.locked,
490 Node::Table(n) => n.locked,
491 Node::Shape(n) => n.locked,
492 Node::Connector(n) => n.locked,
493 Node::Pattern(n) => n.locked,
494 Node::Chart(n) => n.locked,
495 Node::Light(n) => n.locked,
496 Node::Mesh(n) => n.locked,
497 Node::Footnote(_) => None,
499 Node::Unknown(_) => None,
500 }
501 }
502
503 doc.body
504 .pages
505 .iter()
506 .find_map(|page| find_node_shared(&page.children, id))
507 .and_then(locked_of)
508 == Some(true)
509}
510
511pub(super) fn subtree_contains(node: &Node, id: &str) -> bool {
515 if node_id_of(node) == Some(id) {
516 return true;
517 }
518 match node {
519 Node::Frame(f) => f.children.iter().any(|c| subtree_contains(c, id)),
520 Node::Group(g) => g.children.iter().any(|c| subtree_contains(c, id)),
521 Node::Table(t) => t.rows.iter().any(|r| {
522 r.cells
523 .iter()
524 .any(|c| c.children.iter().any(|ch| subtree_contains(ch, id)))
525 }),
526 Node::Unknown(u) => u.children.iter().any(|c| subtree_contains(c, id)),
527 Node::Rect(_)
528 | Node::Ellipse(_)
529 | Node::Line(_)
530 | Node::Text(_)
531 | Node::Code(_)
532 | Node::Image(_)
533 | Node::Polygon(_)
534 | Node::Polyline(_)
535 | Node::Instance(_)
536 | Node::Field(_)
537 | Node::Footnote(_)
538 | Node::Toc(_)
539 | Node::Shape(_)
540 | Node::Connector(_)
541 | Node::Pattern(_)
542 | Node::Chart(_)
543 | Node::Light(_)
544 | Node::Mesh(_) => false,
545 }
546}
547
548pub(super) fn find_node_any_mut<'doc>(doc: &'doc mut Document, id: &str) -> Option<&'doc mut Node> {
556 let page_index = doc.body.pages.iter().enumerate().find_map(|(pi, page)| {
558 let found = page.children.iter().any(|n| subtree_contains(n, id));
559 if found { Some(pi) } else { None }
560 });
561
562 match page_index {
564 None => None,
565 Some(pi) => match doc.body.pages.get_mut(pi) {
566 None => None,
567 Some(page) => find_in_children_any_mut(&mut page.children, id),
568 },
569 }
570}
571
572fn find_in_children_any_mut<'a>(children: &'a mut [Node], id: &str) -> Option<&'a mut Node> {
580 enum Hit {
584 Direct(usize),
585 Descend(usize),
586 }
587
588 let hit = children.iter().enumerate().find_map(|(i, node)| {
589 if node_id_of(node) == Some(id) {
590 return Some(Hit::Direct(i));
591 }
592 match node {
593 Node::Frame(f) if f.children.iter().any(|c| subtree_contains(c, id)) => {
594 Some(Hit::Descend(i))
595 }
596 Node::Group(g) if g.children.iter().any(|c| subtree_contains(c, id)) => {
597 Some(Hit::Descend(i))
598 }
599 Node::Table(t)
600 if t.rows.iter().any(|r| {
601 r.cells
602 .iter()
603 .any(|c| c.children.iter().any(|ch| subtree_contains(ch, id)))
604 }) =>
605 {
606 Some(Hit::Descend(i))
607 }
608 Node::Unknown(u) if u.children.iter().any(|c| subtree_contains(c, id)) => {
609 Some(Hit::Descend(i))
610 }
611 Node::Frame(_)
612 | Node::Group(_)
613 | Node::Table(_)
614 | Node::Unknown(_)
615 | Node::Rect(_)
616 | Node::Ellipse(_)
617 | Node::Line(_)
618 | Node::Text(_)
619 | Node::Code(_)
620 | Node::Image(_)
621 | Node::Polygon(_)
622 | Node::Polyline(_)
623 | Node::Instance(_)
624 | Node::Field(_)
625 | Node::Footnote(_)
626 | Node::Toc(_)
627 | Node::Shape(_)
628 | Node::Connector(_)
629 | Node::Pattern(_)
630 | Node::Chart(_)
631 | Node::Light(_)
632 | Node::Mesh(_) => None,
633 }
634 });
635
636 match hit {
638 None => None,
639 Some(Hit::Direct(i)) => children.get_mut(i),
640 Some(Hit::Descend(i)) => match children.get_mut(i) {
641 Some(Node::Frame(f)) => find_in_children_any_mut(&mut f.children, id),
642 Some(Node::Group(g)) => find_in_children_any_mut(&mut g.children, id),
643 Some(Node::Table(t)) => {
644 for row in &mut t.rows {
645 for cell in &mut row.cells {
646 if let Some(found) = find_in_children_any_mut(&mut cell.children, id) {
647 return Some(found);
648 }
649 }
650 }
651 None
652 }
653 Some(Node::Unknown(u)) => find_in_children_any_mut(&mut u.children, id),
654 Some(Node::Rect(_))
656 | Some(Node::Ellipse(_))
657 | Some(Node::Line(_))
658 | Some(Node::Text(_))
659 | Some(Node::Code(_))
660 | Some(Node::Image(_))
661 | Some(Node::Polygon(_))
662 | Some(Node::Polyline(_))
663 | Some(Node::Instance(_))
664 | Some(Node::Field(_))
665 | Some(Node::Footnote(_))
666 | Some(Node::Toc(_))
667 | Some(Node::Shape(_))
668 | Some(Node::Connector(_))
669 | Some(Node::Pattern(_))
670 | Some(Node::Chart(_))
671 | Some(Node::Light(_))
672 | Some(Node::Mesh(_))
673 | None => None,
674 },
675 }
676}
677
678pub(super) fn find_node_shared<'a>(children: &'a [Node], id: &str) -> Option<&'a Node> {
680 for node in children {
681 if node_id_of(node) == Some(id) {
682 return Some(node);
683 }
684 match node {
685 Node::Frame(f) => {
686 if let Some(found) = find_node_shared(&f.children, id) {
687 return Some(found);
688 }
689 }
690 Node::Group(g) => {
691 if let Some(found) = find_node_shared(&g.children, id) {
692 return Some(found);
693 }
694 }
695 Node::Table(t) => {
696 for row in &t.rows {
697 for cell in &row.cells {
698 if let Some(found) = find_node_shared(&cell.children, id) {
699 return Some(found);
700 }
701 }
702 }
703 }
704 Node::Unknown(u) => {
705 if let Some(found) = find_node_shared(&u.children, id) {
706 return Some(found);
707 }
708 }
709 Node::Rect(_)
710 | Node::Ellipse(_)
711 | Node::Line(_)
712 | Node::Text(_)
713 | Node::Code(_)
714 | Node::Image(_)
715 | Node::Polygon(_)
716 | Node::Polyline(_)
717 | Node::Instance(_)
718 | Node::Field(_)
719 | Node::Footnote(_)
720 | Node::Toc(_)
721 | Node::Shape(_)
722 | Node::Connector(_)
723 | Node::Pattern(_)
724 | Node::Chart(_)
725 | Node::Light(_)
726 | Node::Mesh(_) => {}
727 }
728 }
729 None
730}
731
732pub(super) fn node_id_of(node: &Node) -> Option<&str> {
734 match node {
735 Node::Rect(r) => Some(&r.id),
736 Node::Ellipse(e) => Some(&e.id),
737 Node::Line(l) => Some(&l.id),
738 Node::Text(t) => Some(&t.id),
739 Node::Code(c) => Some(&c.id),
740 Node::Frame(f) => Some(&f.id),
741 Node::Group(g) => Some(&g.id),
742 Node::Image(i) => Some(&i.id),
743 Node::Polygon(p) => Some(&p.id),
744 Node::Polyline(p) => Some(&p.id),
745 Node::Instance(i) => Some(&i.id),
746 Node::Field(f) => Some(&f.id),
747 Node::Toc(t) => Some(&t.id),
748 Node::Footnote(f) => Some(&f.id),
749 Node::Table(t) => Some(&t.id),
750 Node::Shape(s) => Some(&s.id),
751 Node::Connector(c) => Some(&c.id),
752 Node::Pattern(p) => Some(&p.id),
753 Node::Chart(c) => Some(&c.id),
754 Node::Light(l) => Some(&l.id),
755 Node::Mesh(m) => Some(&m.id),
756 Node::Unknown(u) => u.id.as_deref(),
757 }
758}
759
760pub(super) fn node_kind_str(node: &Node) -> &'static str {
764 match node {
765 Node::Rect(_) => "rect",
766 Node::Ellipse(_) => "ellipse",
767 Node::Line(_) => "line",
768 Node::Text(_) => "text",
769 Node::Code(_) => "code",
770 Node::Frame(_) => "frame",
771 Node::Group(_) => "group",
772 Node::Image(_) => "image",
773 Node::Polygon(_) => "polygon",
774 Node::Polyline(_) => "polyline",
775 Node::Instance(_) => "instance",
776 Node::Field(_) => "field",
777 Node::Toc(_) => "toc",
778 Node::Footnote(_) => "footnote",
779 Node::Table(_) => "table",
780 Node::Shape(_) => "shape",
781 Node::Connector(_) => "connector",
782 Node::Pattern(_) => "pattern",
783 Node::Chart(_) => "chart",
784 Node::Light(_) => "light",
785 Node::Mesh(_) => "mesh",
786 Node::Unknown(_) => "unknown",
787 }
788}
789
790pub(super) fn px(v: f64) -> Dimension {
792 Dimension {
793 value: v,
794 unit: Unit::Px,
795 }
796}
797
798pub(super) fn record_affected(id: &str, affected: &mut Vec<String>) {
804 if !affected.iter().any(|s| s == id) {
805 affected.push(id.to_owned());
806 }
807}