1use std::any::Any;
5use std::cell::RefCell;
6use std::collections::HashMap;
7use std::rc::Rc;
8
9use tilo_actions::{Action as _, ActionContext, ActionError, ActionInvoker, ChangeKind};
10
11use super::live::Live;
12use super::record::{Held, RecordDoor};
13use super::section::{Opener, SectionDoor, SectionKind, record_kind};
14use tilo_resource::{Invoke, Resource};
15use ui_core::LocalState;
16use ui_core::history_mode::HistoryOwner;
17use ui_core::record::RecordKind;
18use ui_core::record::person::{PersonRecord as StoredRecord, PersonRecords};
19
20pub struct PersonRecord<P: Resource> {
33 shared: Rc<Shared<P>>,
34}
35
36impl<P: Resource> Clone for PersonRecord<P> {
37 fn clone(&self) -> Self {
38 Self {
39 shared: Rc::clone(&self.shared),
40 }
41 }
42}
43
44struct Shared<P: Resource> {
45 record: StoredRecord,
46 owner: Option<&'static str>,
49 app_id: &'static str,
50 app_title: &'static str,
51 me: String,
52 live: Live<P>,
54 state: LocalState<Rc<P::State>>,
56 held: RefCell<Option<Rc<Held<P>>>>,
58}
59
60impl<P: Resource> Shared<P> {
61 fn refresh(&self) -> bool {
64 let moved = match self.record.read(|doc| self.live.sync(doc)) {
65 Some(Ok(moved)) => moved,
66 Some(Err(error)) => {
67 tracing::warn!(kind = P::KIND, %error, "person record unreadable");
68 false
69 }
70 None => false,
71 };
72 let _ = self.live.take_change();
74 if moved {
75 self.state.set(self.live.get());
76 }
77 moved
78 }
79}
80
81impl<P: Resource> PersonRecord<P> {
82 #[must_use]
84 pub fn get(&self) -> Rc<P::State> {
85 self.shared.refresh();
86 self.shared.state.with(Rc::clone)
87 }
88
89 pub fn with<T>(&self, read: impl FnOnce(&P::State) -> T) -> T {
91 read(&self.get())
92 }
93
94 #[must_use]
97 pub fn state(&self) -> LocalState<Rc<P::State>> {
98 self.shared.state.clone()
99 }
100
101 #[must_use]
103 pub fn revision(&self) -> u64 {
104 self.shared.refresh();
105 self.shared.live.revision()
106 }
107
108 pub fn apply(&self, action: &P::Action) -> Result<(), ActionError> {
115 self.apply_as(action, &ActionInvoker::actor(&self.shared.me))
116 }
117
118 pub(crate) fn apply_as(
121 &self,
122 action: &P::Action,
123 invoker: &ActionInvoker,
124 ) -> Result<(), ActionError> {
125 let shared = &self.shared;
126 if let Some(owner) = shared.owner {
127 return Err(ActionError::Refused(format!(
128 "the person's {} are edited in {owner}",
129 P::LABEL.to_lowercase()
130 )));
131 }
132 let encoded = tilo_resource::encode::<P>(action)?;
133 let change = action.change_kind();
134 let context = ActionContext::for_resource(P::KIND, P::KIND, P::LABEL)
135 .with_app(shared.app_id, shared.app_title)
136 .with_invoker(invoker.clone())
137 .with_change(change);
138 shared
139 .record
140 .edit(change == ChangeKind::Content, |doc| {
141 let writer =
142 ui_core::history_source::authors::device_actor(&shared.me, doc.get_actor());
143 tilo_actions::apply_local_with_context(doc, action, encoded, Some(&writer), context)
144 .map(|_| ())
145 .map_err(|error| error.to_string())
146 })
147 .map_err(ActionError::Refused)?;
148 shared.refresh();
149 Ok(())
150 }
151
152 pub fn invoke(&self, invoke: &Invoke<P>) -> Result<(), ActionError> {
157 let action = invoke
158 .build(tilo_resource::origin_now_ms())
159 .map_err(ActionError::Refused)?;
160 self.apply(&action)
161 }
162
163 #[must_use]
166 pub fn editable(&self) -> bool {
167 self.shared.owner.is_none()
168 }
169
170 pub fn undo(&self) -> bool {
172 let undone = self.shared.record.undo();
173 self.shared.refresh();
174 undone
175 }
176
177 pub fn redo(&self) -> bool {
179 let redone = self.shared.record.redo();
180 self.shared.refresh();
181 redone
182 }
183
184 #[must_use]
188 pub fn syncs(&self) -> bool {
189 self.shared.record.syncs()
190 }
191
192 #[must_use]
197 pub fn content(&self) -> Option<tilo_content::ContentStore> {
198 self.shared.record.content()
199 }
200
201 #[must_use]
204 pub fn record(&self) -> &StoredRecord {
205 &self.shared.record
206 }
207}
208
209type OpenKey = (&'static str, String);
211
212pub(crate) struct PersonState {
215 records: PersonRecords,
216 app_id: &'static str,
217 app_title: &'static str,
218 me: String,
219 opened: RefCell<HashMap<OpenKey, Rc<dyn Any>>>,
221 others: RefCell<HashMap<&'static str, PersonRecords>>,
223 owners: Rc<RefCell<Vec<HistoryOwner>>>,
224 refreshers: RefCell<Vec<Box<dyn Fn() -> bool>>>,
225 published: RefCell<Vec<Published>>,
228}
229
230struct Published {
232 id: String,
234 label: String,
235 door: Rc<dyn RecordDoor>,
237 step: Step,
240}
241
242type Step = Box<dyn Fn(bool, &str) -> bool>;
244
245struct Opened {
248 shared: Rc<dyn Any>,
249 door: Rc<dyn RecordDoor>,
250 step: Step,
251}
252
253pub(crate) const HISTORY_TYPE: &str = "tilo.app.person-history";
255
256const MAX_STEPS: i64 = 50;
258
259#[derive(serde::Serialize, serde::Deserialize)]
261struct HistoryRequest {
262 record: String,
263 undo: bool,
264 steps: Option<i64>,
265}
266
267#[must_use]
269pub(crate) fn resource_id(kind: &str) -> String {
270 format!("person:{kind}")
271}
272
273fn history_spec(record: &str, label: &str, undo: bool) -> tilo_actions::ActionSpec {
274 let mut spec = history_operation(record.to_owned(), label, undo);
275 spec.contract.result = tilo_actions::ResultSpec::Json {
276 schema: serde_json::json!({
277 "type": "object",
278 "properties": { "steps": { "type": "integer" } },
279 }),
280 };
281 spec
282}
283
284fn history_operation(record: String, label: &str, undo: bool) -> tilo_actions::ActionSpec {
285 let (id, verb) = if undo {
286 ("undo", "Undo")
287 } else {
288 ("redo", "Redo")
289 };
290 tilo_actions::ActionSpec::typed_operation(
291 id,
292 verb,
293 verb,
294 tilo_actions::OperationKind::Effect,
295 HISTORY_TYPE,
296 vec![
297 tilo_actions::ActionParam::optional(
298 "steps",
299 tilo_actions::ParamType::Number,
300 "How many of your steps (default 1, at most 50)",
301 )
302 .nullable(),
303 ],
304 move |args| {
305 tilo_actions::EncodedAction::encode_operation(
306 HISTORY_TYPE,
307 &HistoryRequest {
308 record: record.clone(),
309 undo,
310 steps: args.integer("steps"),
311 },
312 )
313 .unwrap_or_else(|_| tilo_actions::EncodedAction {
314 type_id: tilo_actions::resource::INVALID_ARGUMENTS_TYPE.to_owned(),
315 payload: Vec::new(),
316 })
317 },
318 )
319 .of_kind(tilo_actions::ActionKind::Read)
320 .with_description(if undo {
321 format!(
322 "Take back your last change to your {label}: it is yours alone, on all of your devices."
323 )
324 } else {
325 format!("Make again the change to your {label} you last took back.")
326 })
327}
328
329impl PersonState {
330 pub(crate) fn new(
331 records: PersonRecords,
332 app_id: &'static str,
333 app_title: &'static str,
334 ) -> Self {
335 let me = records.author().0.clone();
336 Self {
337 records,
338 app_id,
339 app_title,
340 me,
341 opened: RefCell::default(),
342 others: RefCell::default(),
343 owners: Rc::default(),
344 refreshers: RefCell::default(),
345 published: RefCell::default(),
346 }
347 }
348
349 pub(crate) fn open<P: Resource>(&self) -> Option<PersonRecord<P>> {
351 self.open_of::<P>(self.app_id, true)
352 }
353
354 pub(crate) fn open_shared<P: tilo_resource::SharedRecord>(&self) -> Option<PersonRecord<P>> {
357 self.open_of::<P>(P::OWNER, P::edited_by(self.app_id))
358 }
359
360 fn open_of<P: Resource>(&self, owner: &'static str, edits: bool) -> Option<PersonRecord<P>> {
363 let open = |record: StoredRecord| -> Option<Opened> {
364 let shared = self.shared_of::<P>(record, (!edits).then_some(owner))?;
365 let held = Rc::new(held::<P>(&shared, edits));
366 *shared.held.borrow_mut() = Some(Rc::clone(&held));
367 let weak = Rc::downgrade(&shared);
368 Some(Opened {
369 shared: shared as Rc<dyn Any>,
370 door: held,
371 step: Box::new(move |undo, _| {
372 weak.upgrade().is_some_and(|shared| {
373 let record = PersonRecord { shared };
374 if undo { record.undo() } else { record.redo() }
375 })
376 }),
377 })
378 };
379 let shared = self.open_kind(
380 owner,
381 P::KIND,
382 P::LABEL,
383 &|doc| P::initialize(doc).map_err(|error| error.to_string()),
384 &open,
385 )?;
386 shared
387 .downcast::<Shared<P>>()
388 .ok()
389 .map(|shared| PersonRecord { shared })
390 }
391
392 fn open_kind(
397 &self,
398 owner: &'static str,
399 kind: &'static str,
400 label: &'static str,
401 initialize: &dyn Fn(&mut ui_core::automerge::AutoCommit) -> Result<(), String>,
402 open: &dyn Fn(StoredRecord) -> Option<Opened>,
403 ) -> Option<Rc<dyn Any>> {
404 let key = (owner, kind.to_owned());
405 if let Some(open) = self.opened.borrow().get(&key) {
406 return Some(Rc::clone(open));
407 }
408 let record = open_record(&self.records_of(owner)?, kind.to_owned(), kind, initialize)?;
409 let Opened { shared, door, step } = open(record)?;
410 if owner != self.app_id {
411 self.opened.borrow_mut().insert(key, Rc::clone(&shared));
412 return Some(shared);
413 }
414 self.publish(
415 key,
416 Rc::clone(&shared),
417 Published {
418 id: resource_id(kind),
419 label: label.to_owned(),
420 door,
421 step,
422 },
423 );
424 Some(shared)
425 }
426
427 fn shared_of<P: Resource>(
429 &self,
430 record: StoredRecord,
431 owner: Option<&'static str>,
432 ) -> Option<Rc<Shared<P>>> {
433 let reader = record
434 .read(tilo_resource::Reader::<P>::read)?
435 .map_err(|error| tracing::warn!(kind = P::KIND, %error, "person record unreadable"))
436 .ok()?;
437 let live = Live::new(reader);
438 self.owners.borrow_mut().push(record.history_owner());
439 let shared = Rc::new(Shared::<P> {
440 record,
441 owner,
442 app_id: self.app_id,
443 app_title: self.app_title,
444 me: self.me.clone(),
445 state: LocalState::new(live.get()),
446 live,
447 held: RefCell::new(None),
448 });
449 let weak = Rc::downgrade(&shared);
450 self.refreshers.borrow_mut().push(Box::new(move || {
451 weak.upgrade().is_some_and(|shared| shared.refresh())
452 }));
453 Some(shared)
454 }
455
456 pub(crate) fn open_section(
462 &self,
463 kind: Rc<SectionKind>,
464 device: Option<&str>,
465 ) -> Option<Rc<SectionDoor>> {
466 let name = record_kind(kind.kind, kind.device_local, device);
467 let key = (self.app_id, name.clone());
468 if let Some(open) = self.opened.borrow().get(&key) {
469 return Rc::clone(open).downcast::<SectionDoor>().ok();
470 }
471 let label = kind.kind;
472 let door = SectionDoor::person(
473 kind,
474 Opener {
475 records: self.records.clone(),
476 name,
477 owners: Rc::clone(&self.owners),
478 },
479 self.app_id,
480 self.app_title,
481 self.me.clone(),
482 )
483 .map_err(|error| tracing::warn!(kind = label, %error, "settings section unavailable"))
484 .ok()?;
485 let door = Rc::new(door);
486 let stepping = Rc::downgrade(&door);
487 self.publish(
488 key,
489 Rc::clone(&door) as Rc<dyn Any>,
490 Published {
491 id: door.id().to_owned(),
492 label: door.label().to_owned(),
493 door: Rc::clone(&door) as Rc<dyn RecordDoor>,
494 step: Box::new(move |undo, by| {
495 stepping.upgrade().is_some_and(|door| door.step(undo, by))
496 }),
497 },
498 );
499 Some(door)
500 }
501
502 fn records_of(&self, owner: &'static str) -> Option<PersonRecords> {
505 if owner == self.app_id {
506 return Some(self.records.clone());
507 }
508 let mut others = self.others.borrow_mut();
509 if let Some(records) = others.get(owner) {
510 return Some(records.clone());
511 }
512 let records = self
513 .records
514 .of_package(owner)
515 .map_err(|error| {
516 tracing::warn!(owner, %error, "the person's records of another app are unavailable");
517 })
518 .ok()?;
519 others.insert(owner, records.clone());
520 Some(records)
521 }
522
523 fn publish(&self, key: OpenKey, open: Rc<dyn Any>, published: Published) {
526 self.published.borrow_mut().push(published);
527 self.opened.borrow_mut().insert(key, open);
528 }
529
530 pub(crate) fn refresh(&self) -> bool {
532 self.refreshers
533 .borrow()
534 .iter()
535 .fold(false, |moved, refresh| refresh() | moved)
536 }
537
538 pub(crate) fn resources(&self) -> Vec<tilo_actions::Resource> {
542 self.published
543 .borrow()
544 .iter()
545 .map(|published| {
546 published
547 .door
548 .publish()
549 .with_action(history_spec(&published.id, &published.label, true))
550 .with_action(history_spec(&published.id, &published.label, false))
551 })
552 .collect()
553 }
554
555 pub(crate) fn doors(&self) -> Vec<Rc<dyn RecordDoor>> {
557 self.published
558 .borrow()
559 .iter()
560 .map(|published| Rc::clone(&published.door))
561 .collect()
562 }
563
564 pub(crate) fn me(&self) -> &str {
566 &self.me
567 }
568
569 pub(crate) fn answer(&self, payload: &[u8]) -> Result<tilo_actions::ActionOutput, String> {
571 let request: HistoryRequest =
572 tilo_actions::decode_json(payload).map_err(|error| error.to_string())?;
573 let steps = request.steps.unwrap_or(1);
574 if !(1..=MAX_STEPS).contains(&steps) {
575 return Err(format!("steps is 1 to {MAX_STEPS}"));
576 }
577 let invoker = ui_core::invocation_scope::current_invoker()
578 .unwrap_or_else(|| ActionInvoker::actor(&self.me));
579 if invoker.actor != self.me && invoker.on_behalf_of.as_deref() != Some(self.me.as_str()) {
580 return Err(format!(
581 "`{}` is {}'s own; only they change it",
582 request.record, self.me
583 ));
584 }
585 let published = self.published.borrow();
586 let record = published
587 .iter()
588 .find(|published| published.id == request.record)
589 .ok_or_else(|| format!("no record of yours is open as {}", request.record))?;
590 let mut taken = 0;
591 while taken < steps && (record.step)(request.undo, &invoker.actor) {
592 taken = taken.saturating_add(1);
593 }
594 if taken == 0 {
595 return Err(if request.undo {
596 "nothing of yours to undo".to_owned()
597 } else {
598 "nothing of yours to redo".to_owned()
599 });
600 }
601 Ok(tilo_actions::ActionOutput {
602 value: Some(serde_json::json!({ "steps": taken })),
603 resources: vec![record.id.clone()],
604 })
605 }
606
607 pub(crate) fn owners(&self) -> Vec<HistoryOwner> {
609 self.owners.borrow().clone()
610 }
611}
612
613fn open_record(
616 records: &PersonRecords,
617 kind: String,
618 of: &str,
619 initialize: &dyn Fn(&mut ui_core::automerge::AutoCommit) -> Result<(), String>,
620) -> Option<StoredRecord> {
621 records
622 .open_record(RecordKind::new(kind), initialize)
623 .map_err(|error| tracing::warn!(kind = of, %error, "person record unavailable"))
624 .ok()
625}
626
627fn held<P: Resource>(shared: &Rc<Shared<P>>, edits: bool) -> Held<P> {
630 let following = Rc::downgrade(shared);
631 let applying = Rc::downgrade(shared);
632 Held::new(
633 resource_id(P::KIND),
634 format!("Your {}", P::LABEL.to_lowercase()),
635 edits,
636 shared.live.clone(),
637 move |_: &Live<P>| {
638 if let Some(shared) = following.upgrade() {
639 shared.refresh();
640 }
641 },
642 move |action: &P::Action, invoker: &ActionInvoker| {
643 let shared = applying
644 .upgrade()
645 .ok_or_else(|| ActionError::Refused("the record is closed".into()))?;
646 PersonRecord { shared }.apply_as(action, invoker)
647 },
648 )
649}
650
651impl<P: Resource> super::record::sealed::Hold<P> for PersonRecord<P> {
652 fn held(&self, _storage: super::record::sealed::Storage<'_>) -> Option<Rc<Held<P>>> {
653 self.shared.held.borrow().clone()
654 }
655
656 fn door(
657 &self,
658 _storage: super::record::sealed::Storage<'_>,
659 ) -> Option<super::record::sealed::Door> {
660 self.shared
661 .held
662 .borrow()
663 .clone()
664 .map(|held| super::record::sealed::Door(held as Rc<dyn RecordDoor>))
665 }
666}
667
668impl Drop for PersonState {
669 fn drop(&mut self) {
671 if let Err(error) = self.records.flush() {
672 tracing::warn!(%error, "the person's records could not be stored");
673 }
674 }
675}