authorclover caruso <git@paperclover.net> 2026-10-15 02:54:23-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 04:13:21-07:00
logc414df0810a16db7b85ef4c2abddc5dbd289154b
tree633027442f87da9d208fe2425482025f091edb52
parentefa39925fa1ba017f1d50b3cb409f4bd9b11257c
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

feat(mobile): keep refused edits as conflict pages

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.5

7 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)) } }
10521052
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 }
10541055
1055 /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming1056 /// 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 ?? 0410 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]);
105bool sb_view_reload_ready(const View *view);105bool 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.
107bool sb_view_reload(View *view, bool discard, float anchor[2]);107bool sb_view_reload(View *view, bool discard, float anchor[2]);
108bool sb_view_keep(const View *view);
108void sb_view_focus(View *view, bool focused);109void sb_view_focus(View *view, bool focused);
109void sb_view_content(View *view, float bounds[4]);110void sb_view_content(View *view, float bounds[4]);
110bool sb_view_block(const View *view, float x, float y, float rect[4]);111bool 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]);
137char *sb_view_paper(const View *view);138char *sb_view_paper(const View *view);
138bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);139bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
139bool sb_view_set_art(View *view, const char *name);140bool sb_view_set_art(View *view, const char *name);
140char *sb_view_page_text(const View *view);
141uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length);141uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length);
142void sb_bytes_free(uint8_t *bytes, size_t length);142void sb_bytes_free(uint8_t *bytes, size_t length);
143int8_t sb_view_date_request(View *view, int64_t *seconds);143int8_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};
31use onestore::{31use 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};
36use parley::Affinity;36use parley::Affinity;
37use std::{37use 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 }
462463
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 = ruled466 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}
12401221
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)]
1226pub 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; with1264/// 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 true1687 true
1665}1688}
16661689
1667/// The page's text, for the clipboard when an edit is refused; freed with `sb_string_free`.
1668#[unsafe(no_mangle)]
1669pub 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's1690/// 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}
575575
576#[test]
577fn 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]
577fn search_finds_pages_by_title_and_text_across_sections() {727fn 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}
263236
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.
241pub 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.
256fn modelled(page: &Page) -> Page {
257 let mut page = page.clone();
258 page.objects
259 .retain(|object| !matches!(object, PageObject::Unsupported(_)));
260 page
261}
262
263fn 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.
272fn 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 or303/// 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 moved304/// 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.
33
4pub use crate::background::{Background, Known, Listener};4pub use crate::background::{Background, Known, Listener};
5pub use crate::merge::conflict;
5use crate::{6use 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};