1use dspk_pipeline::Node;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use super::graph::{GraphNode, NodeKind, COLUMN};
17use super::epoch::{EpochDoc, EventSetDoc, Placement};
18use super::recipe::RecipeDoc;
19use super::session::{op_name, DisplayDoc, RecordingRef, SessionDoc};
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
22#[serde(tag = "command", rename_all = "snake_case", deny_unknown_fields)]
23pub enum SessionCommand {
24 OpenView {
26 recording: RecordingRef,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 name: Option<String>,
29 },
30 CopyView {
33 view: String,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 channels: Option<Vec<usize>>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 name: Option<String>,
38 },
39 CloseView { view: String },
41 RenameView { view: String, name: String },
42 SelectView { view: String },
44 AddOp {
46 view: String,
47 node: Node,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 index: Option<usize>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 id: Option<String>,
52 },
53 SetOp { view: String, op: String, node: Node },
55 EnableOp { view: String, op: String, enabled: bool },
56 RemoveOp { view: String, op: String },
58 MoveOp { view: String, op: String, index: usize },
60 SetDisplay { view: String, display: DisplayDoc },
61 SetPluginData { view: String, plugin: String, data: serde_json::Value },
64 AddNode {
66 kind: NodeKind,
67 #[serde(default)]
68 x: f32,
69 #[serde(default)]
70 y: f32,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 id: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 after: Option<String>,
75 },
76 RemoveNodes { nodes: Vec<String> },
78 MoveNodes { moves: Vec<NodeMove> },
80 Connect { from: String, to: String },
82 Disconnect { to: String },
84 SetNode { id: String, node: Node },
86 EnableNode { id: String, enabled: bool },
87 SetSource { id: String, recording: RecordingRef },
89 AddRecipe { recipe: RecipeDoc },
91 MakeRecipe {
93 nodes: Vec<String>,
94 name: String,
95 #[serde(default)]
96 labels: Vec<String>,
97 },
98 SetRecipeOverride { id: String, key: String, value: serde_json::Value },
100 SetRecipeOp { recipe: String, op: String, node: Node },
102 SetRecipeInfo { recipe: String, name: String, labels: Vec<String>, description: String },
104 Ungroup { id: String },
106 SetEpoch { epoch: EpochDoc },
108 RemoveEpoch { id: String },
109 SetEpochs { epochs: Vec<EpochDoc> },
111 SetEventSet { set: EventSetDoc },
113 RemoveEventSet { id: String },
114 PlaceRecording {
116 path: std::path::PathBuf,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 source: Option<String>,
119 start_sec: f64,
120 },
121 UnplaceRecording { path: std::path::PathBuf },
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
126#[serde(deny_unknown_fields)]
127pub struct NodeMove {
128 pub id: String,
129 pub x: f32,
130 pub y: f32,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum Changed {
136 Views,
138 Ops(String),
140 Display(String),
142 Plugin(String, String),
144 Graph,
146 Timeline,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
151pub enum CommandError {
152 #[error("no view {0:?}")]
153 NoView(String),
154 #[error("view {view:?} has no op {op:?}")]
155 NoOp { view: String, op: String },
156 #[error("no node {0:?}")]
157 NoNode(String),
158 #[error("{0}")]
159 Invalid(String),
160}
161
162pub fn apply(doc: &mut SessionDoc, cmd: SessionCommand) -> Result<Changed, CommandError> {
164 let mut work = doc.clone();
165 let change = apply_graph(&mut work, cmd)?;
166 work.compile();
167 *doc = work;
168 Ok(change)
169}
170
171fn apply_graph(doc: &mut SessionDoc, cmd: SessionCommand) -> Result<Changed, CommandError> {
172 use SessionCommand as C;
173 match cmd {
174 C::OpenView { recording, name } => {
175 check_channels(&recording.channels)?;
176 let name = name.unwrap_or_else(|| crate::domain::session::view_name(&recording));
177 let y = doc.graph.next_row();
178 let s = source_id(doc);
179 let v = doc.new_view_id();
180 doc.graph.nodes.push(GraphNode { id: s.clone(), x: 0.0, y, kind: NodeKind::Source { recording } });
181 doc.graph.nodes.push(GraphNode { id: v.clone(), x: COLUMN, y, kind: view_kind(name) });
182 doc.graph.connect(&s, &v).map_err(CommandError::Invalid)?;
183 doc.active_view = Some(v);
184 Ok(Changed::Views)
185 }
186 C::CopyView { view, channels, name } => {
187 let path = view_path(doc, &view)?;
188 let NodeKind::Source { recording } = node(doc, &path[0])?.kind.clone() else { unreachable!("a path starts at a source") };
189 let NodeKind::View { name: old, display, plugins } = node(doc, &view)?.kind.clone() else { unreachable!("checked by view_path") };
190 check_channels(&channels)?;
191 let y = doc.graph.next_row();
192 let source = match channels {
193 Some(subset) => {
194 if let Some(parent) = &recording.channels {
196 if let Some(c) = subset.iter().find(|c| !parent.contains(c)) {
197 return Err(CommandError::Invalid(format!("channel {c} is not read by view {view:?}")));
198 }
199 }
200 let s = source_id(doc);
201 let recording = RecordingRef { channels: Some(subset), ..recording };
202 doc.graph.nodes.push(GraphNode { id: s.clone(), x: 0.0, y, kind: NodeKind::Source { recording } });
203 s
204 }
205 None => path[0].clone(),
206 };
207 let mut last = source;
209 let mut x = COLUMN;
210 for op in &path[1..path.len() - 1] {
211 let kind = node(doc, op)?.kind.clone();
212 let NodeKind::Op { node: n, .. } = &kind else { continue };
213 let id = doc.new_op_id(n);
214 doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind });
215 doc.graph.connect(&last, &id).map_err(CommandError::Invalid)?;
216 last = id;
217 x += COLUMN;
218 }
219 let v = doc.new_view_id();
220 let name = name.unwrap_or_else(|| format!("{old} (copy)"));
221 doc.graph.nodes.push(GraphNode { id: v.clone(), x, y, kind: NodeKind::View { name, display, plugins } });
222 doc.graph.connect(&last, &v).map_err(CommandError::Invalid)?;
223 doc.active_view = Some(v);
224 Ok(Changed::Views)
225 }
226 C::CloseView { view } => {
227 view_node(doc, &view)?;
228 let mut cur = doc.graph.input(&view).map(str::to_string);
229 doc.graph.remove(&view);
230 while let Some(id) = cur {
232 if !doc.graph.outputs(&id).is_empty() {
233 break;
234 }
235 cur = doc.graph.input(&id).map(str::to_string);
236 doc.graph.remove(&id);
237 }
238 if doc.active_view.as_deref() == Some(view.as_str()) {
239 let i = doc.views.iter().position(|v| v.id == view).unwrap_or(0);
240 let rest: Vec<String> = doc.views.iter().filter(|v| v.id != view).map(|v| v.id.clone()).collect();
241 doc.active_view = rest.get(i.min(rest.len().saturating_sub(1))).cloned();
242 }
243 Ok(Changed::Views)
244 }
245 C::RenameView { view, name } => {
246 if let NodeKind::View { name: n, .. } = &mut view_node(doc, &view)?.kind {
247 *n = name;
248 }
249 Ok(Changed::Views)
250 }
251 C::SelectView { view } => {
252 view_path(doc, &view)?;
253 doc.active_view = Some(view);
254 Ok(Changed::Views)
255 }
256 C::AddOp { view, node: op, index, id } => {
257 check_processing(&op)?;
258 let path = view_path(doc, &view)?;
259 let id = match id {
260 Some(id) if doc.graph.contains(&id) || doc.view(&id).is_some() || id == "read" || id == "write" => {
261 return Err(CommandError::Invalid(format!("op id {id:?} is taken")));
262 }
263 Some(id) => id,
264 None => doc.new_op_id(&op),
265 };
266 let ops = path.len() - 2;
267 let at = index.unwrap_or(ops).min(ops);
268 let (x, y) = node(doc, &path[at + 1]).map(|n| (n.x, n.y))?;
269 for later in &path[at + 1..] {
271 if let Some(n) = doc.graph.node_mut(later) {
272 n.x += COLUMN;
273 }
274 }
275 doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind: NodeKind::Op { enabled: true, node: op } });
276 doc.graph.splice(&path[at], &path[at + 1], &id);
277 Ok(Changed::Ops(view))
278 }
279 C::SetOp { view, op, node: n } => {
280 check_processing(&n)?;
281 if let NodeKind::Op { node, .. } = &mut chain_op(doc, &view, &op)?.kind {
282 *node = n;
283 }
284 Ok(Changed::Ops(view))
285 }
286 C::EnableOp { view, op, enabled } => {
287 if let NodeKind::Op { enabled: e, .. } = &mut chain_op(doc, &view, &op)?.kind {
288 *e = enabled;
289 }
290 Ok(Changed::Ops(view))
291 }
292 C::RemoveOp { view, op } => {
293 chain_op(doc, &view, &op)?;
294 doc.graph.remove_bridging(&op);
295 Ok(Changed::Ops(view))
296 }
297 C::MoveOp { view, op, index } => {
298 chain_op(doc, &view, &op)?;
299 if doc.graph.outputs(&op).len() > 1 {
300 return Err(CommandError::Invalid(format!("{op} feeds other chains too: move it on the Pipeline canvas")));
301 }
302 doc.graph.unlink_bridging(&op);
303 let path = view_path(doc, &view)?;
304 let at = index.min(path.len() - 2);
305 doc.graph.splice(&path[at], &path[at + 1], &op);
306 Ok(Changed::Ops(view))
307 }
308 C::SetDisplay { view, display } => {
309 if display.lanes == 0 || !(display.gain.is_finite() && display.gain > 0.0) {
310 return Err(CommandError::Invalid("display: lanes ≥ 1 and gain > 0".into()));
311 }
312 if let NodeKind::View { display: d, .. } = &mut view_node(doc, &view)?.kind {
313 *d = display;
314 }
315 Ok(Changed::Display(view))
316 }
317 C::SetPluginData { view, plugin, data } => {
318 if let NodeKind::View { plugins, .. } = &mut view_node(doc, &view)?.kind {
319 if data.is_null() {
320 plugins.remove(&plugin);
321 } else {
322 plugins.insert(plugin.clone(), data);
323 }
324 }
325 Ok(Changed::Plugin(view, plugin))
326 }
327 C::AddNode { kind, x, y, id, after } => {
328 let id = match id {
329 Some(id) if doc.graph.contains(&id) || doc.view(&id).is_some() || id == "read" || id == "write" => {
330 return Err(CommandError::Invalid(format!("node id {id:?} is taken")));
331 }
332 Some(id) => id,
333 None => match &kind {
334 NodeKind::Source { .. } => source_id(doc),
335 NodeKind::Op { node, .. } => doc.new_op_id(node),
336 NodeKind::View { .. } => doc.new_view_id(),
337 NodeKind::Write {} => doc.graph.numbered_id("w", |id| doc.view(id).is_some()),
338 NodeKind::Recipe { recipe, .. } => doc.graph.fresh_id(&super::recipe::file_stem(recipe).replace('-', "_")),
339 },
340 };
341 match &kind {
342 NodeKind::Op { node, .. } => check_processing(node)?,
343 NodeKind::Recipe { recipe, .. } if !doc.recipes.contains_key(recipe) => {
344 return Err(CommandError::Invalid(format!("no recipe {recipe:?} in the session")));
345 }
346 NodeKind::Source { recording } => check_channels(&recording.channels)?,
347 _ => {}
348 }
349 doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind });
350 if let Some(from) = after {
351 doc.graph.connect(&from, &id).map_err(CommandError::Invalid)?;
352 }
353 Ok(Changed::Graph)
354 }
355 C::RemoveNodes { nodes } => {
356 for id in &nodes {
357 node(doc, id)?;
358 }
359 for id in &nodes {
360 doc.graph.remove(id);
361 }
362 Ok(Changed::Graph)
363 }
364 C::MoveNodes { moves } => {
365 for m in moves {
366 let n = doc.graph.node_mut(&m.id).ok_or_else(|| CommandError::NoNode(m.id.clone()))?;
367 if !(m.x.is_finite() && m.y.is_finite()) {
368 return Err(CommandError::Invalid("node positions are numbers".into()));
369 }
370 (n.x, n.y) = (m.x, m.y);
371 }
372 Ok(Changed::Graph)
373 }
374 C::Connect { from, to } => {
375 doc.graph.connect(&from, &to).map_err(CommandError::Invalid)?;
376 Ok(Changed::Graph)
377 }
378 C::Disconnect { to } => {
379 node(doc, &to)?;
380 doc.graph.disconnect(&to);
381 Ok(Changed::Graph)
382 }
383 C::SetNode { id, node: n } => {
384 check_processing(&n)?;
385 match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
386 NodeKind::Op { node, .. } => *node = n,
387 _ => return Err(CommandError::Invalid(format!("{id} is not an op"))),
388 }
389 Ok(Changed::Graph)
390 }
391 C::EnableNode { id, enabled } => {
392 match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
393 NodeKind::Op { enabled: e, .. } | NodeKind::Recipe { enabled: e, .. } => *e = enabled,
394 _ => return Err(CommandError::Invalid(format!("{id} is not an op"))),
395 }
396 Ok(Changed::Graph)
397 }
398 C::AddRecipe { recipe } => {
399 if recipe.name.trim().is_empty() {
400 return Err(CommandError::Invalid("a recipe needs a name".into()));
401 }
402 for op in &recipe.ops {
403 check_processing(&op.node)?;
404 }
405 doc.recipes.insert(recipe.name.clone(), recipe);
406 Ok(Changed::Graph)
407 }
408 C::MakeRecipe { nodes, name, labels } => {
409 let name = name.trim().to_string();
410 if name.is_empty() || doc.recipes.contains_key(&name) {
411 return Err(CommandError::Invalid(format!("recipe name {name:?} is empty or taken")));
412 }
413 let order = chain_order(doc, &nodes)?;
414 let ops: Vec<super::session::OpDoc> = order
415 .iter()
416 .map(|id| match &doc.graph.node(id).expect("checked").kind {
417 NodeKind::Op { enabled, node } => super::session::OpDoc { id: id.clone(), enabled: *enabled, node: node.clone() },
418 _ => unreachable!("checked: ops only"),
419 })
420 .collect();
421 let (head, tail) = (order[0].clone(), order[order.len() - 1].clone());
422 let input = doc.graph.input(&head).map(str::to_string);
423 let outputs: Vec<String> = doc.graph.outputs(&tail).into_iter().map(str::to_string).collect();
424 let (x, y) = node(doc, &head).map(|n| (n.x, n.y))?;
425 let id = doc.graph.fresh_id(&super::recipe::file_stem(&name).replace('-', "_"));
426 let mut labels = labels;
427 if labels.is_empty() {
428 let mut fams: Vec<String> = ops.iter().map(|o| super::session::op_family(&o.node).to_string()).collect();
429 fams.dedup();
430 labels = fams;
431 }
432 doc.recipes.insert(name.clone(), RecipeDoc::new(name.clone(), labels, ops));
433 for n in &order {
434 doc.graph.remove(n);
435 }
436 doc.graph.nodes.push(GraphNode { id: id.clone(), x, y, kind: NodeKind::Recipe { recipe: name, enabled: true, overrides: Default::default() } });
437 if let Some(i) = input {
438 doc.graph.connect(&i, &id).map_err(CommandError::Invalid)?;
439 }
440 for o in outputs {
441 doc.graph.connect(&id, &o).map_err(CommandError::Invalid)?;
442 }
443 Ok(Changed::Graph)
444 }
445 C::SetRecipeOverride { id, key, value } => {
446 let recipe = match &node(doc, &id)?.kind {
447 NodeKind::Recipe { recipe, .. } => recipe.clone(),
448 _ => return Err(CommandError::Invalid(format!("{id} is not a recipe"))),
449 };
450 let def = doc.recipes.get(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?.clone();
451 let Some(NodeKind::Recipe { overrides, .. }) = doc.graph.node_mut(&id).map(|n| &mut n.kind) else { unreachable!() };
452 if value.is_null() {
453 overrides.remove(&key);
454 } else {
455 overrides.insert(key.clone(), value);
456 }
457 for op in def.ops_with(overrides) {
459 check_processing(&op.node)?;
460 }
461 Ok(Changed::Graph)
462 }
463 C::SetRecipeOp { recipe, op, node: n } => {
464 check_processing(&n)?;
465 let def = doc.recipes.get_mut(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?;
466 let slot = def.ops.iter_mut().find(|o| o.id == op).ok_or_else(|| CommandError::Invalid(format!("recipe {recipe:?} has no op {op:?}")))?;
467 slot.node = n;
468 def.version += 1;
469 Ok(Changed::Graph)
470 }
471 C::SetRecipeInfo { recipe, name, labels, description } => {
472 let name = name.trim().to_string();
473 if name.is_empty() || (name != recipe && doc.recipes.contains_key(&name)) {
474 return Err(CommandError::Invalid(format!("recipe name {name:?} is empty or taken")));
475 }
476 let mut def = doc.recipes.remove(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?;
477 def.name = name.clone();
478 def.labels = labels;
479 def.description = description;
480 def.version += 1;
481 doc.recipes.insert(name.clone(), def);
482 for n in &mut doc.graph.nodes {
483 if let NodeKind::Recipe { recipe: r, .. } = &mut n.kind {
484 if *r == recipe {
485 *r = name.clone();
486 }
487 }
488 }
489 Ok(Changed::Graph)
490 }
491 C::Ungroup { id } => {
492 let (recipe, enabled, overrides, x, y) = match &node(doc, &id)?.kind {
493 NodeKind::Recipe { recipe, enabled, overrides } => (recipe.clone(), *enabled, overrides.clone(), node(doc, &id)?.x, node(doc, &id)?.y),
494 _ => return Err(CommandError::Invalid(format!("{id} is not a recipe"))),
495 };
496 let def = doc.recipes.get(&recipe).ok_or_else(|| CommandError::Invalid(format!("no recipe {recipe:?}")))?.clone();
497 let input = doc.graph.input(&id).map(str::to_string);
498 let outputs: Vec<String> = doc.graph.outputs(&id).into_iter().map(str::to_string).collect();
499 doc.graph.remove(&id);
500 let mut last = input;
501 for (k, op) in def.ops_with(&overrides).into_iter().enumerate() {
502 let nid = doc.new_op_id(&op.node);
503 doc.graph.nodes.push(GraphNode { id: nid.clone(), x: x + k as f32 * COLUMN, y, kind: NodeKind::Op { enabled: op.enabled && enabled, node: op.node } });
504 if let Some(l) = &last {
505 doc.graph.connect(l, &nid).map_err(CommandError::Invalid)?;
506 }
507 last = Some(nid);
508 }
509 if let Some(l) = last {
510 for o in outputs {
511 doc.graph.connect(&l, &o).map_err(CommandError::Invalid)?;
512 }
513 }
514 Ok(Changed::Graph)
515 }
516 C::SetEpoch { mut epoch } => {
517 if !(epoch.start_sec.is_finite() && epoch.end_sec.is_finite() && epoch.end_sec > epoch.start_sec && epoch.start_sec >= 0.0) {
518 return Err(CommandError::Invalid("an epoch ends after it starts, at or after 0 s".into()));
519 }
520 if epoch.name.trim().is_empty() {
521 epoch.name = format!("{} {}", epoch.kind, doc.epochs.len() + 1);
522 }
523 if epoch.id.is_empty() {
524 epoch.id = (1..).map(|i| format!("e{i}")).find(|id| !doc.epochs.iter().any(|e| e.id == *id)).expect("ids are unbounded");
525 }
526 match doc.epochs.iter_mut().find(|e| e.id == epoch.id) {
527 Some(e) => *e = epoch,
528 None => doc.epochs.push(epoch),
529 }
530 doc.epochs.sort_by(|a, b| a.recording.cmp(&b.recording).then(a.start_sec.total_cmp(&b.start_sec)));
531 Ok(Changed::Timeline)
532 }
533 C::RemoveEpoch { id } => {
534 let n = doc.epochs.len();
535 doc.epochs.retain(|e| e.id != id);
536 if doc.epochs.len() == n {
537 return Err(CommandError::Invalid(format!("no epoch {id:?}")));
538 }
539 Ok(Changed::Timeline)
540 }
541 C::SetEpochs { epochs } => {
542 let mut d = doc.clone();
543 for epoch in epochs {
544 apply(&mut d, C::SetEpoch { epoch })?;
545 }
546 *doc = d;
547 Ok(Changed::Timeline)
548 }
549 C::SetEventSet { mut set } => {
550 if !(set.tick_height.is_finite() && set.tick_width.is_finite() && set.hue.is_finite()) {
551 return Err(CommandError::Invalid("an event set's look must be finite".into()));
552 }
553 set.tick_height = set.tick_height.clamp(0.05, 1.0);
554 set.tick_width = set.tick_width.clamp(0.5, 8.0);
555 set.hue = set.hue.rem_euclid(360.0);
556 if set.name.trim().is_empty() {
557 set.name = format!("events {}", doc.event_sets.len() + 1);
558 }
559 if set.id.is_empty() {
560 set.id = (1..).map(|i| format!("ev{i}")).find(|id| !doc.event_sets.iter().any(|e| e.id == *id)).expect("ids are unbounded");
561 }
562 match doc.event_sets.iter_mut().find(|e| e.id == set.id) {
563 Some(e) => *e = set,
564 None => doc.event_sets.push(set),
565 }
566 Ok(Changed::Timeline)
567 }
568 C::RemoveEventSet { id } => {
569 let n = doc.event_sets.len();
570 doc.event_sets.retain(|e| e.id != id);
571 if doc.event_sets.len() == n {
572 return Err(CommandError::Invalid(format!("no event set {id:?}")));
573 }
574 Ok(Changed::Timeline)
575 }
576 C::PlaceRecording { path, source, start_sec } => {
577 if !(start_sec.is_finite() && start_sec >= 0.0) {
578 return Err(CommandError::Invalid("a recording starts at or after 0 s".into()));
579 }
580 match doc.placements.iter_mut().find(|p| p.path == path && p.source == source) {
581 Some(p) => p.start_sec = start_sec,
582 None => doc.placements.push(Placement { path, source, start_sec }),
583 }
584 Ok(Changed::Timeline)
585 }
586 C::UnplaceRecording { path } => {
587 doc.placements.retain(|p| p.path != path);
588 Ok(Changed::Timeline)
589 }
590 C::SetSource { id, recording } => {
591 check_channels(&recording.channels)?;
592 match &mut doc.graph.node_mut(&id).ok_or_else(|| CommandError::NoNode(id.clone()))?.kind {
593 NodeKind::Source { recording: r } => *r = recording,
594 _ => return Err(CommandError::Invalid(format!("{id} is not a recording"))),
595 }
596 Ok(Changed::Graph)
597 }
598 }
599}
600
601fn chain_order(doc: &SessionDoc, nodes: &[String]) -> Result<Vec<String>, CommandError> {
603 let bad = |m: &str| Err(CommandError::Invalid(format!("a recipe is made of a chain of ops: {m}")));
604 if nodes.is_empty() {
605 return bad("nothing selected");
606 }
607 for id in nodes {
608 if !matches!(node(doc, id)?.kind, NodeKind::Op { .. }) {
609 return bad(&format!("{id} is not an op"));
610 }
611 }
612 let inside = |id: &str| nodes.iter().any(|n| n == id);
613 let heads: Vec<&String> = nodes.iter().filter(|n| !doc.graph.input(n).is_some_and(inside)).collect();
614 if heads.len() != 1 {
615 return bad("the selection must be one chain");
616 }
617 let mut order = vec![heads[0].clone()];
618 while order.len() < nodes.len() {
619 let next: Vec<&str> = doc.graph.outputs(order.last().expect("non-empty")).into_iter().filter(|o| inside(o)).collect();
620 match next.as_slice() {
621 [one] if doc.graph.outputs(order.last().expect("non-empty")).len() == 1 => order.push(one.to_string()),
622 _ => return bad("the selection branches or has gaps"),
623 }
624 }
625 Ok(order)
626}
627
628pub fn view_kind(name: String) -> NodeKind {
630 NodeKind::View { name, display: DisplayDoc::default(), plugins: Default::default() }
631}
632
633fn source_id(doc: &SessionDoc) -> String {
634 doc.graph.numbered_id("s", |id| doc.view(id).is_some())
635}
636
637fn check_channels(channels: &Option<Vec<usize>>) -> Result<(), CommandError> {
638 if channels.as_ref().is_some_and(Vec::is_empty) {
639 return Err(CommandError::Invalid("a view needs at least one channel".into()));
640 }
641 Ok(())
642}
643
644fn check_processing(node: &Node) -> Result<(), CommandError> {
647 match node {
648 Node::Read { .. } | Node::Write { .. } | Node::Resample { .. } => {
649 Err(CommandError::Invalid(format!("{} is not an op of a view", op_name(node))))
650 }
651 _ => node.stage().map(|_| ()).map_err(|e| CommandError::Invalid(e.to_string())),
652 }
653}
654
655fn node<'a>(doc: &'a SessionDoc, id: &str) -> Result<&'a GraphNode, CommandError> {
656 doc.graph.node(id).ok_or_else(|| CommandError::NoNode(id.to_string()))
657}
658
659fn view_node<'a>(doc: &'a mut SessionDoc, view: &str) -> Result<&'a mut GraphNode, CommandError> {
661 match doc.graph.node_mut(view) {
662 Some(n) if matches!(n.kind, NodeKind::View { .. }) => Ok(n),
663 _ => Err(CommandError::NoView(view.to_string())),
664 }
665}
666
667fn view_path(doc: &mut SessionDoc, view: &str) -> Result<Vec<String>, CommandError> {
669 view_node(doc, view)?;
670 doc.graph.path(view).ok_or_else(|| CommandError::Invalid(format!("view {view:?} is not connected to a recording")))
671}
672
673fn chain_op<'a>(doc: &'a mut SessionDoc, view: &str, op: &str) -> Result<&'a mut GraphNode, CommandError> {
675 let path = view_path(doc, view)?;
676 if !path[1..path.len() - 1].iter().any(|n| n == op) {
677 return Err(CommandError::NoOp { view: view.to_string(), op: op.to_string() });
678 }
679 doc.graph.node_mut(op).ok_or_else(|| CommandError::NoOp { view: view.to_string(), op: op.to_string() })
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use crate::domain::session::Show;
686 use crate::domain::graph::NodeKind;
687 use dspk_pipeline::{Band, Design, Mode, Start};
688
689 fn bandpass(lo: f64, hi: f64) -> Node {
690 Node::Iir { band: Band::Bandpass, cutoff: vec![lo, hi], order: 3, design: Design::Butterworth, ripple_db: None, mode: Mode::ForwardBackward, start: Start::Rest }
691 }
692
693 fn opened() -> SessionDoc {
694 let mut d = SessionDoc::default();
695 let rec = RecordingRef { path: "/data/rec.bin".into(), source: None, channels: None };
696 assert_eq!(apply(&mut d, SessionCommand::OpenView { recording: rec, name: None }), Ok(Changed::Views));
697 d
698 }
699
700 #[test]
701 fn test_open_add_set_remove() {
702 let mut d = opened();
703 assert_eq!(d.active().unwrap().name, "rec.bin");
704 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
705 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
706 assert_eq!(d.views[0].ops.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["iir", "cmr"]);
707 apply(&mut d, SessionCommand::SetOp { view: "v1".into(), op: "iir".into(), node: bandpass(500.0, 5000.0) }).unwrap();
708 assert_eq!(d.views[0].ops[0].node, bandpass(500.0, 5000.0));
709 apply(&mut d, SessionCommand::MoveOp { view: "v1".into(), op: "cmr".into(), index: 0 }).unwrap();
710 assert_eq!(d.views[0].ops[0].id, "cmr");
711 apply(&mut d, SessionCommand::RemoveOp { view: "v1".into(), op: "cmr".into() }).unwrap();
712 assert_eq!(d.views[0].ops.len(), 1);
713 }
714
715 #[test]
718 fn test_views_are_named_by_source() {
719 let mut d = opened();
720 let emg = RecordingRef { path: "/data/meps.nwb.zarr".into(), source: Some("/acquisition/EMG".into()), channels: None };
721 apply(&mut d, SessionCommand::OpenView { recording: emg.clone(), name: None }).unwrap();
722 assert_eq!(d.active().unwrap().name, "EMG");
723 let main = RecordingRef { path: "/data/rec.zarr".into(), source: Some(dspk_io::sources::MAIN.into()), channels: None };
724 assert_eq!(crate::domain::session::view_name(&main), "rec.zarr");
725 let view = d.active_view.clone().unwrap();
726 apply(&mut d, SessionCommand::RenameView { view, name: "meps.nwb.zarr · /acquisition/EMG".into() }).unwrap();
727 let loaded = SessionDoc::from_json(&serde_json::to_string(&d).unwrap()).unwrap();
728 assert!(loaded.views.iter().any(|v| v.name == "EMG"), "{:?}", loaded.views.iter().map(|v| &v.name).collect::<Vec<_>>());
729 }
730
731 #[test]
732 fn test_invalid_commands_leave_the_document_unchanged() {
733 let mut d = opened();
734 let before = d.clone();
735 let bad_iir = Node::Iir { band: Band::Bandpass, cutoff: vec![300.0], order: 3, design: Design::Butterworth, ripple_db: None, mode: Mode::ForwardBackward, start: Start::Rest };
736 assert!(apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bad_iir, index: None, id: None }).is_err());
737 assert!(apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Car {}, index: None, id: Some("read".into()) }).is_err());
738 assert!(apply(&mut d, SessionCommand::RemoveOp { view: "v1".into(), op: "nope".into() }).is_err());
739 assert!(apply(&mut d, SessionCommand::SelectView { view: "v9".into() }).is_err());
740 let display = DisplayDoc { lanes: 0, ..DisplayDoc::default() };
741 assert!(apply(&mut d, SessionCommand::SetDisplay { view: "v1".into(), display }).is_err());
742 assert_eq!(d, before);
743 }
744
745 #[test]
746 fn test_copy_with_a_channel_subset() {
747 let mut d = opened();
748 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
749 apply(&mut d, SessionCommand::CopyView { view: "v1".into(), channels: Some(vec![4, 5]), name: None }).unwrap();
750 let copy = d.active().unwrap();
751 assert_eq!((copy.id.as_str(), copy.recording.channels.as_deref()), ("v2", Some(&[4, 5][..])));
752 let nodes = |ops: &[crate::domain::session::OpDoc]| ops.iter().map(|o| (o.enabled, o.node.clone())).collect::<Vec<_>>();
753 assert_eq!(nodes(©.ops), nodes(&d.views[0].ops), "the copy starts with the same chain");
754 assert_ne!(copy.ops[0].id, d.views[0].ops[0].id, "its own ops: edits to it leave the original alone");
755 assert!(apply(&mut d, SessionCommand::CopyView { view: "v2".into(), channels: Some(vec![6]), name: None }).is_err());
757 }
758
759 #[test]
760 fn test_close_moves_the_active_view() {
761 let mut d = opened();
762 apply(&mut d, SessionCommand::CopyView { view: "v1".into(), channels: None, name: None }).unwrap();
763 apply(&mut d, SessionCommand::CloseView { view: "v2".into() }).unwrap();
764 assert_eq!(d.active_view.as_deref(), Some("v1"));
765 apply(&mut d, SessionCommand::CloseView { view: "v1".into() }).unwrap();
766 assert_eq!(d.active_view, None);
767 }
768
769 #[test]
770 fn test_commands_are_json() {
771 let cmd: SessionCommand = serde_json::from_str(
772 r#"{"command": "set_op", "view": "v1", "op": "iir", "node": {"op": "iir", "band": "highpass", "cutoff": [250.0]}}"#,
773 )
774 .unwrap();
775 let mut d = opened();
776 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
777 assert_eq!(apply(&mut d, cmd), Ok(Changed::Ops("v1".into())));
778 let display = DisplayDoc { show: Show::Raw, ..DisplayDoc::default() };
779 assert_eq!(apply(&mut d, SessionCommand::SetDisplay { view: "v1".into(), display }), Ok(Changed::Display("v1".into())));
780 }
781
782 #[test]
783 fn test_plugin_data_is_set_and_removed() {
784 let mut d = opened();
785 let data = serde_json::json!({"sorting": "/d/rec.kilosort4.sorting.zarr"});
786 let cmd = SessionCommand::SetPluginData { view: "v1".into(), plugin: "synapse".into(), data: data.clone() };
787 assert_eq!(apply(&mut d, cmd), Ok(Changed::Plugin("v1".into(), "synapse".into())));
788 assert_eq!(d.views[0].plugins["synapse"], data);
789 let back = SessionDoc::from_json(&d.to_json()).unwrap();
790 assert_eq!(back, d);
791 apply(&mut d, SessionCommand::SetPluginData { view: "v1".into(), plugin: "synapse".into(), data: serde_json::Value::Null }).unwrap();
792 assert!(d.views[0].plugins.is_empty());
793 }
794
795 #[test]
796 fn test_graph_commands_branch_a_recording() {
797 let mut d = opened();
798 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
799 let lp: Node = serde_json::from_value(serde_json::json!({"op": "iir", "band": "lowpass", "cutoff": [300.0]})).unwrap();
801 apply(&mut d, SessionCommand::AddNode { kind: NodeKind::Op { enabled: true, node: lp }, x: 260.0, y: 150.0, id: Some("lfp_band".into()), after: Some("s1".into()) }).unwrap();
802 apply(&mut d, SessionCommand::AddNode { kind: view_kind("LFP".into()), x: 520.0, y: 150.0, id: None, after: Some("lfp_band".into()) }).unwrap();
803 assert_eq!(d.views.len(), 2);
804 assert_eq!(d.views[1].name, "LFP");
805 assert_eq!(d.views[1].ops[0].id, "lfp_band");
806 apply(&mut d, SessionCommand::EnableNode { id: "iir".into(), enabled: false }).unwrap();
808 assert!(!d.views[0].ops[0].enabled && d.views[1].ops[0].enabled);
809 apply(&mut d, SessionCommand::Disconnect { to: "v2".into() }).unwrap();
811 assert_eq!(d.views.len(), 1);
812 assert!(apply(&mut d, SessionCommand::Connect { from: "v1".into(), to: "v2".into() }).is_err(), "a view has no output");
813 apply(&mut d, SessionCommand::MoveNodes { moves: vec![NodeMove { id: "v2".into(), x: 10.0, y: 20.0 }] }).unwrap();
814 assert_eq!((d.graph.node("v2").unwrap().x, d.graph.node("v2").unwrap().y), (10.0, 20.0));
815 apply(&mut d, SessionCommand::RemoveNodes { nodes: vec!["v2".into(), "lfp_band".into()] }).unwrap();
816 assert_eq!(d.graph.nodes.len(), 3);
817 }
818
819 #[test]
820 fn test_epochs_and_placements() {
821 let mut d = opened();
822 let e = EpochDoc { id: String::new(), recording: "/data/rec.bin".into(), name: String::new(), kind: "stimulus".into(), start_sec: 2.0, end_sec: 3.5, notes: String::new() };
823 apply(&mut d, SessionCommand::SetEpoch { epoch: e.clone() }).unwrap();
824 assert_eq!((d.epochs[0].id.as_str(), d.epochs[0].name.as_str()), ("e1", "stimulus 1"));
825 assert!(apply(&mut d, SessionCommand::SetEpoch { epoch: EpochDoc { end_sec: 1.0, ..e.clone() } }).is_err(), "ends before it starts");
826 apply(&mut d, SessionCommand::SetEpoch { epoch: EpochDoc { id: "e1".into(), name: "tone".into(), ..e } }).unwrap();
827 assert_eq!((d.epochs.len(), d.epochs[0].name.as_str()), (1, "tone"));
828 apply(&mut d, SessionCommand::RemoveEpoch { id: "e1".into() }).unwrap();
829 let set = EventSetDoc { tick_height: 3.0, tick_width: 0.0, ..EventSetDoc::new("/r.bin".into(), "", "/r.dspk/events/a.parquet".into()) };
831 apply(&mut d, SessionCommand::SetEventSet { set }).unwrap();
832 let s = &d.event_sets[0];
833 assert_eq!((s.id.as_str(), s.name.as_str(), s.tick_height, s.tick_width), ("ev1", "events 1", 1.0, 0.5));
834 apply(&mut d, SessionCommand::RemoveEventSet { id: "ev1".into() }).unwrap();
835 assert!(d.event_sets.is_empty());
836 let ep = |a: f64, b: f64| EpochDoc { id: String::new(), recording: "/r.bin".into(), name: String::new(), kind: "response".into(), start_sec: a, end_sec: b, notes: String::new() };
838 assert!(apply(&mut d, SessionCommand::SetEpochs { epochs: vec![ep(1.0, 2.0), ep(3.0, 2.0)] }).is_err());
839 assert!(d.epochs.is_empty(), "nothing added");
840 apply(&mut d, SessionCommand::SetEpochs { epochs: vec![ep(1.0, 2.0), ep(3.0, 4.0)] }).unwrap();
841 assert_eq!(d.epochs.len(), 2);
842 apply(&mut d, SessionCommand::PlaceRecording { path: "/data/rec.bin".into(), source: None, start_sec: 60.0 }).unwrap();
843 apply(&mut d, SessionCommand::PlaceRecording { path: "/data/rec.bin".into(), source: None, start_sec: 90.0 }).unwrap();
844 assert_eq!((d.placements.len(), d.placements[0].start_sec), (1, 90.0));
845 let back = SessionDoc::from_json(&d.to_json()).unwrap();
846 assert_eq!(back.placements, d.placements);
847 }
848
849 #[test]
850 fn test_recipes_make_override_edit_ungroup() {
851 let mut d = opened();
852 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
853 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
854 let before = d.views[0].ops.iter().map(|o| o.node.clone()).collect::<Vec<_>>();
855 apply(&mut d, SessionCommand::MakeRecipe { nodes: vec!["cmr".into(), "iir".into()], name: "Spikes".into(), labels: vec![] }).unwrap();
856 assert_eq!(d.graph.nodes.len(), 3, "source, recipe, view");
857 assert_eq!(d.views[0].ops.iter().map(|o| o.node.clone()).collect::<Vec<_>>(), before, "the view runs the same ops");
858 assert_eq!(d.recipes["Spikes"].labels, ["filter", "reference"]);
859 apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::json!([500.0, 5000.0]) }).unwrap();
861 assert_eq!(d.views[0].ops[0].node, bandpass(500.0, 5000.0));
862 assert!(apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::json!([500.0]) }).is_err(), "a band-pass needs two cutoffs");
863 apply(&mut d, SessionCommand::SetRecipeOverride { id: "spikes".into(), key: "iir.cutoff".into(), value: serde_json::Value::Null }).unwrap();
864 apply(&mut d, SessionCommand::SetRecipeOp { recipe: "Spikes".into(), op: "iir".into(), node: bandpass(250.0, 7000.0) }).unwrap();
865 assert_eq!((d.views[0].ops[0].node.clone(), d.recipes["Spikes"].version), (bandpass(250.0, 7000.0), 2));
866 apply(&mut d, SessionCommand::Ungroup { id: "spikes".into() }).unwrap();
867 assert_eq!(d.graph.nodes.len(), 4);
868 assert_eq!(d.views[0].ops[0].node, bandpass(250.0, 7000.0));
869 assert!(apply(&mut d, SessionCommand::MakeRecipe { nodes: vec!["s1".into()], name: "x".into(), labels: vec![] }).is_err());
871 }
872
873 #[test]
874 fn test_a_shared_op_is_not_moved_by_one_view() {
875 let mut d = opened();
876 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: Node::Cmr {}, index: None, id: None }).unwrap();
877 apply(&mut d, SessionCommand::AddOp { view: "v1".into(), node: bandpass(300.0, 6000.0), index: None, id: None }).unwrap();
878 apply(&mut d, SessionCommand::AddNode { kind: view_kind("other".into()), x: 0.0, y: 0.0, id: None, after: Some("cmr".into()) }).unwrap();
879 assert!(apply(&mut d, SessionCommand::MoveOp { view: "v1".into(), op: "cmr".into(), index: 1 }).is_err());
880 assert_eq!(d.view("v2").unwrap().ops.len(), 1, "the other view keeps cmr");
881 }
882}