1use ravenrs_criteria::{Criteria, Scope, Value};
5
6use crate::space::key_text;
7use crate::{
8 Action, ActionCategory, CriteriaMode, EntityInfo, Module, Object, Operation, Query, Selection,
9 SpaceError, ViewController,
10};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14#[non_exhaustive]
15#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
16pub struct Transition {
17 pub from: String,
19 pub to: String,
21 pub caption: String,
23 pub when: Option<String>,
25}
26
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
46#[non_exhaustive]
47#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
48pub struct StateMachine {
49 pub field: String,
51 pub states: Vec<(String, String)>,
53 pub start: Option<String>,
55 pub transitions: Vec<Transition>,
57}
58
59#[cfg_attr(feature = "live", ravenrs_live::expose)]
60impl StateMachine {
61 pub fn new(field: impl Into<String>) -> Self {
63 Self { field: field.into(), ..Self::default() }
64 }
65
66 pub fn state(mut self, code: impl Into<String>, caption: impl Into<String>) -> Self {
68 self.states.push((code.into(), caption.into()));
69 self
70 }
71
72 pub fn start(mut self, code: impl Into<String>) -> Self {
74 self.start = Some(code.into());
75 self
76 }
77
78 pub fn transition(
80 mut self,
81 from: impl Into<String>,
82 to: impl Into<String>,
83 caption: impl Into<String>,
84 ) -> Self {
85 self.transitions.push(Transition {
86 from: from.into(),
87 to: to.into(),
88 caption: caption.into(),
89 when: None,
90 });
91 self
92 }
93
94 pub fn when(mut self, expression: impl Into<String>) -> Self {
96 if let Some(last) = self.transitions.last_mut() {
97 last.when = Some(expression.into());
98 }
99 self
100 }
101
102 fn caption_of<'a>(&'a self, code: &'a str) -> &'a str {
103 self.states.iter().find(|(known, _)| known == code).map_or(code, |(_, caption)| caption)
104 }
105
106 pub(crate) fn problem(&self) -> Option<String> {
107 let known = |code: &str| self.states.iter().any(|(state, _)| state == code);
108 if self.states.is_empty() {
109 return Some(format!("the states of {} are not declared", self.field));
110 }
111 if let Some(start) = self.start.as_deref().filter(|start| !known(start)) {
112 return Some(format!("{} starts in {start}, which is not one of its states", self.field));
113 }
114 self.transitions.iter().find_map(|transition| {
115 [&transition.from, &transition.to]
116 .into_iter()
117 .find(|state| !known(state))
118 .map(|state| format!("{} goes through {state}, which is not one of its states", self.field))
119 .or_else(|| {
120 let expression = transition.when.as_deref()?;
121 Criteria::parse(expression).err().map(|error| format!("{}: {error}", self.field))
122 })
123 })
124 }
125
126 fn check(&self, entity: &EntityInfo, old: Option<&Object>, new: &Object) -> Result<(), SpaceError> {
129 let text = |object: &Object| key_text(&object.get(&self.field)).unwrap_or_default();
130 let to = text(new);
131 let refused = |why: String| Err(SpaceError::Refused(format!("{}: {why}", entity.caption)));
132 let Some(old) = old else {
133 if !self.states.iter().any(|(state, _)| *state == to) {
134 return refused(format!("{to} is not a state"));
135 }
136 return match &self.start {
137 Some(start) if *start != to => {
138 refused(format!("a new record starts in {}", self.caption_of(start)))
139 }
140 _ => Ok(()),
141 };
142 };
143 let from = text(old);
144 if from == to {
145 return Ok(());
146 }
147 let environment = crate::environment();
148 let mut after = new.clone();
149 entity.compute(&mut after, &environment);
150 let opens = self.transitions.iter().filter(|way| way.from == from && way.to == to).any(|way| {
151 way.when.as_deref().is_none_or(|expression| {
152 Criteria::parse(expression)
154 .ok()
155 .is_some_and(|criteria| Scope::new(&environment).row(&after).matches(&criteria))
156 })
157 });
158 if opens {
159 Ok(())
160 } else {
161 refused(format!("it cannot go from {} to {}", self.caption_of(&from), self.caption_of(&to)))
162 }
163 }
164}
165
166pub fn check_transitions(entity: &EntityInfo, old: Option<&Object>, new: &Object) -> Result<(), SpaceError> {
169 entity.state_machines.iter().try_for_each(|machine| machine.check(entity, old, new))
170}
171
172#[derive(Clone, Debug, PartialEq, Eq)]
174#[non_exhaustive]
175#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
176pub struct Reminder {
177 pub field: String,
179 pub message: Option<String>,
181 pub criteria: Option<String>,
183}
184
185#[cfg_attr(feature = "live", ravenrs_live::expose)]
186impl Reminder {
187 pub fn new(field: impl Into<String>) -> Self {
189 Self { field: field.into(), message: None, criteria: None }
190 }
191
192 pub fn message(mut self, field: impl Into<String>) -> Self {
194 self.message = Some(field.into());
195 self
196 }
197
198 pub fn criteria(mut self, expression: impl Into<String>) -> Self {
200 self.criteria = Some(expression.into());
201 self
202 }
203}
204
205pub fn local_now() -> ravenrs_criteria::DateTime {
207 #[cfg(target_arch = "wasm32")]
208 {
209 ravenrs_criteria::DateTime::now()
210 }
211 #[cfg(not(target_arch = "wasm32"))]
212 {
213 let utc = ravenrs_criteria::DateTime::now();
214 let offset = i64::from(jiff::tz::TimeZone::system().to_offset(jiff::Timestamp::now()).seconds());
215 let seconds = i64::from(utc.seconds) + offset;
216 ravenrs_criteria::DateTime {
217 days: utc.days + seconds.div_euclid(86_400),
218 seconds: seconds.rem_euclid(86_400) as u32,
219 }
220 }
221}
222
223struct Transitions {
224 actions: Vec<Action>,
225}
226
227impl ViewController for Transitions {
228 fn id(&self) -> &str {
229 "StateTransitions"
230 }
231
232 fn actions(&self) -> Vec<Action> {
233 self.actions.clone()
234 }
235}
236
237fn action_of(entity: &EntityInfo, machine: &StateMachine, way: &Transition) -> Action {
238 let (field, to) = (machine.field.clone(), way.to.clone());
239 let key = entity.key_field().map(|key| key.name.clone()).unwrap_or_default();
240 let name = entity.name.clone();
241 let id = format!("{}.{}.{}.{}", entity.name, machine.field, way.from, way.to);
242 let mut opens = format!("[{field}] = \'{}\'", way.from.replace('\'', "\'\'"));
243 if let Some(when) = &way.when {
244 opens = format!("({opens}) And ({when})");
245 }
246 Action::simple(id, way.caption.clone())
247 .category(ActionCategory::Edit)
248 .target_entity(entity.name.clone())
249 .selection(Selection::RequireMultiple)
250 .target_criteria(opens, CriteriaMode::TrueForAll)
251 .requires(Operation::Write)
252 .on_execute(move |cx| {
253 let mut space = cx.space.borrow_mut();
254 let result = (|| {
255 for record in &cx.selected {
256 let mut stored = space
257 .find(&name, &record.get(&key))?
258 .ok_or_else(|| SpaceError::Conflict(format!("a record of {name} is gone")))?;
259 stored.set(&field, Value::Text(to.clone()));
260 space.save(stored)?;
261 }
262 space.commit()
263 })();
264 if let Err(error) = result {
265 space.rollback();
266 return Err(error.to_string());
267 }
268 drop(space);
269 cx.records_changed();
270 Ok(())
271 })
272}
273
274#[derive(Debug)]
276pub(crate) struct StateMachineModule(pub(crate) Vec<EntityInfo>);
277
278impl Module for StateMachineModule {
279 fn name(&self) -> &str {
280 "StateMachines"
281 }
282
283 fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
284 let actions = self
285 .0
286 .iter()
287 .flat_map(|entity| {
288 entity.state_machines.iter().flat_map(move |machine| {
289 machine.transitions.iter().map(move |way| action_of(entity, machine, way))
290 })
291 })
292 .collect();
293 vec![Box::new(Transitions { actions })]
294 }
295}
296
297pub fn reminders_of(
300 space: &dyn crate::ObjectSpace,
301 entity: &EntityInfo,
302 environment: &ravenrs_criteria::Environment,
303) -> Vec<Object> {
304 let Some(reminder) = &entity.reminder else { return Vec::new() };
305 let found = space.query(&entity.name, &Query::all()).unwrap_or_default();
306 let criteria = reminder.criteria.as_deref().and_then(|text| Criteria::parse(text).ok());
307 found
308 .into_iter()
309 .filter(|record| matches!(record.get(&reminder.field), Value::Date(_)))
310 .filter(|record| {
311 criteria.as_ref().is_none_or(|criteria| Scope::new(environment).row(record).matches(criteria))
312 })
313 .collect()
314}