| author | |
| committer | |
| log | c414df0810a16db7b85ef4c2abddc5dbd289154b |
| tree | 633027442f87da9d208fe2425482025f091edb52 |
| parent | efa39925fa1ba017f1d50b3cb409f4bd9b11257c |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
When the section refuses an edit, iOS keeps the page as the view shows it
instead of putting its text on the clipboard: as a conflict page under the
stored page with its differing paragraphs highlighted, as OneNote 2010
keeps a version it could not merge, or as a copy where the page is gone.
The alert mentions the kept page only once the section has taken it.
Refs #136
Assisted-by: claude-opus-5.57 files changed, 273 insertions(+), 65 deletions(-)
apps/ios/Snowbound/CanvasView.swift+2-1| ... | @@ -1050,7 +1050,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI | ... | @@ -1050,7 +1050,8 @@ final class CanvasView: UIScrollView, UIScrollViewDelegate, UITextInput, UITextI |
| 1050 | /// The title as typed; nil on a page without an editable title. | 1050 | /// The title as typed; nil on a page without an editable title. |
| 1051 | var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } | 1051 | var pageTitle: String? { handle.flatMap { take(sb_view_title($0)) } } |
| 1052 | 1052 | ||
| 1053 | var pageText: String { handle.flatMap { take(sb_view_page_text($0)) } ?? "" } | 1053 | /// Keeps the page as shown as a page of its own after an edit was refused; false when nothing was kept. |
| 1054 | func keep() -> Bool { handle.map(sb_view_keep) ?? false } | ||
| 1054 | 1055 | ||
| 1055 | /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming | 1056 | /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming |
| 1056 | /// `section`. | 1057 | /// `section`. |
apps/ios/Snowbound/PageViewController.swift+3-3| ... | @@ -409,10 +409,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate | ... | @@ -409,10 +409,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate |
| 409 | @objc private func sectionChanged(_ notification: Notification) { | 409 | @objc private func sectionChanged(_ notification: Notification) { |
| 410 | let flags = notification.userInfo?["flags"] as? UInt32 ?? 0 | 410 | let flags = notification.userInfo?["flags"] as? UInt32 ?? 0 |
| 411 | if flags & Section.rejected != 0 { | 411 | if flags & Section.rejected != 0 { |
| 412 | // Nothing typed is lost unannounced: the page's text goes on the clipboard. | 412 | let kept = canvas.keep() |
| 413 | UIPasteboard.general.string = canvas.pageText | ||
| 414 | canvas.reload(discard: true) | 413 | canvas.reload(discard: true) |
| 415 | alert("Change Not Saved", "The page shows what was last saved. Your text is on the clipboard to paste back.") | 414 | alert("Change Not Saved", "The page shows what was last saved." |
| 415 | + (kept ? " Your version is kept as a separate page." : "")) | ||
| 416 | } else if flags & Section.remote != 0 { | 416 | } else if flags & Section.remote != 0 { |
| 417 | canvas.reload(discard: false) | 417 | canvas.reload(discard: false) |
| 418 | } else { | 418 | } else { |
crates/mobile/include/snowbound.h+1-1| ... | @@ -105,6 +105,7 @@ bool sb_view_set_reading(View *view, float width, float anchor[2]); | ... | @@ -105,6 +105,7 @@ bool sb_view_set_reading(View *view, float width, float anchor[2]); |
| 105 | bool sb_view_reload_ready(const View *view); | 105 | bool sb_view_reload_ready(const View *view); |
| 106 | // A non-null page-point anchor follows its source caret across the refresh. | 106 | // A non-null page-point anchor follows its source caret across the refresh. |
| 107 | bool sb_view_reload(View *view, bool discard, float anchor[2]); | 107 | bool sb_view_reload(View *view, bool discard, float anchor[2]); |
| 108 | bool sb_view_keep(const View *view); | ||
| 108 | void sb_view_focus(View *view, bool focused); | 109 | void sb_view_focus(View *view, bool focused); |
| 109 | void sb_view_content(View *view, float bounds[4]); | 110 | void sb_view_content(View *view, float bounds[4]); |
| 110 | bool sb_view_block(const View *view, float x, float y, float rect[4]); | 111 | bool sb_view_block(const View *view, float x, float y, float rect[4]); |
| ... | @@ -137,7 +138,6 @@ bool sb_view_ink_selection(const View *view, float rect[4]); | ... | @@ -137,7 +138,6 @@ bool sb_view_ink_selection(const View *view, float rect[4]); |
| 137 | char *sb_view_paper(const View *view); | 138 | char *sb_view_paper(const View *view); |
| 138 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); | 139 | bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled); |
| 139 | bool sb_view_set_art(View *view, const char *name); | 140 | bool sb_view_set_art(View *view, const char *name); |
| 140 | char *sb_view_page_text(const View *view); | ||
| 141 | uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length); | 141 | uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length); |
| 142 | void sb_bytes_free(uint8_t *bytes, size_t length); | 142 | void sb_bytes_free(uint8_t *bytes, size_t length); |
| 143 | int8_t sb_view_date_request(View *view, int64_t *seconds); | 143 | int8_t sb_view_date_request(View *view, int64_t *seconds); |
crates/mobile/src/lib.rs+44-27| ... | @@ -31,10 +31,11 @@ use draw::edit::{Key, NamedKey, SelectionUnit}; | ... | @@ -31,10 +31,11 @@ use draw::edit::{Key, NamedKey, SelectionUnit}; |
| 31 | use onestore::{ | 31 | use onestore::{ |
| 32 | ExGuid, | 32 | ExGuid, |
| 33 | op::{Edit, Op, PageOp}, | 33 | op::{Edit, Op, PageOp}, |
| 34 | page::{Page, ink::ShapeKind}, | 34 | page::{Page, PageObject, PageParagraph, ink::ShapeKind}, |
| 35 | }; | 35 | }; |
| 36 | use parley::Affinity; | 36 | use parley::Affinity; |
| 37 | use std::{ | 37 | use std::{ |
| 38 | collections::BTreeSet, | ||
| 38 | error::Error, | 39 | error::Error, |
| 39 | ffi::{CStr, CString, c_char, c_void}, | 40 | ffi::{CStr, CString, c_char, c_void}, |
| 40 | mem::ManuallyDrop, | 41 | mem::ManuallyDrop, |
| ... | @@ -460,26 +461,6 @@ impl Canvas { | ... | @@ -460,26 +461,6 @@ impl Canvas { |
| 460 | Ok(true) | 461 | Ok(true) |
| 461 | } | 462 | } |
| 462 | 463 | ||
| 463 | /// The page's text, outline by outline, as the desktop puts a refused edit's on the | ||
| 464 | /// clipboard. | ||
| 465 | fn page_text(&self) -> String { | ||
| 466 | let editor = &self.page.editor; | ||
| 467 | editor | ||
| 468 | .visible_outlines() | ||
| 469 | .chain(editor.caret_outline()) | ||
| 470 | .map(|outline| { | ||
| 471 | outline | ||
| 472 | .document() | ||
| 473 | .paragraphs() | ||
| 474 | .map(canvas::search::shown) | ||
| 475 | .collect::<Vec<_>>() | ||
| 476 | .join("\n") | ||
| 477 | }) | ||
| 478 | .filter(|text| !text.trim().is_empty()) | ||
| 479 | .collect::<Vec<_>>() | ||
| 480 | .join("\n\n") | ||
| 481 | } | ||
| 482 | |||
| 483 | /// Gives the page `color`, a COLORREF, and rule lines `ruled` of `RULE_LINES`, or none. | 464 | /// Gives the page `color`, a COLORREF, and rule lines `ruled` of `RULE_LINES`, or none. |
| 484 | fn set_paper(&mut self, color: Option<u32>, ruled: Option<usize>) -> Result<bool> { | 465 | fn set_paper(&mut self, color: Option<u32>, ruled: Option<usize>) -> Result<bool> { |
| 485 | let rules = ruled | 466 | let rules = ruled |
| ... | @@ -1238,6 +1219,48 @@ pub extern "C" fn sb_view_reload_ready(view: &View) -> bool { | ... | @@ -1238,6 +1219,48 @@ pub extern "C" fn sb_view_reload_ready(view: &View) -> bool { |
| 1238 | .is_ok_and(|edits| edits.pending == 0 && !edits.failed) | 1219 | .is_ok_and(|edits| edits.pending == 0 && !edits.failed) |
| 1239 | } | 1220 | } |
| 1240 | 1221 | ||
| 1222 | /// After the section refused an edit, keeps the page as the view shows it, as OneNote keeps a | ||
| 1223 | /// version it could not merge: a conflict page under the stored page, its differing paragraphs | ||
| 1224 | /// highlighted, or a copy of it where the page is gone. True once the section took it. | ||
| 1225 | #[unsafe(no_mangle)] | ||
| 1226 | pub extern "C" fn sb_view_keep(view: &View) -> bool { | ||
| 1227 | let kept = (|| -> Result<bool> { | ||
| 1228 | let space = view.canvas.space; | ||
| 1229 | let page = view.canvas.page.editor.page()?; | ||
| 1230 | let section = &view.section.section; | ||
| 1231 | let stored = section.page(space).ok(); | ||
| 1232 | if stored.as_ref() == Some(&page) { | ||
| 1233 | return Ok(false); | ||
| 1234 | } | ||
| 1235 | let paragraphs = |page: &Page| -> Vec<PageParagraph> { | ||
| 1236 | page.objects | ||
| 1237 | .iter() | ||
| 1238 | .flat_map(|object| match object { | ||
| 1239 | PageObject::Outline(outline) => outline.paragraphs.clone(), | ||
| 1240 | _ => Vec::new(), | ||
| 1241 | }) | ||
| 1242 | .collect() | ||
| 1243 | }; | ||
| 1244 | let before = stored.as_ref().map(paragraphs).unwrap_or_default(); | ||
| 1245 | let objects: BTreeSet<ExGuid> = paragraphs(&page) | ||
| 1246 | .into_iter() | ||
| 1247 | .filter(|paragraph| !before.contains(paragraph)) | ||
| 1248 | .map(|paragraph| paragraph.id) | ||
| 1249 | .collect(); | ||
| 1250 | let author = view.section.author(); | ||
| 1251 | let op = notebook::session::conflict(space, &page, &objects, &author, stored.is_some())?; | ||
| 1252 | section.replica().apply( | ||
| 1253 | &author, | ||
| 1254 | Edit { | ||
| 1255 | at: library::filetime(), | ||
| 1256 | ops: vec![op], | ||
| 1257 | }, | ||
| 1258 | )?; | ||
| 1259 | Ok(true) | ||
| 1260 | })(); | ||
| 1261 | report(kept).unwrap_or(false) | ||
| 1262 | } | ||
| 1263 | |||
| 1241 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with | 1264 | /// Shows the page as stored after a change made elsewhere, keeping the caret; with |
| 1242 | /// `discard`, after the section refused an edit, drops what the page took since. | 1265 | /// `discard`, after the section refused an edit, drops what the page took since. |
| 1243 | /// A supplied page-point anchor follows its source caret across the refresh. | 1266 | /// A supplied page-point anchor follows its source caret across the refresh. |
| ... | @@ -1664,12 +1687,6 @@ pub unsafe extern "C" fn sb_tag_icon( | ... | @@ -1664,12 +1687,6 @@ pub unsafe extern "C" fn sb_tag_icon( |
| 1664 | true | 1687 | true |
| 1665 | } | 1688 | } |
| 1666 | 1689 | ||
| 1667 | /// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`. | ||
| 1668 | #[unsafe(no_mangle)] | ||
| 1669 | pub extern "C" fn sb_view_page_text(view: &View) -> *mut c_char { | ||
| 1670 | owned(view.canvas.page_text()) | ||
| 1671 | } | ||
| 1672 | |||
| 1673 | /// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's | 1690 | /// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's |
| 1674 | /// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null. | 1691 | /// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null. |
| 1675 | /// | 1692 | /// |
crates/mobile/src/tests.rs+150| ... | @@ -573,6 +573,156 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() { | ... | @@ -573,6 +573,156 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() { |
| 573 | assert!(highlighted); | 573 | assert!(highlighted); |
| 574 | } | 574 | } |
| 575 | 575 | ||
| 576 | #[test] | ||
| 577 | fn a_refused_edit_is_kept_as_a_conflict_page_or_a_copy() { | ||
| 578 | use onestore::page::PageObject; | ||
| 579 | |||
| 580 | let directory = tempfile::tempdir().unwrap(); | ||
| 581 | let file = directory.path().join("Keep.one"); | ||
| 582 | let cache = directory.path().join("cache"); | ||
| 583 | std::fs::write( | ||
| 584 | &file, | ||
| 585 | onestore::create_section("Keep.one", "Original body", "Fixture").unwrap(), | ||
| 586 | ) | ||
| 587 | .unwrap(); | ||
| 588 | let library = Arc::new(Library::open(&file, &cache, true).unwrap()); | ||
| 589 | let section = Section::open( | ||
| 590 | Arc::clone(&library), | ||
| 591 | file.to_str().unwrap(), | ||
| 592 | "Fixture".into(), | ||
| 593 | || {}, | ||
| 594 | ) | ||
| 595 | .unwrap(); | ||
| 596 | let space = section.shared.section.pages().unwrap()[0].0; | ||
| 597 | let (page, read_only) = section.shared.page(space).unwrap(); | ||
| 598 | let outline = page | ||
| 599 | .objects | ||
| 600 | .iter() | ||
| 601 | .find_map(|object| match object { | ||
| 602 | PageObject::Outline(outline) => Some(outline.id), | ||
| 603 | _ => None, | ||
| 604 | }) | ||
| 605 | .unwrap(); | ||
| 606 | let mut view = View { | ||
| 607 | canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), | ||
| 608 | presentation: None, | ||
| 609 | frame: Arc::default(), | ||
| 610 | section: Arc::clone(&section.shared), | ||
| 611 | read_only, | ||
| 612 | reload_pending: false, | ||
| 613 | local_edits: Arc::new(Mutex::new(LocalEdits::default())), | ||
| 614 | }; | ||
| 615 | focus(&mut view.canvas, "Original body"); | ||
| 616 | assert!(sb_select(&mut view, 0, 0)); | ||
| 617 | // Another writer deletes the outline the view still shows. | ||
| 618 | section | ||
| 619 | .shared | ||
| 620 | .section | ||
| 621 | .apply( | ||
| 622 | "Peer", | ||
| 623 | page_edit(space, vec![PageOp::Delete { object: outline }]), | ||
| 624 | ) | ||
| 625 | .unwrap(); | ||
| 626 | section.shared.section.written().unwrap(); | ||
| 627 | if sb_view_format(&view) & 1 == 0 { | ||
| 628 | assert!(sb_view_apply(&mut view, 0)); | ||
| 629 | } | ||
| 630 | assert!(unsafe { sb_insert(&mut view, c"Stale rich 😀 ".as_ptr()) }); | ||
| 631 | let deadline = Instant::now() + Duration::from_secs(10); | ||
| 632 | while section.poll() & 4 == 0 { | ||
| 633 | assert!(Instant::now() < deadline); | ||
| 634 | std::thread::sleep(Duration::from_millis(10)); | ||
| 635 | } | ||
| 636 | assert!(sb_view_keep(&view)); | ||
| 637 | assert!(sb_view_reload(&mut view, true, None)); | ||
| 638 | assert!(!sb_view_keep(&view)); | ||
| 639 | section.shared.section.written().unwrap(); | ||
| 640 | let kept = |conflicts: Vec<(ExGuid, Vec<onestore::ConflictPage>)>| { | ||
| 641 | let [(of, versions)] = conflicts.as_slice() else { | ||
| 642 | panic!("{conflicts:?}"); | ||
| 643 | }; | ||
| 644 | assert_eq!(*of, space); | ||
| 645 | let [version] = versions.as_slice() else { | ||
| 646 | panic!("{versions:?}"); | ||
| 647 | }; | ||
| 648 | assert_eq!(version.user, "Fixture"); | ||
| 649 | version.space | ||
| 650 | }; | ||
| 651 | let version = kept(section.shared.section.conflicts().unwrap()); | ||
| 652 | let (page, read_only) = section.shared.page(version).unwrap(); | ||
| 653 | assert!(read_only); | ||
| 654 | let stale = page.objects.iter().any(|object| match object { | ||
| 655 | PageObject::Outline(outline) => outline.paragraphs.iter().any(|paragraph| { | ||
| 656 | paragraph.format.highlight == Some(canvas::conflict::CONFLICTING) | ||
| 657 | && match &paragraph.content { | ||
| 658 | onestore::page::ParagraphContent::Text(text) => { | ||
| 659 | text.text.text().starts_with("Stale rich 😀 Original body") | ||
| 660 | && text.text.spans()[0].format.bold == Some(true) | ||
| 661 | } | ||
| 662 | _ => false, | ||
| 663 | } | ||
| 664 | }), | ||
| 665 | _ => false, | ||
| 666 | }); | ||
| 667 | assert!(stale, "{page:?}"); | ||
| 668 | let shared = Arc::clone(&section.shared); | ||
| 669 | drop((view, section, library)); | ||
| 670 | Arc::into_inner(shared) | ||
| 671 | .expect("No other writer") | ||
| 672 | .section | ||
| 673 | .close() | ||
| 674 | .unwrap(); | ||
| 675 | let library = Arc::new(Library::open(&file, &cache, true).unwrap()); | ||
| 676 | let section = open(&library, "Keep.one"); | ||
| 677 | assert_eq!(kept(section.shared.section.conflicts().unwrap()), version); | ||
| 678 | |||
| 679 | // A change outside the text, to a page deleted meanwhile, is kept as a copy of the page. | ||
| 680 | let (page, read_only) = section.shared.page(space).unwrap(); | ||
| 681 | let title = page.title.clone(); | ||
| 682 | let mut view = View { | ||
| 683 | canvas: ManuallyDrop::new(Canvas::new(space, page, [640, 960], 2.0).unwrap()), | ||
| 684 | presentation: None, | ||
| 685 | frame: Arc::default(), | ||
| 686 | section: Arc::clone(&section.shared), | ||
| 687 | read_only, | ||
| 688 | reload_pending: false, | ||
| 689 | local_edits: Arc::new(Mutex::new(LocalEdits::default())), | ||
| 690 | }; | ||
| 691 | section | ||
| 692 | .shared | ||
| 693 | .section | ||
| 694 | .apply( | ||
| 695 | "Peer", | ||
| 696 | Edit { | ||
| 697 | at: library::filetime(), | ||
| 698 | ops: vec![Op::Section(onestore::op::SectionOp::Delete(vec![space]))], | ||
| 699 | }, | ||
| 700 | ) | ||
| 701 | .unwrap(); | ||
| 702 | section.shared.section.written().unwrap(); | ||
| 703 | assert!(sb_view_set_paper(&mut view, 0x77, 0x88, 0x99, -1)); | ||
| 704 | let deadline = Instant::now() + Duration::from_secs(10); | ||
| 705 | while section.poll() & 4 == 0 { | ||
| 706 | assert!(Instant::now() < deadline); | ||
| 707 | std::thread::sleep(Duration::from_millis(10)); | ||
| 708 | } | ||
| 709 | assert!(sb_view_keep(&view)); | ||
| 710 | let copies: Vec<_> = section | ||
| 711 | .shared | ||
| 712 | .section | ||
| 713 | .pages() | ||
| 714 | .unwrap() | ||
| 715 | .into_iter() | ||
| 716 | .filter(|(listed, _, _)| *listed != space) | ||
| 717 | .map(|(listed, _, _)| section.shared.section.page(listed).unwrap()) | ||
| 718 | .collect(); | ||
| 719 | let [copy] = copies.as_slice() else { | ||
| 720 | panic!("{copies:?}"); | ||
| 721 | }; | ||
| 722 | assert_eq!(copy.color, Some(0x0099_8877)); | ||
| 723 | assert_eq!(copy.title, title); | ||
| 724 | } | ||
| 725 | |||
| 576 | #[test] | 726 | #[test] |
| 577 | fn search_finds_pages_by_title_and_text_across_sections() { | 727 | fn search_finds_pages_by_title_and_text_across_sections() { |
| 578 | let directory = tempfile::tempdir().unwrap(); | 728 | let directory = tempfile::tempdir().unwrap(); |
crates/notebook/src/merge.rs+72-33| ... | @@ -203,36 +203,9 @@ pub(crate) fn conflict_page( | ... | @@ -203,36 +203,9 @@ pub(crate) fn conflict_page( |
| 203 | at: u64, | 203 | at: u64, |
| 204 | guid: Option<[u8; 16]>, | 204 | guid: Option<[u8; 16]>, |
| 205 | ) -> Result<Edit> { | 205 | ) -> Result<Edit> { |
| 206 | let placed = |creation: PageCreation| match guid { | ||
| 207 | Some(guid) => creation.in_space(guid), | ||
| 208 | None => Ok(creation), | ||
| 209 | }; | ||
| 210 | let mut page = page.clone(); | ||
| 211 | page.objects | ||
| 212 | .retain(|object| !matches!(object, PageObject::Unsupported(_))); | ||
| 213 | let listed = order(new)?; | 206 | let listed = order(new)?; |
| 214 | let ops = if listed.iter().any(|(listed, _)| *listed == space) { | 207 | let ops = if listed.iter().any(|(listed, _)| *listed == space) { |
| 215 | let index = Index::of(&page); | 208 | vec![conflict_op(space, page, objects, author, guid)?] |
| 216 | let mut marked: Vec<ExGuid> = objects | ||
| 217 | .iter() | ||
| 218 | .copied() | ||
| 219 | .filter(|id| index.nodes.contains_key(id)) | ||
| 220 | .collect(); | ||
| 221 | let copy = page.copy_with(&mut marked)?; | ||
| 222 | let titled = page | ||
| 223 | .objects | ||
| 224 | .iter() | ||
| 225 | .any(|object| matches!(object, PageObject::Title(_))); | ||
| 226 | vec![Op::Section(SectionOp::Conflict { | ||
| 227 | of: space, | ||
| 228 | creation: placed(PageCreation::new( | ||
| 229 | None, | ||
| 230 | titled.then_some(page.title.as_str()), | ||
| 231 | author, | ||
| 232 | )?)?, | ||
| 233 | page: copy, | ||
| 234 | objects: marked, | ||
| 235 | })] | ||
| 236 | } else { | 209 | } else { |
| 237 | // A copy under fresh identities, as OneNote 2010 puts a removed page back. | 210 | // A copy under fresh identities, as OneNote 2010 puts a removed page back. |
| 238 | let queued = order(local)?; | 211 | let queued = order(local)?; |
| ... | @@ -243,11 +216,11 @@ pub(crate) fn conflict_page( | ... | @@ -243,11 +216,11 @@ pub(crate) fn conflict_page( |
| 243 | .map(|(next, _)| *next) | 216 | .map(|(next, _)| *next) |
| 244 | .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next)); | 217 | .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next)); |
| 245 | let before = series_start(new, before)?; | 218 | let before = series_start(new, before)?; |
| 246 | let creation = placed(PageCreation::new(before, Some(&page.title), author)?)?; | 219 | let mut creation = PageCreation::new(before, Some(&page.title), author)?; |
| 247 | let mut ops = vec![Op::Section(SectionOp::Import { | 220 | if let Some(guid) = guid { |
| 248 | creation: creation.clone(), | 221 | creation = creation.in_space(guid)?; |
| 249 | page: page.copy()?, | 222 | } |
| 250 | })]; | 223 | let mut ops = vec![import(page, creation.clone())?]; |
| 251 | if let Some(level) = at.map(|at| queued[at].1).filter(|level| *level > 1) { | 224 | if let Some(level) = at.map(|at| queued[at].1).filter(|level| *level > 1) { |
| 252 | ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::set_level( | 225 | ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::set_level( |
| 253 | creation.space(), | 226 | creation.space(), |
| ... | @@ -261,6 +234,72 @@ pub(crate) fn conflict_page( | ... | @@ -261,6 +234,72 @@ pub(crate) fn conflict_page( |
| 261 | Ok(edit) | 234 | Ok(edit) |
| 262 | } | 235 | } |
| 263 | 236 | ||
| 237 | /// The op keeping `page`, a version of the page in `space` the section could not take: a | ||
| 238 | /// conflict page under it with `objects` highlighted, or, where the section no longer lists | ||
| 239 | /// the page, a copy of it at the end under fresh identities. Content outside the page model | ||
| 240 | /// stays behind. | ||
| 241 | pub fn conflict( | ||
| 242 | space: ExGuid, | ||
| 243 | page: &Page, | ||
| 244 | objects: &BTreeSet<ExGuid>, | ||
| 245 | author: &str, | ||
| 246 | listed: bool, | ||
| 247 | ) -> Result<Op> { | ||
| 248 | if listed { | ||
| 249 | conflict_op(space, page, objects, author, None) | ||
| 250 | } else { | ||
| 251 | import(page, PageCreation::new(None, Some(&page.title), author)?) | ||
| 252 | } | ||
| 253 | } | ||
| 254 | |||
| 255 | /// `page` as the page model holds it, without content outside it. | ||
| 256 | fn modelled(page: &Page) -> Page { | ||
| 257 | let mut page = page.clone(); | ||
| 258 | page.objects | ||
| 259 | .retain(|object| !matches!(object, PageObject::Unsupported(_))); | ||
| 260 | page | ||
| 261 | } | ||
| 262 | |||
| 263 | fn import(page: &Page, creation: PageCreation) -> Result<Op> { | ||
| 264 | Ok(Op::Section(SectionOp::Import { | ||
| 265 | creation, | ||
| 266 | page: modelled(page).copy()?, | ||
| 267 | })) | ||
| 268 | } | ||
| 269 | |||
| 270 | /// The conflict page keeping `page` under the page in `space`, marking `objects`, in the space | ||
| 271 | /// `guid` names or a fresh one. | ||
| 272 | fn conflict_op( | ||
| 273 | space: ExGuid, | ||
| 274 | page: &Page, | ||
| 275 | objects: &BTreeSet<ExGuid>, | ||
| 276 | author: &str, | ||
| 277 | guid: Option<[u8; 16]>, | ||
| 278 | ) -> Result<Op> { | ||
| 279 | let page = modelled(page); | ||
| 280 | let index = Index::of(&page); | ||
| 281 | let mut marked: Vec<ExGuid> = objects | ||
| 282 | .iter() | ||
| 283 | .copied() | ||
| 284 | .filter(|id| index.nodes.contains_key(id)) | ||
| 285 | .collect(); | ||
| 286 | let copy = page.copy_with(&mut marked)?; | ||
| 287 | let titled = page | ||
| 288 | .objects | ||
| 289 | .iter() | ||
| 290 | .any(|object| matches!(object, PageObject::Title(_))); | ||
| 291 | let mut creation = PageCreation::new(None, titled.then_some(page.title.as_str()), author)?; | ||
| 292 | if let Some(guid) = guid { | ||
| 293 | creation = creation.in_space(guid)?; | ||
| 294 | } | ||
| 295 | Ok(Op::Section(SectionOp::Conflict { | ||
| 296 | of: space, | ||
| 297 | creation, | ||
| 298 | page: copy, | ||
| 299 | objects: marked, | ||
| 300 | })) | ||
| 301 | } | ||
| 302 | |||
| 264 | /// Pages the base and the remote both list that the remote moved: into another series or | 303 | /// Pages the base and the remote both list that the remote moved: into another series or |
| 265 | /// level, or out of base order among the rest. OneNote 2010 and this writer give a moved | 304 | /// level, or out of base order among the rest. OneNote 2010 and this writer give a moved |
| 266 | /// page a series of its own; older builds of this writer moved a page within its series. | 305 | /// page a series of its own; older builds of this writer moved a page within its series. |
crates/notebook/src/session.rs+1| ... | @@ -2,6 +2,7 @@ | ... | @@ -2,6 +2,7 @@ |
| 2 | //! publishes their edits to the section file in the background. | 2 | //! publishes their edits to the section file in the background. |
| 3 | 3 | ||
| 4 | pub use crate::background::{Background, Known, Listener}; | 4 | pub use crate::background::{Background, Known, Listener}; |
| 5 | pub use crate::merge::conflict; | ||
| 5 | use crate::{ | 6 | use crate::{ |
| 6 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, | 7 | EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs, |
| 7 | }; | 8 | }; |