457 lines
17 KiB
Rust
457 lines
17 KiB
Rust
//! Existing-record child/incident editing with independent exact-field drafts.
|
|
//! Every proposal names its actual Parent or relationship endpoint slot.
|
|
use super::*;
|
|
use crate::forms::RecoveryDraft;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
|
pub enum Family {
|
|
#[default]
|
|
Child,
|
|
Source,
|
|
Target,
|
|
}
|
|
impl std::fmt::Display for Family {
|
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
formatter.write_str(match self {
|
|
Self::Child => "Owned children",
|
|
Self::Source => "Relationships at source",
|
|
Self::Target => "Relationships at target",
|
|
})
|
|
}
|
|
}
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Family(Family),
|
|
Pick(Layer, Option<Choice>),
|
|
Search(String),
|
|
Stage,
|
|
}
|
|
#[derive(Default)]
|
|
struct PairDraft {
|
|
oa: Option<Choice>,
|
|
sa: Option<Choice>,
|
|
}
|
|
#[derive(Default)]
|
|
pub struct Editor {
|
|
pub family: Family,
|
|
pub search: String,
|
|
pairs: BTreeMap<Family, PairDraft>,
|
|
pub drafts: BTreeMap<FacetSelector, Draft>,
|
|
}
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Saved {
|
|
family: Family,
|
|
pairs: Vec<(Family, Option<Id>, Option<Id>)>,
|
|
drafts: Vec<(FacetSelector, RecoveryDraft)>,
|
|
}
|
|
pub struct Proposal {
|
|
pub slot: (Id, FacetSelector),
|
|
pub path: String,
|
|
pub record: Value,
|
|
}
|
|
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
|
Some(match facet {
|
|
FacetSelector::Child(_) => Family::Child,
|
|
FacetSelector::Incident {
|
|
end: IncidentEnd::Source,
|
|
..
|
|
} => Family::Source,
|
|
FacetSelector::Incident {
|
|
end: IncidentEnd::Target,
|
|
..
|
|
} => Family::Target,
|
|
_ => return None,
|
|
})
|
|
}
|
|
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
|
family(facet).is_some()
|
|
}
|
|
fn keys(facet: &FacetSelector) -> Option<&KeyPair<Id>> {
|
|
match facet {
|
|
FacetSelector::Child(keys) | FacetSelector::Incident { keys, .. } => Some(keys),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Id> {
|
|
let keys = keys(facet)?;
|
|
if layer == Layer::Oa { keys.oa } else { keys.sa }
|
|
}
|
|
pub(super) fn explicit_pair(facet: &FacetSelector) -> Option<(Id, Id)> {
|
|
let keys = keys(facet)?;
|
|
keys.oa.zip(keys.sa)
|
|
}
|
|
pub(super) fn actual_slot(facet: &FacetSelector, layer: Layer) -> Option<(Id, FacetSelector)> {
|
|
let id = key(facet, layer)?;
|
|
Some((
|
|
id,
|
|
match family(facet)? {
|
|
Family::Child => FacetSelector::Header(HeaderFacet::Parent),
|
|
Family::Source => FacetSelector::Relation(RelationFacet::Source),
|
|
Family::Target => FacetSelector::Relation(RelationFacet::Target),
|
|
},
|
|
))
|
|
}
|
|
pub(super) fn path(project: &Project, facet: &FacetSelector, layer: Layer) -> Option<String> {
|
|
let (id, slot) = actual_slot(facet, layer)?;
|
|
if project.layer_of(id) != Some(layer) {
|
|
return None;
|
|
}
|
|
// Child keys cannot accidentally name a relationship (and vice versa).
|
|
match family(facet)? {
|
|
Family::Child if !project.elements.contains_key(&id) => return None,
|
|
Family::Source | Family::Target if !project.relations.contains_key(&id) => return None,
|
|
_ => {}
|
|
}
|
|
scalar::binding(project, id, &slot)
|
|
}
|
|
pub(super) fn label(facet: &FacetSelector) -> &'static str {
|
|
match family(facet) {
|
|
Some(Family::Child) => "Final parent of the destination child",
|
|
Some(Family::Source) => "Final source of the destination relationship",
|
|
Some(Family::Target) => "Final target of the destination relationship",
|
|
None => "Unavailable attachment",
|
|
}
|
|
}
|
|
impl Editor {
|
|
pub fn selection(&self, layer: Layer) -> Option<&Choice> {
|
|
let pair = self.pairs.get(&self.family)?;
|
|
if layer == Layer::Oa {
|
|
pair.oa.as_ref()
|
|
} else {
|
|
pair.sa.as_ref()
|
|
}
|
|
}
|
|
pub fn update(
|
|
&mut self,
|
|
project: &Project,
|
|
trace: &Trace,
|
|
event: Event,
|
|
) -> Result<Option<FacetSelector>, String> {
|
|
match event {
|
|
Event::Family(family) => self.family = family,
|
|
Event::Search(search) => self.search = search,
|
|
Event::Pick(layer, choice) => {
|
|
let pair = self.pairs.entry(self.family).or_default();
|
|
if layer == Layer::Oa {
|
|
pair.oa = choice;
|
|
} else {
|
|
pair.sa = choice;
|
|
}
|
|
}
|
|
Event::Stage => {
|
|
let keys = KeyPair {
|
|
oa: self.selection(Layer::Oa).map(|c| c.id),
|
|
sa: self.selection(Layer::Sa).map(|c| c.id),
|
|
};
|
|
let facet = match self.family {
|
|
Family::Child => FacetSelector::Child(keys),
|
|
Family::Source => FacetSelector::Incident {
|
|
keys,
|
|
end: IncidentEnd::Source,
|
|
},
|
|
Family::Target => FacetSelector::Incident {
|
|
keys,
|
|
end: IncidentEnd::Target,
|
|
},
|
|
};
|
|
for (id, expected) in [
|
|
(key(&facet, Layer::Oa), Layer::Oa),
|
|
(key(&facet, Layer::Sa), Layer::Sa),
|
|
] {
|
|
if id
|
|
.and_then(|id| project.layer_of(id))
|
|
.is_some_and(|actual| actual != expected)
|
|
{
|
|
return Err(
|
|
"Attachment identities must match their Operational or System side."
|
|
.into(),
|
|
);
|
|
}
|
|
}
|
|
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
|
return Ok(Some(facet));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
pub fn select_pair(&mut self, project: &Project, facet: &FacetSelector) -> Result<(), String> {
|
|
self.family = family(facet).ok_or("Choose a child or incident relationship address.")?;
|
|
let keys = keys(facet).expect("Closed family");
|
|
self.pairs.insert(
|
|
self.family,
|
|
PairDraft {
|
|
oa: keys.oa.map(|id| view::choice(project, id)),
|
|
sa: keys.sa.map(|id| view::choice(project, id)),
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
pub fn ensure(
|
|
&mut self,
|
|
project: &Project,
|
|
facet: &FacetSelector,
|
|
layer: Layer,
|
|
) -> Result<(), String> {
|
|
let id = key(facet, layer).ok_or("Choose an existing destination attachment identity.")?;
|
|
path(project, facet, layer).ok_or("The destination attachment record is absent or has a different kind. Creation needs an explicit transition mapping.")?;
|
|
if !self.drafts.contains_key(facet) {
|
|
self.drafts.insert(
|
|
facet.clone(),
|
|
Draft::from_project(project, id)
|
|
.ok_or("Destination attachment record is absent.")?,
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn choices(&self, project: &Project, trace: &Trace, layer: Layer) -> Vec<Choice> {
|
|
let owner = if layer == Layer::Oa {
|
|
trace.oa
|
|
} else {
|
|
trace.sa
|
|
};
|
|
let owner_kind = project.elements.get(&owner).map(Element::kind);
|
|
let mut ids: BTreeSet<_> = match self.family {
|
|
Family::Child => project
|
|
.elements
|
|
.values()
|
|
.filter(|e| {
|
|
e.layer() == layer
|
|
&& e.id != owner
|
|
&& (e.parent == Some(owner)
|
|
|| owner_kind.is_some_and(|kind| {
|
|
oa_core::validation::parent_permits(kind, e.kind())
|
|
}))
|
|
})
|
|
.map(|e| e.id)
|
|
.collect(),
|
|
Family::Source | Family::Target => project
|
|
.relations
|
|
.values()
|
|
.filter(|relation| {
|
|
if project.layer_of(relation.id) != Some(layer) {
|
|
return false;
|
|
}
|
|
let (a, b) = relation.body.endpoints();
|
|
let (current, opposite) = if self.family == Family::Source {
|
|
(a, b)
|
|
} else {
|
|
(b, a)
|
|
};
|
|
if current == owner {
|
|
return true;
|
|
}
|
|
match (owner_kind, project.elements.get(&opposite)) {
|
|
(Some(owner_kind), Some(opposite)) => {
|
|
if self.family == Family::Source {
|
|
relation.kind().permits(owner_kind, opposite.kind())
|
|
} else {
|
|
relation.kind().permits(opposite.kind(), owner_kind)
|
|
}
|
|
}
|
|
// Rare relationship-to-relationship endpoints use the ordinary
|
|
// closed property picker rather than assuming element kinds.
|
|
_ => {
|
|
let Some(draft) = Draft::from_project(project, relation.id) else {
|
|
return false;
|
|
};
|
|
let facet = FacetSelector::Relation(if self.family == Family::Source {
|
|
RelationFacet::Source
|
|
} else {
|
|
RelationFacet::Target
|
|
});
|
|
let Some(path) = scalar::binding(project, relation.id, &facet) else {
|
|
return false;
|
|
};
|
|
crate::forms::reference_choices(&draft, project, &path, Id::nil())
|
|
.iter()
|
|
.any(|choice| choice.id == owner)
|
|
}
|
|
}
|
|
})
|
|
.map(|relation| relation.id)
|
|
.collect(),
|
|
};
|
|
if let Some(selected) = self.selection(layer) {
|
|
ids.insert(selected.id);
|
|
}
|
|
let search = self.search.to_lowercase();
|
|
let mut choices: Vec<_> = ids
|
|
.into_iter()
|
|
.map(|id| view::choice(project, id))
|
|
.filter(|choice| {
|
|
search.is_empty()
|
|
|| choice.label.to_lowercase().contains(&search)
|
|
|| self
|
|
.selection(layer)
|
|
.is_some_and(|selected| selected.id == choice.id)
|
|
})
|
|
.collect();
|
|
choices.sort_by_cached_key(|choice| choice.label.to_lowercase());
|
|
choices
|
|
}
|
|
pub fn propose(
|
|
&self,
|
|
project: &Project,
|
|
trace: &Trace,
|
|
direction: TransitionDirection,
|
|
facet: &FacetSelector,
|
|
decision: SemanticDecision,
|
|
form_context: bool,
|
|
) -> Result<Proposal, String> {
|
|
let layer = direction.target_layer();
|
|
let slot = actual_slot(facet, layer)
|
|
.ok_or("Choose the destination attachment identity explicitly.")?;
|
|
let path = path(project, facet, layer).ok_or(
|
|
"Only existing destination child and relationship records are supported here.",
|
|
)?;
|
|
let mut record = Draft::from_project(project, slot.0)
|
|
.ok_or("The attached record is absent.")?
|
|
.value;
|
|
let owner = if layer == Layer::Oa {
|
|
trace.oa
|
|
} else {
|
|
trace.sa
|
|
};
|
|
let value = match decision {
|
|
SemanticDecision::KeepTarget => record.pointer(&path).expect("Closed path").clone(),
|
|
SemanticDecision::Candidate => {
|
|
let draft = self
|
|
.drafts
|
|
.get(facet)
|
|
.ok_or("Set a final attachment value first.")?;
|
|
let supplied = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
|
Ok(value) => value,
|
|
Err(_) if form_context => draft.value.clone(),
|
|
Err(error) => return Err(error),
|
|
};
|
|
supplied
|
|
.pointer(&path)
|
|
.cloned()
|
|
.ok_or("The attachment's exact field is unavailable.")?
|
|
}
|
|
SemanticDecision::TakeSource if key(facet, direction.source_layer()).is_none() => {
|
|
record.pointer(&path).expect("Closed path").clone()
|
|
}
|
|
SemanticDecision::TakeSource => {
|
|
let source = capture_facet(project, trace.oa, trace.sa, facet)?;
|
|
let present = scalar::value(&source, direction.source_layer()).and_then(|value| value.as_bool()).ok_or("Source attachment does not apply; review an existing owner correspondence.")?;
|
|
if present {
|
|
serde_json::to_value(owner).expect("Id")
|
|
} else if family(facet) == Some(Family::Child) {
|
|
if record.pointer(&path) == Some(&serde_json::to_value(owner).expect("Id")) {
|
|
Value::Null
|
|
} else {
|
|
record.pointer(&path).expect("Closed path").clone()
|
|
}
|
|
} else {
|
|
return Err("Taking an absent relationship endpoint is incomplete. Choose Set final value and an explicit replacement endpoint.".into());
|
|
}
|
|
}
|
|
};
|
|
*record.pointer_mut(&path).expect("Closed path") = value;
|
|
Ok(Proposal { slot, path, record })
|
|
}
|
|
pub fn recovery_snapshot(&self) -> Saved {
|
|
Saved {
|
|
family: self.family,
|
|
pairs: self
|
|
.pairs
|
|
.iter()
|
|
.map(|(family, pair)| {
|
|
(
|
|
*family,
|
|
pair.oa.as_ref().map(|c| c.id),
|
|
pair.sa.as_ref().map(|c| c.id),
|
|
)
|
|
})
|
|
.collect(),
|
|
drafts: self
|
|
.drafts
|
|
.iter()
|
|
.map(|(facet, draft)| (facet.clone(), draft.recovery_snapshot()))
|
|
.collect(),
|
|
}
|
|
}
|
|
pub fn from_recovery(project: &Project, layer: Layer, saved: Saved) -> Result<Self, String> {
|
|
if saved.drafts.len() > crate::source_workspace::LIMITS.model.max_records
|
|
|| saved.pairs.len() > 3
|
|
{
|
|
return Err("Recovered attachment drafts exceed the supported limits.".into());
|
|
}
|
|
let mut editor = Self {
|
|
family: saved.family,
|
|
..Self::default()
|
|
};
|
|
for (family, oa, sa) in saved.pairs {
|
|
for (id, layer) in [(oa, Layer::Oa), (sa, Layer::Sa)] {
|
|
if id
|
|
.and_then(|id| project.layer_of(id))
|
|
.is_some_and(|actual| actual != layer)
|
|
{
|
|
return Err("Recovered attachment picker has a wrong-layer identity.".into());
|
|
}
|
|
}
|
|
if editor
|
|
.pairs
|
|
.insert(
|
|
family,
|
|
PairDraft {
|
|
oa: oa.map(|id| view::choice(project, id)),
|
|
sa: sa.map(|id| view::choice(project, id)),
|
|
},
|
|
)
|
|
.is_some()
|
|
{
|
|
return Err("Recovered attachment picker family is duplicated.".into());
|
|
}
|
|
}
|
|
for (facet, saved) in saved.drafts {
|
|
for expected in [Layer::Oa, Layer::Sa] {
|
|
if key(&facet, expected)
|
|
.and_then(|id| project.layer_of(id))
|
|
.is_some_and(|actual| actual != expected)
|
|
{
|
|
return Err("Recovered attachment address has a wrong-layer identity.".into());
|
|
}
|
|
}
|
|
let (id, _) = actual_slot(&facet, layer)
|
|
.ok_or("Recovered attachment has no destination record.")?;
|
|
let path = path(project, &facet, layer)
|
|
.ok_or("Recovered attachment record has a different kind.")?;
|
|
let draft = Draft::from_recovery(project, saved)?;
|
|
let original =
|
|
Draft::from_project(project, id).ok_or("Recovered attachment is absent.")?;
|
|
let mut protected = draft.value.clone();
|
|
*protected
|
|
.pointer_mut(&path)
|
|
.ok_or("Recovered attachment field is absent.")? =
|
|
original.value.pointer(&path).expect("Closed path").clone();
|
|
if draft.original != id
|
|
|| protected != original.value
|
|
|| draft.inputs.iter().any(|(key, input)| {
|
|
key != &path
|
|
&& original
|
|
.inputs
|
|
.get(key)
|
|
.is_none_or(|old| old.raw != input.raw)
|
|
})
|
|
{
|
|
return Err(
|
|
"Recovered attachment draft changes identity, kind or an unrelated property."
|
|
.into(),
|
|
);
|
|
}
|
|
if editor.drafts.insert(facet, draft).is_some() {
|
|
return Err("Recovered attachment draft address is duplicated.".into());
|
|
}
|
|
}
|
|
Ok(editor)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "attachments_tests.rs"]
|
|
mod tests;
|