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
10501050 /// The title as typed; nil on a page without an editable title.
10511051 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
10551056 /// The page on `paper`, in points, as OneNote 2010 prints it, each sheet's footer naming
10561057 /// `section`.
apps/ios/Snowbound/PageViewController.swift+3-3
......@@ -409,10 +409,10 @@ final class PageViewController: UIViewController, PHPickerViewControllerDelegate
409409 @objc private func sectionChanged(_ notification: Notification) {
410410 let flags = notification.userInfo?["flags"] as? UInt32 ?? 0
411411 if flags & Section.rejected != 0 {
412 // Nothing typed is lost unannounced: the page's text goes on the clipboard.
413 UIPasteboard.general.string = canvas.pageText
412 let kept = canvas.keep()
414413 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." : ""))
416416 } else if flags & Section.remote != 0 {
417417 canvas.reload(discard: false)
418418 } else {
crates/mobile/include/snowbound.h+1-1
......@@ -105,6 +105,7 @@ bool sb_view_set_reading(View *view, float width, float anchor[2]);
105105bool sb_view_reload_ready(const View *view);
106106// A non-null page-point anchor follows its source caret across the refresh.
107107bool sb_view_reload(View *view, bool discard, float anchor[2]);
108bool sb_view_keep(const View *view);
108109void sb_view_focus(View *view, bool focused);
109110void sb_view_content(View *view, float bounds[4]);
110111bool 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]);
137138char *sb_view_paper(const View *view);
138139bool sb_view_set_paper(View *view, int16_t red, uint8_t green, uint8_t blue, int8_t ruled);
139140bool sb_view_set_art(View *view, const char *name);
140char *sb_view_page_text(const View *view);
141141uint8_t *sb_view_pdf(const View *view, float width, float height, const char *section, size_t *length);
142142void sb_bytes_free(uint8_t *bytes, size_t length);
143143int8_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};
3131use onestore::{
3232 ExGuid,
3333 op::{Edit, Op, PageOp},
34 page::{Page, ink::ShapeKind},
34 page::{Page, PageObject, PageParagraph, ink::ShapeKind},
3535};
3636use parley::Affinity;
3737use std::{
38 collections::BTreeSet,
3839 error::Error,
3940 ffi::{CStr, CString, c_char, c_void},
4041 mem::ManuallyDrop,
......@@ -460,26 +461,6 @@ impl Canvas {
460461 Ok(true)
461462 }
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
483464 /// Gives the page `color`, a COLORREF, and rule lines `ruled` of `RULE_LINES`, or none.
484465 fn set_paper(&mut self, color: Option<u32>, ruled: Option<usize>) -> Result<bool> {
485466 let rules = ruled
......@@ -1238,6 +1219,48 @@ pub extern "C" fn sb_view_reload_ready(view: &View) -> bool {
12381219 .is_ok_and(|edits| edits.pending == 0 && !edits.failed)
12391220}
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
12411264/// Shows the page as stored after a change made elsewhere, keeping the caret; with
12421265/// `discard`, after the section refused an edit, drops what the page took since.
12431266/// A supplied page-point anchor follows its source caret across the refresh.
......@@ -1664,12 +1687,6 @@ pub unsafe extern "C" fn sb_tag_icon(
16641687 true
16651688}
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
16731690/// The page on paper `width` × `height` points as OneNote 2010 prints it, each sheet's
16741691/// footer naming `section`: `length` bytes of PDF, freed with `sb_bytes_free`, or null.
16751692///
crates/mobile/src/tests.rs+150
......@@ -573,6 +573,156 @@ fn conflict_pages_list_under_their_page_and_take_no_edits() {
573573 assert!(highlighted);
574574}
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
576726#[test]
577727fn search_finds_pages_by_title_and_text_across_sections() {
578728 let directory = tempfile::tempdir().unwrap();
crates/notebook/src/merge.rs+72-33
......@@ -203,36 +203,9 @@ pub(crate) fn conflict_page(
203203 at: u64,
204204 guid: Option<[u8; 16]>,
205205) -> 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(_)));
213206 let listed = order(new)?;
214207 let ops = if listed.iter().any(|(listed, _)| *listed == space) {
215 let index = Index::of(&page);
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 })]
208 vec![conflict_op(space, page, objects, author, guid)?]
236209 } else {
237210 // A copy under fresh identities, as OneNote 2010 puts a removed page back.
238211 let queued = order(local)?;
......@@ -243,11 +216,11 @@ pub(crate) fn conflict_page(
243216 .map(|(next, _)| *next)
244217 .find(|next| !moved.contains(next) && listed.iter().any(|(listed, _)| listed == next));
245218 let before = series_start(new, before)?;
246 let creation = placed(PageCreation::new(before, Some(&page.title), author)?)?;
247 let mut ops = vec![Op::Section(SectionOp::Import {
248 creation: creation.clone(),
249 page: page.copy()?,
250 })];
219 let mut creation = PageCreation::new(before, Some(&page.title), author)?;
220 if let Some(guid) = guid {
221 creation = creation.in_space(guid)?;
222 }
223 let mut ops = vec![import(page, creation.clone())?];
251224 if let Some(level) = at.map(|at| queued[at].1).filter(|level| *level > 1) {
252225 ops.push(Op::Section(SectionOp::Pages(vec![PageEdit::set_level(
253226 creation.space(),
......@@ -261,6 +234,72 @@ pub(crate) fn conflict_page(
261234 Ok(edit)
262235}
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
264303/// Pages the base and the remote both list that the remote moved: into another series or
265304/// level, or out of base order among the rest. OneNote 2010 and this writer give a moved
266305/// 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 @@
22//! publishes their edits to the section file in the background.
33
44pub use crate::background::{Background, Known, Listener};
5pub use crate::merge::conflict;
56use crate::{
67 EditStatus, Error, PendingEdit, Remote, Replica, Resolution, Result, SyncWorker, discover, fs,
78};