1#[cfg(any(feature = "wasm", test))]
2use std::collections::BTreeSet;
3use std::collections::{HashMap, HashSet};
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6
7#[cfg(any(feature = "wasm", test))]
8use serde::Serialize;
9use yrs::sync::time::Clock;
10#[cfg(any(feature = "wasm", test))]
11use yrs::types::{Event, PathSegment};
12use yrs::undo::StackItem;
13#[cfg(any(feature = "wasm", test))]
14use yrs::{DeepObservable, Map, MapRef, Subscription};
15use yrs::{Doc, IdSet, Origin, ReadTxn, Transact};
16
17use crate::{COMMENTS, EditError, EditResult, META, SHAPES, SLIDE_ORDER, SLIDES, STORIES};
18
19use crate::DeckSession;
20
21const CAPTURE_TIMEOUT_MS: u64 = 500;
22
23#[cfg(test)]
24type HistorySets = Vec<(yrs::IdSet, yrs::IdSet)>;
25#[cfg(test)]
26type ClientClockCounts = Vec<(yrs::ClientID, u64)>;
27#[cfg(test)]
28type HistoryCounts = Vec<(ClientClockCounts, ClientClockCounts)>;
29
30#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
32pub enum UndoCaptureMode {
33 #[default]
34 Auto,
35 Manual,
36}
37
38struct CaptureClock {
39 source: Arc<dyn Clock>,
40 state: Mutex<CaptureClockState>,
41}
42
43struct CaptureClockState {
44 mode: UndoCaptureMode,
45 holds: usize,
46 ticks: u64,
47 last_source: u64,
48}
49
50impl CaptureClock {
51 fn new(source: Arc<dyn Clock>) -> Self {
52 Self {
53 state: Mutex::new(CaptureClockState {
54 mode: UndoCaptureMode::Auto,
55 holds: 0,
56 ticks: 1,
57 last_source: source.now(),
58 }),
59 source,
60 }
61 }
62
63 fn mode(&self) -> UndoCaptureMode {
64 self.state.lock().unwrap().mode
65 }
66
67 fn set_mode(&self, mode: UndoCaptureMode) {
68 self.state.lock().unwrap().mode = mode;
69 }
70}
71
72impl Clock for CaptureClock {
73 fn now(&self) -> u64 {
74 let mut state = self.state.lock().unwrap();
75 let now = self.source.now();
76 let elapsed = match (state.mode, state.holds) {
77 (UndoCaptureMode::Auto, 0) => now.saturating_sub(state.last_source),
78 _ => 0,
79 };
80 state.last_source = now;
81 state.ticks = state.ticks.saturating_add(elapsed);
82 state.ticks
83 }
84}
85
86struct CaptureClockHold(Arc<CaptureClock>);
87
88impl Drop for CaptureClockHold {
89 fn drop(&mut self) {
90 self.0.state.lock().unwrap().holds -= 1;
91 }
92}
93
94impl DeckSession {
95 pub(crate) fn with_undo_capture<T>(
96 &self,
97 operation: impl FnOnce() -> EditResult<T>,
98 ) -> EditResult<T> {
99 self.automatic_undo_barrier();
100 let clock = self.undo.borrow().clock.clone();
101 clock.state.lock().unwrap().holds += 1;
102 let hold = CaptureClockHold(clock);
103 let result = operation();
104 drop(hold);
105 self.automatic_undo_barrier();
106 result
107 }
108}
109
110pub struct DeckUndoManager {
111 clock: Arc<CaptureClock>,
112 inner: yrs::undo::UndoManager<()>,
113 kept: Arc<AtomicBool>,
114 client_id: u64,
115 tables: HashMap<String, IdSet>,
116 #[cfg(any(feature = "wasm", test))]
117 history: Option<HistoryObserver>,
118}
119
120#[cfg(any(feature = "wasm", test))]
121#[derive(Default, Serialize)]
122#[serde(rename_all = "camelCase")]
123pub(crate) struct HistoryReceipt {
124 pub applied: bool,
125 pub changed_slide_ids: BTreeSet<String>,
126 pub deck_size_changed: bool,
127 pub slide_order_changed: bool,
128}
129
130#[cfg(any(feature = "wasm", test))]
131#[derive(Default)]
132struct HistoryState {
133 owners: HashMap<String, String>,
134 receipt: Option<HistoryReceipt>,
135}
136
137#[cfg(any(feature = "wasm", test))]
138struct HistoryObserver {
139 state: Arc<Mutex<HistoryState>>,
140 _subscriptions: Vec<Subscription>,
141}
142
143#[cfg(any(feature = "wasm", test))]
144fn index_shape<T: ReadTxn>(txn: &T, state: &mut HistoryState, shape_id: &str, slide_id: &str) {
145 if state
146 .owners
147 .insert(shape_id.to_owned(), slide_id.to_owned())
148 .as_deref()
149 == Some(slide_id)
150 {
151 return;
152 }
153 refresh_shape(txn, state, shape_id, slide_id);
154}
155
156#[cfg(any(feature = "wasm", test))]
157fn refresh_shape<T: ReadTxn>(txn: &T, state: &mut HistoryState, shape_id: &str, slide_id: &str) {
158 let Ok(shape) = crate::deck::shape_ref(txn, shape_id) else {
159 return;
160 };
161 let Ok(stories) = crate::deck::map_string_array(&shape, txn, "textStories") else {
162 return;
163 };
164 for story in stories {
165 if let Ok(key) = crate::table::storage_key(txn, &story) {
166 state.owners.insert(key, slide_id.to_owned());
167 }
168 state.owners.insert(story, slide_id.to_owned());
169 }
170 let Ok(children) = crate::deck::map_string_array(&shape, txn, "children") else {
171 return;
172 };
173 for child in children {
174 index_shape(txn, state, &child, slide_id);
175 }
176}
177
178#[cfg(any(feature = "wasm", test))]
179fn index_slide<T: ReadTxn>(txn: &T, state: &mut HistoryState, slide_id: &str) {
180 let Ok(slide) = crate::deck::slide_ref(txn, slide_id) else {
181 return;
182 };
183 if let Ok(order) = crate::deck::slide_shape_order(&slide, txn) {
184 for id in crate::deck::live_shape_order(&order, txn).unwrap_or_default() {
185 index_shape(txn, state, &id, slide_id);
186 }
187 }
188}
189
190#[cfg(any(feature = "wasm", test))]
191impl DeckSession {
192 pub(crate) fn enable_history_receipts(&self) -> EditResult<()> {
193 let mut undo = self.undo.borrow_mut();
194 if undo.history.is_some() {
195 return Ok(());
196 }
197 let txn = self.doc.transact();
198 let mut initial = HistoryState::default();
199 for id in crate::deck::live_slide_ids(&txn)? {
200 index_slide(&txn, &mut initial, &id);
201 }
202 for (id, value) in crate::deck::required_map(&txn, COMMENTS)?.iter(&txn) {
203 if let Ok(map) = value.cast::<MapRef>()
204 && let Some(slide_id) = crate::deck::map_string(&map, &txn, "slideId")
205 && crate::deck::slide_ref(&txn, &slide_id).is_ok()
206 {
207 initial.owners.insert(id.to_owned(), slide_id);
208 }
209 }
210 let state = Arc::new(Mutex::new(initial));
211 let mut subscriptions = Vec::new();
212 for root in [SLIDES, SHAPES, STORIES, COMMENTS, META] {
213 let map = crate::deck::required_map(&txn, root)?;
214 let state = state.clone();
215 subscriptions.push(map.observe_deep(move |txn, events| {
216 let mut state = state.lock().unwrap();
217 let mut keys = BTreeSet::new();
218 for event in events.iter() {
219 if let Some(PathSegment::Key(key)) = event.path().front() {
220 keys.insert(key.to_string());
221 } else if let Event::Map(event) = event {
222 keys.extend(event.keys(txn).keys().map(|key| key.to_string()));
223 }
224 }
225 for key in keys {
226 let previous_owner = state.owners.get(&key).cloned();
227 let owner = match root {
228 SLIDES => Some(key.clone()),
229 COMMENTS => txn
230 .get_map(COMMENTS)
231 .and_then(|map| map.get(txn, &key))
232 .and_then(|value| value.cast::<MapRef>().ok())
233 .and_then(|map| crate::deck::map_string(&map, txn, "slideId"))
234 .or_else(|| previous_owner.clone()),
235 _ => previous_owner.clone(),
236 };
237 if let Some(receipt) = &mut state.receipt {
238 if root == META {
239 if key == "widthEmu" || key == "heightEmu" {
240 receipt.deck_size_changed = true;
241 } else {
242 receipt
243 .changed_slide_ids
244 .extend(crate::deck::live_slide_ids(txn).unwrap_or_default());
245 }
246 }
247 if let Some(id) = &owner
248 && crate::deck::slide_ref(txn, id).is_ok()
249 {
250 receipt.changed_slide_ids.insert(id.clone());
251 }
252 if root == COMMENTS {
253 receipt.changed_slide_ids.extend(
254 previous_owner.filter(|id| crate::deck::slide_ref(txn, id).is_ok()),
255 );
256 }
257 }
258 if root == SLIDES {
259 index_slide(txn, &mut state, &key);
260 } else if let Some(id) = owner {
261 if root == SHAPES {
262 refresh_shape(txn, &mut state, &key, &id);
263 } else if root == COMMENTS {
264 state.owners.insert(key, id);
265 }
266 }
267 }
268 }));
269 }
270 let order = txn
271 .get_array(SLIDE_ORDER)
272 .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))?;
273 let order_state = state.clone();
274 subscriptions.push(order.observe_deep(move |_, _| {
275 if let Some(receipt) = &mut order_state.lock().unwrap().receipt {
276 receipt.slide_order_changed = true;
277 }
278 }));
279 undo.history = Some(HistoryObserver {
280 state,
281 _subscriptions: subscriptions,
282 });
283 Ok(())
284 }
285
286 pub(crate) fn history_step(&self, redo: bool) -> EditResult<HistoryReceipt> {
287 self.enable_history_receipts()?;
288 let mut undo = self.undo.borrow_mut();
289 let state = undo.history.as_ref().unwrap().state.clone();
290 state.lock().unwrap().receipt = Some(HistoryReceipt::default());
291 let applied = if redo { undo.redo() } else { undo.undo() };
292 let mut receipt = state.lock().unwrap().receipt.take().unwrap();
293 receipt.applied = applied;
294 let txn = self.doc.transact();
295 receipt
296 .changed_slide_ids
297 .retain(|id| crate::deck::slide_ref(&txn, id).is_ok());
298 Ok(receipt)
299 }
300}
301
302impl DeckUndoManager {
303 pub(crate) fn new(doc: &Doc, client_id: u64) -> EditResult<Self> {
304 let clock = Arc::new(CaptureClock::new(default_clock()));
305 let kept = Arc::new(AtomicBool::new(false));
306 let inner = build_inner(doc, client_id, &clock, &kept, Vec::new(), Vec::new())?;
307 Ok(Self {
308 inner,
309 clock,
310 kept,
311 client_id,
312 tables: HashMap::new(),
313 #[cfg(any(feature = "wasm", test))]
314 history: None,
315 })
316 }
317
318 pub(crate) fn track_table(&mut self, shape_id: &str, changes: IdSet) {
320 self.tables
321 .entry(shape_id.to_owned())
322 .or_default()
323 .merge_with(changes);
324 }
325
326 pub(crate) fn tracked_tables(&self) -> impl Iterator<Item = &String> {
327 self.tables.keys()
328 }
329
330 pub(crate) fn drop_table(&mut self, doc: &Doc, shape_id: &str) -> EditResult<()> {
332 let Some(changes) = self.tables.remove(shape_id) else {
333 return Ok(());
334 };
335 let keep = |stack: &[StackItem<()>]| -> Vec<_> {
336 stack
337 .iter()
338 .filter(|item| !touches(item, &changes))
339 .cloned()
340 .collect()
341 };
342 let undo = keep(self.inner.undo_stack());
343 let redo = keep(self.inner.redo_stack());
344 if undo.len() == self.inner.undo_stack().len()
345 && redo.len() == self.inner.redo_stack().len()
346 {
347 return Ok(());
348 }
349 self.inner = build_inner(doc, self.client_id, &self.clock, &self.kept, undo, redo)?;
350 Ok(())
351 }
352
353 fn step(&mut self, redo: bool) -> bool {
354 let stacks = |inner: &yrs::undo::UndoManager<()>| {
355 if redo {
356 (inner.redo_stack().last().cloned(), inner.undo_stack().len())
357 } else {
358 (inner.undo_stack().last().cloned(), inner.redo_stack().len())
359 }
360 };
361 let (popped, before) = stacks(&self.inner);
362 let applied = if redo {
363 self.inner.redo_blocking()
364 } else {
365 self.inner.undo_blocking()
366 };
367 let target = if redo {
368 self.inner.undo_stack()
369 } else {
370 self.inner.redo_stack()
371 };
372 if applied
373 && target.len() > before
374 && let (Some(popped), Some(pushed)) = (popped, target.last())
375 {
376 let pushed = pushed.insertions().merge(pushed.deletions());
377 for changes in self.tables.values_mut() {
378 if touches(&popped, changes) {
379 changes.merge_with(pushed.clone());
380 }
381 }
382 }
383 applied
384 }
385
386 pub(crate) fn assert_replay_capture(&self) -> Result<(), crate::peer::PeerError> {
387 if self.capture_mode() != UndoCaptureMode::Manual {
388 return Err(crate::peer::PeerError::new(
389 "capturePolicy",
390 "replay requires manual undo capture",
391 ));
392 }
393 Ok(())
394 }
395
396 pub(crate) fn ever_kept(&self) -> bool {
398 self.kept.load(Ordering::Relaxed)
399 }
400
401 pub(crate) fn diagnostics(&self) -> (usize, usize) {
402 (self.inner.undo_stack().len(), self.inner.redo_stack().len())
403 }
404
405 #[cfg(test)]
406 pub(crate) fn stack_diagnostics(&self) -> (HistorySets, HistorySets) {
407 let capture = |stack: &[yrs::undo::StackItem<()>]| {
408 stack
409 .iter()
410 .map(|item| (item.deletions().clone(), item.insertions().clone()))
411 .collect()
412 };
413 (
414 capture(self.inner.undo_stack()),
415 capture(self.inner.redo_stack()),
416 )
417 }
418
419 #[cfg(test)]
420 pub(crate) fn stack_clock_counts(&self) -> (HistoryCounts, HistoryCounts) {
421 let counts = |set: &yrs::IdSet| -> ClientClockCounts {
422 set.iter()
423 .map(|(client, ranges)| {
424 let count = ranges
425 .iter()
426 .map(|range| u64::from(range.end - range.start))
427 .sum();
428 (*client, count)
429 })
430 .collect()
431 };
432 let capture = |stack: &[yrs::undo::StackItem<()>]| -> HistoryCounts {
433 stack
434 .iter()
435 .map(|item| (counts(item.deletions()), counts(item.insertions())))
436 .collect()
437 };
438 (
439 capture(self.inner.undo_stack()),
440 capture(self.inner.redo_stack()),
441 )
442 }
443
444 pub fn capture_mode(&self) -> UndoCaptureMode {
445 self.clock.mode()
446 }
447
448 pub fn set_capture_mode(&mut self, mode: UndoCaptureMode) {
449 if self.capture_mode() != mode {
450 self.add_undo_barrier();
451 self.clock.set_mode(mode);
452 }
453 }
454
455 pub fn undo(&mut self) -> bool {
456 self.step(false)
457 }
458
459 pub fn redo(&mut self) -> bool {
460 self.step(true)
461 }
462
463 pub fn clear(&mut self) {
464 self.inner.clear_all();
465 self.inner.reset();
466 self.tables.clear();
467 }
468
469 pub fn can_undo(&self) -> bool {
470 self.inner.can_undo()
471 }
472
473 pub fn can_redo(&self) -> bool {
474 self.inner.can_redo()
475 }
476
477 pub fn add_undo_barrier(&mut self) {
478 self.inner.reset();
479 }
480}
481
482fn touches(item: &StackItem<()>, changes: &IdSet) -> bool {
483 !item.insertions().intersect(changes).is_empty()
484 || !item.deletions().intersect(changes).is_empty()
485}
486
487fn build_inner(
488 doc: &Doc,
489 client_id: u64,
490 clock: &Arc<CaptureClock>,
491 kept: &Arc<AtomicBool>,
492 init_undo_stack: Vec<StackItem<()>>,
493 init_redo_stack: Vec<StackItem<()>>,
494) -> EditResult<yrs::undo::UndoManager<()>> {
495 let txn = doc.transact();
496 let order = txn
497 .get_array(SLIDE_ORDER)
498 .ok_or_else(|| EditError::InvalidState("missing slide order".to_owned()))?;
499 let slides = txn
500 .get_map(SLIDES)
501 .ok_or_else(|| EditError::InvalidState("missing slides map".to_owned()))?;
502 let shapes = txn
503 .get_map(SHAPES)
504 .ok_or_else(|| EditError::InvalidState("missing shapes map".to_owned()))?;
505 let stories = txn
506 .get_map(STORIES)
507 .ok_or_else(|| EditError::InvalidState("missing stories map".to_owned()))?;
508 let comments = txn
509 .get_map(COMMENTS)
510 .ok_or_else(|| EditError::InvalidState("missing comments map".to_owned()))?;
511 let meta = txn
512 .get_map(META)
513 .ok_or_else(|| EditError::InvalidState("missing meta map".to_owned()))?;
514 drop(txn);
515 let options = yrs::undo::Options {
516 capture_timeout_millis: CAPTURE_TIMEOUT_MS,
517 tracked_origins: HashSet::from([Origin::from(client_id)]),
518 capture_transaction: None,
519 timestamp: clock.clone(),
520 init_undo_stack,
521 init_redo_stack,
522 };
523 let mut inner = yrs::undo::UndoManager::with_options(options);
524 inner.expand_scope(doc, &order);
525 inner.expand_scope(doc, &slides);
526 inner.expand_scope(doc, &shapes);
527 inner.expand_scope(doc, &stories);
528 inner.expand_scope(doc, &comments);
529 inner.expand_scope(doc, &meta);
530 let flag = Arc::clone(kept);
531 inner.observe_item_added_with("kept", move |_, _| flag.store(true, Ordering::Relaxed));
532 Ok(inner)
533}
534
535fn default_clock() -> Arc<dyn Clock> {
536 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
537 {
538 Arc::new(|| {
539 std::time::SystemTime::now()
540 .duration_since(std::time::UNIX_EPOCH)
541 .map(|elapsed| elapsed.as_millis() as u64)
542 .unwrap_or_default()
543 })
544 }
545 #[cfg(all(target_family = "wasm", target_os = "unknown"))]
546 {
547 use std::sync::atomic::{AtomicU64, Ordering};
548 let ticks = AtomicU64::new(0);
549 Arc::new(move || ticks.fetch_add(CAPTURE_TIMEOUT_MS + 1, Ordering::Relaxed))
550 }
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::{EditCtx, ReadRequest, ShapeDraft, ShapeRect, TextStyle};
557
558 fn session() -> DeckSession {
559 let mut package = pptx_parse::PptxPackage::default();
560 package.presentation.width_emu = 9144000;
561 package.presentation.height_emu = 6858000;
562 DeckSession::from_package_with_fingerprint(package, "history-receipts".to_owned(), 7)
563 .unwrap()
564 }
565
566 #[test]
567 fn history_receipts_exclude_deleted_slides_for_text_and_meta_changes() {
568 let session = session();
569 let context = EditCtx::local("test");
570 let deleted = session.insert_slide(&context, 0, None).unwrap().slide_id;
571 let live = session.insert_slide(&context, 1, None).unwrap().slide_id;
572 let shape = session
573 .add_text_box(
574 &context,
575 &deleted,
576 &ShapeDraft {
577 name: "Text".to_owned(),
578 rect: ShapeRect {
579 x: 0,
580 y: 0,
581 width: 1000,
582 height: 1000,
583 },
584 text: "alpha".to_owned(),
585 style: TextStyle::default(),
586 },
587 )
588 .unwrap();
589 session.clear_undo_history();
590 let story = format!("story:{}:0", shape.shape_id);
591 session
592 .insert_text(&context, &story, 0, "X", &TextStyle::default())
593 .unwrap();
594 session.add_undo_barrier();
595 session
596 .delete_slide(
597 &EditCtx {
598 origin: crate::EditOrigin::Remote,
599 author: "peer".to_owned(),
600 },
601 &deleted,
602 )
603 .unwrap();
604 session.enable_history_receipts().unwrap();
605 assert!(
606 session
607 .history_step(false)
608 .unwrap()
609 .changed_slide_ids
610 .is_empty()
611 );
612 {
613 let mut txn = session.transact_for(&context);
614 let meta = txn.get_map(META).unwrap();
615 meta.insert(&mut txn, "title", "changed");
616 }
617 session.add_undo_barrier();
618 for redo in [false, true] {
619 assert_eq!(
620 session.history_step(redo).unwrap().changed_slide_ids,
621 BTreeSet::from([live.clone()])
622 );
623 }
624 }
625
626 #[test]
627 fn history_receipts_scope_text_without_reading_untouched_slide() {
628 let session = session();
629 let context = EditCtx::local("test");
630 session.set_undo_capture_mode(UndoCaptureMode::Manual);
631 session.enable_history_receipts().unwrap();
632 let mut slides = Vec::new();
633 let mut shapes = Vec::new();
634 for index in 0..2 {
635 let slide = session.insert_slide(&context, index, None).unwrap();
636 let shape = session
637 .add_text_box(
638 &context,
639 &slide.slide_id,
640 &ShapeDraft {
641 name: "Text".to_owned(),
642 rect: ShapeRect {
643 x: 0,
644 y: 0,
645 width: 1000,
646 height: 1000,
647 },
648 text: "alpha".to_owned(),
649 style: TextStyle::default(),
650 },
651 )
652 .unwrap();
653 slides.push(slide.slide_id);
654 shapes.push(shape.shape_id);
655 }
656 session.add_undo_barrier();
657 {
658 let mut txn = session.doc.transact_mut_with(crate::REMOTE_ORIGIN);
659 let shape = crate::deck::shape_ref(&txn, &shapes[1]).unwrap();
660 shape.remove(&mut txn, "width");
661 }
662 assert!(session.snapshot().is_err());
663 let story_id = format!("story:{}:0", shapes[0]);
664 session
665 .insert_text(&context, &story_id, 0, "Z", &TextStyle::default())
666 .unwrap();
667 session.add_undo_barrier();
668 for (redo, text) in [(false, "alpha"), (true, "Zalpha")] {
669 let receipt = session.history_step(redo).unwrap();
670 assert!(receipt.applied);
671 assert_eq!(
672 receipt.changed_slide_ids,
673 BTreeSet::from([slides[0].clone()])
674 );
675 assert!(!receipt.deck_size_changed);
676 assert!(!receipt.slide_order_changed);
677 let read = session
678 .read_command_content(&ReadRequest {
679 slide_ids: Some(vec![slides[0].clone()]),
680 })
681 .unwrap()
682 .unwrap();
683 let value = serde_json::to_value(read).unwrap();
684 assert_eq!(value["stories"][0]["text"], text);
685 }
686 assert!(session.snapshot().is_err());
687 }
688
689 #[test]
690 fn history_receipts_report_slide_order_and_deck_size() {
691 let session = session();
692 let context = EditCtx::local("test");
693 session.enable_history_receipts().unwrap();
694 session.set_undo_capture_mode(UndoCaptureMode::Manual);
695 let slide = session.insert_slide(&context, 0, None).unwrap();
696 session.add_undo_barrier();
697 for redo in [false, true] {
698 let receipt = session.history_step(redo).unwrap();
699 assert!(receipt.applied);
700 assert!(receipt.slide_order_changed);
701 assert!(!receipt.deck_size_changed);
702 assert_eq!(
703 receipt.changed_slide_ids,
704 if redo {
705 BTreeSet::from([slide.slide_id.clone()])
706 } else {
707 BTreeSet::new()
708 }
709 );
710 }
711 session.add_undo_barrier();
712 {
713 let mut txn = session.transact_for(&context);
714 let meta = crate::deck::required_map(&txn, META).unwrap();
715 meta.insert(&mut txn, "widthEmu", 1000_f64);
716 }
717 session.add_undo_barrier();
718 for redo in [false, true] {
719 let receipt = session.history_step(redo).unwrap();
720 assert!(receipt.applied);
721 assert!(receipt.deck_size_changed);
722 assert!(!receipt.slide_order_changed);
723 }
724 }
725}