authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-11 04:01:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-12 17:00:32-07:00
logcffb5ce4bca95f8c242b5513d981a708206cee41
treecb09fcef5f6794e742e6945aec25c7e37da23252
parent7d2feb6f84ba766193b4041b9d6ee375a354e98e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: open sections through a replica-backed session for the app

The application edits through notebook::session. Section::open binds a section file to a replica under a cache directory named by the document identity and publishes in the background through a file remote (read_file, commit_file and the new confirm_file_snapshot); save(space, before, after, author) reports Queued, Unchanged or Stale, the last when the stored page no longer matches the model the edit started from. The recovery contract is queue(), conflicts(), remote_page(space), review(id, after) and export_recovery(path). The canvas editor gains CanvasEditor::page(), rebuilding the stored model from its state so an unedited import round-trips exactly and an edited one feeds the page writer; import retains only what it used to overwrite. snowbound opens a page with --section FILE TITLE [--cache DIR], saves every change through the session, shows saving, saved, conflict or offline in the window title, reloads a page changed underneath the editor, and resolves a conflict with ⌘⇧K (keep mine) or ⌘⇧T (take theirs). tools/session_acceptance.py is the executable acceptance scenario: the session_client example edits pages on the mounted Samba share across two launches and a OneNote clone reopens the result; the first run passed on four pages (evidence/m4/session-acceptance). Assisted-by: claude-fable-5.1

12 files changed, 1036 insertions(+), 53 deletions(-)

Cargo.lock+1
...@@ -2613,6 +2613,7 @@ dependencies = [...@@ -2613,6 +2613,7 @@ dependencies = [
2613 "accesskit_winit",2613 "accesskit_winit",
2614 "arboard",2614 "arboard",
2615 "canvas",2615 "canvas",
2616 "notebook",
2616 "objc2 0.5.2",2617 "objc2 0.5.2",
2617 "objc2-app-kit 0.2.2",2618 "objc2-app-kit 0.2.2",
2618 "objc2-foundation 0.2.2",2619 "objc2-foundation 0.2.2",
crates/canvas/src/editor.rs+263-6
...@@ -9,7 +9,7 @@ use crate::{...@@ -9,7 +9,7 @@ use crate::{
9};9};
10use onestore::ExGuid;10use onestore::ExGuid;
11use onestore::page::text::{EditError, Paragraph};11use onestore::page::text::{EditError, Paragraph};
12use onestore::page::{Definition, Outline, PageParagraph};12use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title};
13use parley::{13use parley::{
14 Affinity, BoundingBox,14 Affinity, BoundingBox,
15 editing::{Cursor, Selection as ParagraphSelection},15 editing::{Cursor, Selection as ParagraphSelection},
...@@ -102,6 +102,7 @@ pub struct CanvasEditor {...@@ -102,6 +102,7 @@ pub struct CanvasEditor {
102 date: Option<PageDate>,102 date: Option<PageDate>,
103 outlines: Vec<TextOutline>,103 outlines: Vec<TextOutline>,
104 definitions: BTreeMap<ExGuid, Definition>,104 definitions: BTreeMap<ExGuid, Definition>,
105 header: PageHeader,
105 active: Focus,106 active: Focus,
106 undo: Vec<History>,107 undo: Vec<History>,
107 redo: Vec<History>,108 redo: Vec<History>,
...@@ -109,6 +110,15 @@ pub struct CanvasEditor {...@@ -109,6 +110,15 @@ pub struct CanvasEditor {
109 preferred_x: Option<f32>,110 preferred_x: Option<f32>,
110}111}
111112
113/// Imported page state the editable content does not carry.
114#[derive(Default)]
115struct PageHeader {
116 title: String,
117 created: Option<u64>,
118 margin_origin: [f32; 2],
119 areas: Vec<page::TitleArea>,
120}
121
112enum Focus {122enum Focus {
113 Outline(usize),123 Outline(usize),
114 Draft {124 Draft {
...@@ -643,6 +653,7 @@ impl CanvasEditor {...@@ -643,6 +653,7 @@ impl CanvasEditor {
643 definitions: BTreeMap::new(),653 definitions: BTreeMap::new(),
644 objects: Vec::new(),654 objects: Vec::new(),
645 date: None,655 date: None,
656 header: PageHeader::default(),
646 active,657 active,
647 undo: Vec::new(),658 undo: Vec::new(),
648 redo: Vec::new(),659 redo: Vec::new(),
...@@ -651,11 +662,13 @@ impl CanvasEditor {...@@ -651,11 +662,13 @@ impl CanvasEditor {
651 })662 })
652 }663 }
653664
654 pub fn from_page(665 pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> {
655 mut page: onestore::page::Page,666 let page::Import {
656 engine: &mut TextEngine,667 objects,
657 ) -> Result<Self, EditorError> {668 mut outlines,
658 let (objects, mut outlines, date) = page::build(&mut page, engine, true)?;669 date,
670 areas,
671 } = page::build(&mut page, engine, true)?;
659 let needs_caret = outlines.is_empty();672 let needs_caret = outlines.is_empty();
660 if needs_caret {673 if needs_caret {
661 let x = objects674 let x = objects
...@@ -680,6 +693,12 @@ impl CanvasEditor {...@@ -680,6 +693,12 @@ impl CanvasEditor {
680 };693 };
681 }694 }
682 editor.objects = objects;695 editor.objects = objects;
696 editor.header = PageHeader {
697 title: page.title,
698 created: page.created,
699 margin_origin: page.margin_origin,
700 areas,
701 };
683 let mut ids = BTreeSet::new();702 let mut ids = BTreeSet::new();
684 if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {703 if !editor.object_layouts().all(|(id, _)| ids.insert(id)) {
685 return Err(EditError::InvalidStructure.into());704 return Err(EditError::InvalidStructure.into());
...@@ -687,6 +706,74 @@ impl CanvasEditor {...@@ -687,6 +706,74 @@ impl CanvasEditor {
687 Ok(editor)706 Ok(editor)
688 }707 }
689708
709 /// Rebuilds the stored page, restoring the title areas and read-only objects import split up.
710 pub fn page(&self) -> Result<Page, EditorError> {
711 let mut objects: Vec<PageObject> = Vec::new();
712 for content in &self.objects {
713 let mut outline = match content {
714 page::Content::Editable(id) => {
715 match self.outlines.iter().find(|outline| outline.id == *id) {
716 Some(outline) => outline.snapshot(),
717 None => continue,
718 }
719 }
720 page::Content::Outline { source, .. } => source.clone(),
721 page::Content::Date { .. } => self
722 .date
723 .as_ref()
724 .ok_or(EditError::InvalidStructure)?
725 .source()
726 .clone(),
727 page::Content::Image(image) => {
728 objects.push(PageObject::Image(image.clone()));
729 continue;
730 }
731 page::Content::ReadOnly(object) => {
732 objects.push(object.source.clone());
733 continue;
734 }
735 };
736 let area = self
737 .header
738 .areas
739 .iter()
740 .find_map(|area| Some((area, *area.origins.get(&outline.id)?)));
741 let Some((area, origin)) = area else {
742 objects.push(PageObject::Outline(outline));
743 continue;
744 };
745 [outline.layout.x, outline.layout.y] = origin;
746 match objects.last_mut() {
747 Some(PageObject::Title(title)) if title.id == area.id => {
748 title.outlines.push(outline)
749 }
750 _ => objects.push(PageObject::Title(Title {
751 id: area.id,
752 date: area.date,
753 layout: area.layout.clone(),
754 outlines: vec![outline],
755 })),
756 }
757 }
758 objects.extend(
759 self.outlines
760 .iter()
761 .filter(|outline| !self.has_page_outline(outline.id))
762 .map(|outline| PageObject::Outline(outline.snapshot())),
763 );
764 Ok(Page {
765 title: self.header.title.clone(),
766 created: self
767 .date
768 .as_ref()
769 .map(PageDate::timestamp)
770 .or(self.header.created),
771 margin_origin: self.header.margin_origin,
772 objects,
773 definitions: self.definitions.clone(),
774 })
775 }
776
690 /// Whether this outline occupies a slot in the imported page's paint order.777 /// Whether this outline occupies a slot in the imported page's paint order.
691 pub fn has_page_outline(&self, id: ExGuid) -> bool {778 pub fn has_page_outline(&self, id: ExGuid) -> bool {
692 self.objects779 self.objects
...@@ -896,6 +983,7 @@ impl CanvasEditor {...@@ -896,6 +983,7 @@ impl CanvasEditor {
896 definitions,983 definitions,
897 objects: Vec::new(),984 objects: Vec::new(),
898 date,985 date,
986 header: PageHeader::default(),
899 active: Focus::Outline(0),987 active: Focus::Outline(0),
900 undo: Vec::new(),988 undo: Vec::new(),
901 redo: Vec::new(),989 redo: Vec::new(),
...@@ -7108,4 +7196,173 @@ mod tests {...@@ -7108,4 +7196,173 @@ mod tests {
7108 }7196 }
7109 assert!(!editor.redo(&mut engine).unwrap());7197 assert!(!editor.redo(&mut engine).unwrap());
7110 }7198 }
7199
7200 const CORPUS: [&[u8]; 3] = [
7201 include_bytes!("../../../corpus/canvas/baseline-anchors.one"),
7202 include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"),
7203 include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"),
7204 ];
7205
7206 fn corpus_pages(section: &[u8]) -> Vec<(ExGuid, Page)> {
7207 use onestore::{RevisionIndex, Store, document::Document};
7208 let store = Store::parse(section).unwrap();
7209 let index = RevisionIndex::parse(&store).unwrap();
7210 let document = Document::parse(&index).unwrap();
7211 let mut spaces = document
7212 .pages()
7213 .unwrap()
7214 .into_iter()
7215 .map(|(space, _)| space)
7216 .collect::<Vec<_>>();
7217 spaces.dedup();
7218 spaces
7219 .into_iter()
7220 .map(|space| (space, Page::from_space(&document, space).unwrap()))
7221 .collect()
7222 }
7223
7224 fn corpus_page(section: &[u8], title: &str) -> (ExGuid, Page) {
7225 corpus_pages(section)
7226 .into_iter()
7227 .find(|(_, page)| page.title == title)
7228 .unwrap()
7229 }
7230
7231 fn body_text(page: &Page, id: ExGuid) -> Option<String> {
7232 page.objects.iter().find_map(|object| match object {
7233 PageObject::Outline(outline) if outline.id == id => Some(
7234 outline
7235 .paragraphs
7236 .iter()
7237 .map(|paragraph| paragraph.text().unwrap().text.text())
7238 .collect(),
7239 ),
7240 _ => None,
7241 })
7242 }
7243
7244 #[test]
7245 fn an_unedited_page_rebuilds_into_the_model_it_was_imported_from() {
7246 let mut pages = 0;
7247 for section in CORPUS {
7248 for (_, page) in corpus_pages(section) {
7249 let mut engine = TextEngine::default();
7250 let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
7251 assert_eq!(editor.page().unwrap(), page);
7252 pages += 1;
7253 }
7254 }
7255 assert_eq!(pages, 30);
7256 }
7257
7258 #[test]
7259 fn typing_moving_resizing_adding_and_deleting_reach_the_rebuilt_page() {
7260 let mut engine = TextEngine::default();
7261 let (_, source) = corpus_page(CORPUS[0], "Baseline anchors");
7262 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
7263 let removed = editor
7264 .outlines
7265 .iter()
7266 .find(|outline| {
7267 !outline.title
7268 && outline.document.nodes().len() == 1
7269 && outline.document.validate_flat().is_ok()
7270 })
7271 .unwrap()
7272 .id;
7273 let bodies = editor
7274 .outlines
7275 .iter()
7276 .filter(|outline| !outline.title && outline.id != removed)
7277 .map(|outline| outline.id)
7278 .collect::<Vec<_>>();
7279 editor.focus_outline(bodies[0]).unwrap();
7280 editor.insert(&mut engine, "typed").unwrap();
7281 editor.move_outline(bodies[1], [123.0, 456.0]).unwrap();
7282 editor.focus_outline(bodies[2]).unwrap();
7283 editor.resize(&mut engine, 200.0).unwrap();
7284 editor.focus_outline(removed).unwrap();
7285 editor.select_all().unwrap();
7286 assert!(editor.delete(&mut engine, false).unwrap());
7287 let added = editor
7288 .create_outline(&mut engine, [24.0, 600.0], 300.0)
7289 .unwrap();
7290 editor.insert(&mut engine, "added").unwrap();
7291
7292 let page = editor.page().unwrap();
7293 assert_eq!(page.title, source.title);
7294 assert_eq!(page.created, source.created);
7295 assert_eq!(page.margin_origin, source.margin_origin);
7296 assert!(body_text(&page, bodies[0]).unwrap().starts_with("typed"));
7297 let moved = page
7298 .objects
7299 .iter()
7300 .find_map(|object| match object {
7301 PageObject::Outline(outline) if outline.id == bodies[1] => Some(&outline.layout),
7302 _ => None,
7303 })
7304 .unwrap();
7305 assert_eq!([moved.x, moved.y], [Some(123.0), Some(456.0)]);
7306 let resized = page
7307 .objects
7308 .iter()
7309 .find_map(|object| match object {
7310 PageObject::Outline(outline) if outline.id == bodies[2] => Some(&outline.layout),
7311 _ => None,
7312 })
7313 .unwrap();
7314 assert_eq!(resized.max_width, Some(200.0));
7315 assert_eq!(resized.width_set_by_user, Some(true));
7316 assert!(body_text(&page, removed).is_none());
7317 assert_eq!(body_text(&page, added).as_deref(), Some("added"));
7318 assert_eq!(page.objects.last().unwrap().id(), added);
7319 assert_eq!(
7320 page.objects.iter().map(PageObject::id).collect::<Vec<_>>(),
7321 source
7322 .objects
7323 .iter()
7324 .map(PageObject::id)
7325 .filter(|id| *id != removed)
7326 .chain([added])
7327 .collect::<Vec<_>>()
7328 );
7329 assert_eq!(
7330 page.objects
7331 .iter()
7332 .find(|object| matches!(object, PageObject::Title(_))),
7333 source
7334 .objects
7335 .iter()
7336 .find(|object| matches!(object, PageObject::Title(_)))
7337 );
7338
7339 let mut engine = TextEngine::default();
7340 let reimported = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
7341 assert_eq!(reimported.page().unwrap(), page);
7342 }
7343
7344 #[test]
7345 fn an_edited_page_writes_back_through_the_page_writer() {
7346 let mut engine = TextEngine::default();
7347 let (space, source) = corpus_page(CORPUS[2], "Split middle");
7348 let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap();
7349 let body = editor
7350 .outlines
7351 .iter()
7352 .find(|outline| !outline.title)
7353 .unwrap()
7354 .id;
7355 editor.focus_outline(body).unwrap();
7356 editor.insert(&mut engine, "Edited ").unwrap();
7357 let page = editor.page().unwrap();
7358 let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap();
7359 let reread = corpus_pages(written.as_bytes())
7360 .into_iter()
7361 .find_map(|(candidate, page)| (candidate == space).then_some(page))
7362 .unwrap();
7363 let edited = body_text(&page, body).unwrap();
7364 assert!(edited.starts_with("Edited "));
7365 assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str()));
7366 assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str()));
7367 }
7111}7368}
crates/canvas/src/editor/page.rs+41-16
...@@ -6,6 +6,22 @@ use crate::{...@@ -6,6 +6,22 @@ use crate::{
6};6};
7use onestore::page::text::Paragraph;7use onestore::page::text::Paragraph;
8use onestore::page::{Image, Outline, Page, PageObject};8use onestore::page::{Image, Outline, Page, PageObject};
9use std::collections::BTreeMap;
10
11/// A title object's own state, plus the child origins `build` replaces with page coordinates.
12pub(crate) struct TitleArea {
13 pub id: onestore::ExGuid,
14 pub date: Option<onestore::ExGuid>,
15 pub layout: onestore::document::Layout,
16 pub origins: BTreeMap<onestore::ExGuid, [Option<f32>; 2]>,
17}
18
19pub(crate) struct Import {
20 pub objects: Vec<Content>,
21 pub outlines: Vec<TextOutline>,
22 pub date: Option<PageDate>,
23 pub areas: Vec<TitleArea>,
24}
925
10pub(crate) enum Content {26pub(crate) enum Content {
11 Date {27 Date {
...@@ -25,6 +41,8 @@ pub struct ReadOnlyObject {...@@ -25,6 +41,8 @@ pub struct ReadOnlyObject {
25 pub source: PageObject,41 pub source: PageObject,
26 pub message: &'static str,42 pub message: &'static str,
27 pub(crate) label: TextLayout,43 pub(crate) label: TextLayout,
44 /// Title coordinates are stored relative to the page margin; nothing else is offset.
45 offset: [f32; 2],
28}46}
2947
30impl Content {48impl Content {
...@@ -51,7 +69,7 @@ impl Content {...@@ -51,7 +69,7 @@ impl Content {
5169
52impl ReadOnlyObject {70impl ReadOnlyObject {
53 fn new(71 fn new(
54 mut source: PageObject,72 source: PageObject,
55 margin: [f32; 2],73 margin: [f32; 2],
56 message: &'static str,74 message: &'static str,
57 engine: &mut TextEngine,75 engine: &mut TextEngine,
...@@ -61,15 +79,9 @@ impl ReadOnlyObject {...@@ -61,15 +79,9 @@ impl ReadOnlyObject {
61 } else {79 } else {
62 [0.0; 2]80 [0.0; 2]
63 };81 };
64 let layout = source.layout_mut();82 let layout = source.layout();
65 let x = layout.x.unwrap_or(0.0) + offset[0];83 let x = layout.x.unwrap_or(0.0) + offset[0];
66 let y = layout.y.unwrap_or(0.0) + offset[1];84 let y = layout.y.unwrap_or(0.0) + offset[1];
67 if offset[0] != 0.0 {
68 layout.x = Some(x);
69 }
70 if offset[1] != 0.0 {
71 layout.y = Some(y);
72 }
73 let width = layout.max_width.unwrap_or(160.0);85 let width = layout.max_width.unwrap_or(160.0);
74 let height = layout.max_height.unwrap_or(42.0);86 let height = layout.max_height.unwrap_or(42.0);
75 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {87 if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 {
...@@ -97,12 +109,13 @@ impl ReadOnlyObject {...@@ -97,12 +109,13 @@ impl ReadOnlyObject {
97 source,109 source,
98 message,110 message,
99 label,111 label,
112 offset,
100 }))113 }))
101 }114 }
102 pub fn rect(&self) -> [f32; 4] {115 pub fn rect(&self) -> [f32; 4] {
103 let layout = self.source.layout();116 let layout = self.source.layout();
104 let x = layout.x.unwrap_or(0.0);117 let x = layout.x.unwrap_or(0.0) + self.offset[0];
105 let y = layout.y.unwrap_or(0.0);118 let y = layout.y.unwrap_or(0.0) + self.offset[1];
106 [119 [
107 x,120 x,
108 y,121 y,
...@@ -115,20 +128,17 @@ impl ReadOnlyObject {...@@ -115,20 +128,17 @@ impl ReadOnlyObject {
115 }128 }
116}129}
117130
118#[expect(
119 clippy::type_complexity,
120 reason = "the three owned parts of an imported page"
121)]
122pub(crate) fn build(131pub(crate) fn build(
123 page: &mut Page,132 page: &mut Page,
124 engine: &mut TextEngine,133 engine: &mut TextEngine,
125 editable: bool,134 editable: bool,
126) -> Result<(Vec<Content>, Vec<TextOutline>, Option<PageDate>), EditorError> {135) -> Result<Import, EditorError> {
127 if page.margin_origin.iter().any(|v| !v.is_finite()) {136 if page.margin_origin.iter().any(|v| !v.is_finite()) {
128 return Err(EditorError::InvalidGeometry);137 return Err(EditorError::InvalidGeometry);
129 }138 }
130 let mut objects = Vec::new();139 let mut objects = Vec::new();
131 let mut outlines = Vec::new();140 let mut outlines = Vec::new();
141 let mut areas = Vec::new();
132 let mut date = None;142 let mut date = None;
133 for object in std::mem::take(&mut page.objects) {143 for object in std::mem::take(&mut page.objects) {
134 match &object {144 match &object {
...@@ -189,6 +199,16 @@ pub(crate) fn build(...@@ -189,6 +199,16 @@ pub(crate) fn build(
189 }199 }
190 Err(error) => return Err(error.into()),200 Err(error) => return Err(error.into()),
191 };201 };
202 areas.push(TitleArea {
203 id: title.id,
204 date: title.date,
205 layout: title.layout.clone(),
206 origins: title
207 .outlines
208 .iter()
209 .map(|outline| (outline.id, [outline.layout.x, outline.layout.y]))
210 .collect(),
211 });
192 let mut anchor = if editable {212 let mut anchor = if editable {
193 title213 title
194 .outlines214 .outlines
...@@ -309,5 +329,10 @@ pub(crate) fn build(...@@ -309,5 +329,10 @@ pub(crate) fn build(
309 )?)),329 )?)),
310 }330 }
311 }331 }
312 Ok((objects, outlines, date))332 Ok(Import {
333 objects,
334 outlines,
335 date,
336 areas,
337 })
313}338}
crates/canvas/src/gpu/page.rs+3-2
...@@ -78,8 +78,9 @@ impl From<EditorError> for SceneError {...@@ -78,8 +78,9 @@ impl From<EditorError> for SceneError {
7878
79impl PageScene {79impl PageScene {
80 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {80 pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> {
81 let (objects, _, _) =81 let objects = crate::editor::page::build(&mut page, engine, false)
82 crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?;82 .map_err(SceneError::from)?
83 .objects;
83 let images = Self::decode_images(&objects)?;84 let images = Self::decode_images(&objects)?;
84 Ok(Self {85 Ok(Self {
85 reference: Some(objects),86 reference: Some(objects),
crates/notebook/examples/session_client.rs created+195
...@@ -0,0 +1,195 @@
1//! Drives a section the way the application does: open it through a session, save
2//! page-model edits, wait for their publication and report the page texts.
3//! `session_client SECTION CACHE_DIR LABEL` edits the first body paragraph of every
4//! page whose title starts with "Move" and adds one outline per page; each launch
5//! reports the pending queue it found and the receipts it obtained.
6
7use notebook::{
8 EditStatus,
9 session::{Event, Save, Section},
10};
11use onestore::{
12 ExGuid,
13 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id},
14};
15use std::{
16 sync::mpsc,
17 time::{Duration, Instant},
18};
19
20fn body_text(page: &Page) -> Option<ExGuid> {
21 page.objects.iter().find_map(|object| match object {
22 PageObject::Outline(outline) => outline
23 .paragraphs
24 .iter()
25 .find_map(|p| p.text().map(|t| t.id)),
26 _ => None,
27 })
28}
29
30fn texts(page: &Page) -> Vec<String> {
31 let mut out = Vec::new();
32 for object in &page.objects {
33 let outlines: Vec<&Outline> = match object {
34 PageObject::Outline(outline) => vec![outline],
35 PageObject::Title(title) => title.outlines.iter().collect(),
36 _ => Vec::new(),
37 };
38 for outline in outlines {
39 for paragraph in &outline.paragraphs {
40 if let Some(text) = paragraph.text() {
41 out.push(text.text.text().to_owned());
42 }
43 }
44 }
45 }
46 out
47}
48
49fn edit(page: &mut Page, label: &str) {
50 let text = body_text(page).expect("a body paragraph");
51 for object in &mut page.objects {
52 let PageObject::Outline(outline) = object else {
53 continue;
54 };
55 if let Some(paragraph) = outline
56 .paragraphs
57 .iter_mut()
58 .find(|p| p.text().is_some_and(|t| t.id == text))
59 {
60 let target = paragraph.text_mut().unwrap();
61 let format = target.text.format_at(0).unwrap().clone();
62 target
63 .text
64 .apply(onestore::page::text::Edit {
65 range: 0..0,
66 replacement: onestore::page::Paragraph::new(format!("{label} "), format),
67 })
68 .unwrap();
69 }
70 }
71 let template = page
72 .objects
73 .iter()
74 .find_map(|object| match object {
75 PageObject::Outline(outline) => outline
76 .paragraphs
77 .iter()
78 .find(|p| p.text().is_some())
79 .cloned(),
80 _ => None,
81 })
82 .unwrap();
83 let mut paragraph: PageParagraph = template.clone();
84 paragraph.id = new_id().unwrap();
85 paragraph.parent = None;
86 paragraph.level = 1;
87 paragraph.lists.clear();
88 paragraph.tags.clear();
89 paragraph.style = None;
90 paragraph.collapsed = false;
91 paragraph.content = ParagraphContent::Text(TextObject {
92 id: new_id().unwrap(),
93 date_field: None,
94 text: onestore::page::Paragraph::new(
95 format!("{label} outline 🦀 é"),
96 template.text().unwrap().text.format_at(0).unwrap().clone(),
97 ),
98 tags: Vec::new(),
99 });
100 let outline = Outline {
101 id: new_id().unwrap(),
102 title: false,
103 min_width: None,
104 layout: onestore::document::Layout {
105 x: Some(72.0),
106 y: Some(520.0),
107 ..Default::default()
108 },
109 indents: Vec::new(),
110 paragraphs: vec![paragraph],
111 unsupported: Vec::new(),
112 };
113 let at = page
114 .objects
115 .iter()
116 .position(|o| matches!(o, PageObject::Title(_)))
117 .unwrap_or(page.objects.len());
118 page.objects.insert(at, PageObject::Outline(outline));
119}
120
121fn main() -> Result<(), Box<dyn std::error::Error>> {
122 let args: Vec<String> = std::env::args().collect();
123 let [_, file, cache, label] = args.as_slice() else {
124 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());
125 };
126 let (notify, notified) = mpsc::channel();
127 let section = Section::open(file, cache, move || {
128 let _ = notify.send(());
129 })?;
130 let pending = section.pending()?;
131 println!(
132 "{}",
133 serde_json::json!({"event": "opened", "pending": pending.len(), "file": section.file()})
134 );
135 let pages = section.pages()?;
136 let mut queued = Vec::new();
137 for (space, title) in pages.iter().filter(|(_, title)| title.starts_with("Move")) {
138 let before = section.page(*space)?;
139 let mut after = before.clone();
140 edit(&mut after, label);
141 match section.save(*space, &before, &after, "session client")? {
142 Save::Queued(id) => queued.push((id, *space, title.clone(), texts(&after))),
143 other => return Err(format!("{title}: {other:?}").into()),
144 }
145 }
146 let deadline = Instant::now() + Duration::from_secs(120);
147 let mut receipts = Vec::new();
148 while queued
149 .iter()
150 .any(|(id, ..)| !receipts.iter().any(|(n, _)| n == id))
151 {
152 if Instant::now() > deadline {
153 return Err("Publication timed out".into());
154 }
155 let _ = notified.recv_timeout(Duration::from_millis(200));
156 for event in section.events() {
157 match event {
158 Event::Attempt {
159 id,
160 status: EditStatus::Published { revision },
161 } => receipts.push((id, revision)),
162 Event::Attempt { id, status } => {
163 println!(
164 "{}",
165 serde_json::json!({"event": "attempt", "id": id, "status": format!("{status:?}")})
166 );
167 }
168 Event::Unreachable(error) => {
169 println!(
170 "{}",
171 serde_json::json!({"event": "unreachable", "error": error.to_string()})
172 );
173 }
174 Event::Failed(error) => return Err(error.into()),
175 Event::Refreshed => {}
176 }
177 }
178 }
179 for (id, space, title, expected) in &queued {
180 let revision = receipts
181 .iter()
182 .find(|(n, _)| n == id)
183 .map(|(_, r)| r.to_string());
184 let stored = texts(&section.page(*space)?);
185 println!(
186 "{}",
187 serde_json::json!({"event": "published", "id": id, "space": space.to_string(), "title": title,
188 "revision": revision, "texts": expected, "stored": stored})
189 );
190 }
191 assert!(section.pending()?.is_empty());
192 section.close()?;
193 println!("{}", serde_json::json!({"event": "closed"}));
194 Ok(())
195}
crates/notebook/src/session.rs+62-1
...@@ -1,7 +1,9 @@...@@ -1,7 +1,9 @@
1//! The application's view of a notebook: sections opened through a local replica that1//! The application's view of a notebook: sections opened through a local replica that
2//! publishes page saves to the section file in the background.2//! publishes page saves to the section file in the background.
33
4use crate::{EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover};4use crate::{
5 ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover,
6};
5use onestore::{7use onestore::{
6 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,8 CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page,
7};9};
...@@ -87,6 +89,14 @@ pub enum Save {...@@ -87,6 +89,14 @@ pub enum Save {
87 Stale,89 Stale,
88}90}
8991
92/// A queued edit and its durable state.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct QueuedEdit {
95 pub id: u64,
96 pub space: ExGuid,
97 pub status: EditStatus,
98}
99
90/// A section file with its replica and background publication.100/// A section file with its replica and background publication.
91pub struct Section {101pub struct Section {
92 file: PathBuf,102 file: PathBuf,
...@@ -246,6 +256,57 @@ impl Section {...@@ -246,6 +256,57 @@ impl Section {
246 self.replica.pending()256 self.replica.pending()
247 }257 }
248258
259 /// Every queued edit with its state: pending, awaiting confirmation of a retained
260 /// attempt, or a conflict awaiting review.
261 pub fn queue(&self) -> Result<Vec<QueuedEdit>> {
262 self.replica
263 .pending()?
264 .into_iter()
265 .map(|edit| {
266 Ok(QueuedEdit {
267 id: edit.id,
268 space: edit.space,
269 status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending),
270 })
271 })
272 .collect()
273 }
274
275 /// The queued edits whose publication conflicted with a remote change.
276 pub fn conflicts(&self) -> Result<Vec<(QueuedEdit, ConflictKind)>> {
277 Ok(self
278 .queue()?
279 .into_iter()
280 .filter_map(|edit| match edit.status {
281 EditStatus::Conflict(kind) => Some((edit, kind)),
282 _ => None,
283 })
284 .collect())
285 }
286
287 /// The page as last observed in the section file, for reviewing a conflict.
288 pub fn remote_page(&self, space: ExGuid) -> Result<Page> {
289 let snapshot = self.replica.remote_snapshot()?;
290 let store = Store::parse(&snapshot)?;
291 let index = RevisionIndex::parse(&store)?;
292 Ok(Page::from_space(&Document::parse(&index)?, space)?)
293 }
294
295 /// Resolves the oldest conflict with a page reviewed against `remote_page`; the
296 /// reviewed model publishes as a whole, keeping the edit's id.
297 pub fn review(&self, id: u64, after: &Page) -> Result<()> {
298 let local = self.replica.snapshot()?;
299 let remote = self.replica.remote_snapshot()?;
300 self.replica.review_page(id, &local, &remote, after)?;
301 self.wake();
302 Ok(())
303 }
304
305 /// Captures both images, the queue and its states in a read-only archive.
306 pub fn export_recovery(&self, path: impl AsRef<Path>) -> Result<()> {
307 self.replica.export_recovery(path)
308 }
309
249 /// Events since the last poll, oldest first.310 /// Events since the last poll, oldest first.
250 pub fn events(&self) -> Vec<Event> {311 pub fn events(&self) -> Vec<Event> {
251 self.events.try_iter().collect()312 self.events.try_iter().collect()
crates/notebook/tests/session.rs+81-2
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1use notebook::{1use notebook::{
2 EditStatus,2 ConflictKind, EditStatus, Recovery,
3 session::{Event, Notebook, Save, Section},3 session::{Event, Notebook, QueuedEdit, Save, Section},
4};4};
5use onestore::{ExGuid, PreparedEdit, page::Page};5use onestore::{ExGuid, PreparedEdit, page::Page};
6use std::{6use std::{
...@@ -601,3 +601,82 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {...@@ -601,3 +601,82 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() {
601 );601 );
602 assert!(connecting.try_recv().is_err());602 assert!(connecting.try_recv().is_err());
603}603}
604
605#[test]
606fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recovery() {
607 let directory = tempfile::tempdir().unwrap();
608 let file = directory.path().join("notes.one");
609 let cache = directory.path().join("cache");
610 std::fs::write(
611 &file,
612 onestore::create_section("notes.one", "Original", "Author").unwrap(),
613 )
614 .unwrap();
615 let (section, _) = open(&file, &cache);
616 let space = section.pages().unwrap()[0].0;
617 let before = section.page(space).unwrap();
618 let text = first_text(&before);
619 use std::os::unix::fs::PermissionsExt;
620 let permissions = std::fs::metadata(&file).unwrap().permissions();
621 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap();
622 let mut local = before.clone();
623 model_ops::replace_text(&mut local, text, 0..8, "Local");
624 let Save::Queued(id) = section.save(space, &before, &local, "Editor").unwrap() else {
625 panic!()
626 };
627 wait(&section, |event| matches!(event, Event::Unreachable(_)));
628 std::fs::set_permissions(&file, permissions).unwrap();
629 let mut native = before.clone();
630 model_ops::replace_text(&mut native, text, 0..8, "Native");
631 let deadline = Instant::now() + Duration::from_secs(20);
632 loop {
633 let bytes = onestore::read_file(&file).unwrap();
634 match PreparedEdit::page(&bytes, space, &native, "Native")
635 .unwrap()
636 .commit_file(&file)
637 {
638 Ok(()) => break,
639 Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => {
640 assert!(Instant::now() < deadline);
641 std::thread::sleep(Duration::from_millis(20));
642 }
643 Err(error) => panic!("{error:?}"),
644 }
645 }
646 section.wake();
647 wait(
648 &section,
649 |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Conflict(ConflictKind::ContentChanged) } if *n == id),
650 );
651 let conflicts = section.conflicts().unwrap();
652 assert_eq!(
653 conflicts,
654 [(
655 QueuedEdit {
656 id,
657 space,
658 status: EditStatus::Conflict(ConflictKind::ContentChanged)
659 },
660 ConflictKind::ContentChanged
661 )]
662 );
663 assert_same(section.page(space).unwrap(), &local);
664 let remote = section.remote_page(space).unwrap();
665 assert_same(remote.clone(), &native);
666 let archive = directory.path().join("review.sqlite");
667 section.export_recovery(&archive).unwrap();
668 let recovery = Recovery::open(&archive).unwrap();
669 assert_eq!(recovery.pending().unwrap().len(), 1);
670 assert_eq!(
671 recovery.status(id).unwrap(),
672 Some(EditStatus::Conflict(ConflictKind::ContentChanged))
673 );
674 let mut reviewed = remote.clone();
675 model_ops::replace_text(&mut reviewed, text, 0..6, "Native and local");
676 section.review(id, &reviewed).unwrap();
677 assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending));
678 published(&section, id);
679 assert_same(stored_page(&file, space), &reviewed);
680 assert!(section.queue().unwrap().is_empty());
681 section.close().unwrap();
682}
crates/snowbound/Cargo.toml+1
...@@ -7,6 +7,7 @@ publish = false...@@ -7,6 +7,7 @@ publish = false
7[dependencies]7[dependencies]
8canvas = { path = "../canvas", features = ["gpu"] }8canvas = { path = "../canvas", features = ["gpu"] }
9onestore = { path = "../onestore" }9onestore = { path = "../onestore" }
10notebook = { path = "../notebook" }
10pollster = "0.4"11pollster = "0.4"
11wgpu.workspace = true12wgpu.workspace = true
12parley.workspace = true13parley.workspace = true
crates/snowbound/src/accessibility.rs+5-1
...@@ -1324,7 +1324,11 @@ mod tests {...@@ -1324,7 +1324,11 @@ mod tests {
1324 .selection(editor.active_outline().id, selection)1324 .selection(editor.active_outline().id, selection)
1325 .unwrap()1325 .unwrap()
1326 );1326 );
1327 let count = editor.outlines().iter().map(|o| o.layouts().count()).sum();1327 let count = editor
1328 .outlines()
1329 .iter()
1330 .map(|o| o.layouts().count())
1331 .sum::<usize>();
1328 assert_eq!(cached.paragraphs.len(), count);1332 assert_eq!(cached.paragraphs.len(), count);
1329 }1333 }
1330 };1334 };
crates/snowbound/src/main.rs+234-24
...@@ -39,6 +39,8 @@ enum UserEvent {...@@ -39,6 +39,8 @@ enum UserEvent {
39 Quit,39 Quit,
40 InsertText(String),40 InsertText(String),
41 Accessibility(accesskit_winit::Event),41 Accessibility(accesskit_winit::Event),
42 /// The section's synchronization thread reported an event.
43 Sync,
42}44}
4345
44impl From<accesskit_winit::Event> for UserEvent {46impl From<accesskit_winit::Event> for UserEvent {
...@@ -75,6 +77,32 @@ enum Input {...@@ -75,6 +77,32 @@ enum Input {
75 reference: Option<Page>,77 reference: Option<Page>,
76 },78 },
77 Page(Page),79 Page(Page),
80 Section {
81 file: PathBuf,
82 title: String,
83 cache: PathBuf,
84 },
85}
86
87/// The opened section and the stored model the editor's page was loaded from.
88struct Session {
89 section: notebook::session::Section,
90 space: onestore::ExGuid,
91 before: Page,
92 title: String,
93 status: &'static str,
94}
95
96impl Session {
97 fn window_title(&self) -> String {
98 let file = self
99 .section
100 .file()
101 .file_name()
102 .map(|name| name.to_string_lossy().into_owned())
103 .unwrap_or_default();
104 format!("{} · {file}{}", self.title, self.status)
105 }
78}106}
79107
80enum Drag {108enum Drag {
...@@ -112,6 +140,7 @@ struct State {...@@ -112,6 +140,7 @@ struct State {
112 renderer: Renderer,140 renderer: Renderer,
113 engine: TextEngine,141 engine: TextEngine,
114 editor: CanvasEditor,142 editor: CanvasEditor,
143 session: Option<Session>,
115 initial: Vec<(onestore::ExGuid, TextDocument)>,144 initial: Vec<(onestore::ExGuid, TextDocument)>,
116 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,145 initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>,
117 initial_date: Option<u64>,146 initial_date: Option<u64>,
...@@ -167,13 +196,14 @@ impl State {...@@ -167,13 +196,14 @@ impl State {
167 Input::Notes {196 Input::Notes {
168 reference: None, ..197 reference: None, ..
169 } => "Untitled · Temporary page".into(),198 } => "Untitled · Temporary page".into(),
199 Input::Section { title, .. } => title.clone(),
170 })200 })
171 .with_inner_size(LogicalSize::new(1000.0, 720.0)),201 .with_inner_size(LogicalSize::new(1000.0, 720.0)),
172 )?,202 )?,
173 );203 );
174 macos::install_text_input(&window);204 macos::install_text_input(&window);
175 let access_adapter =205 let access_adapter =
176 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy);206 accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone());
177 window.set_visible(true);207 window.set_visible(true);
178 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(208 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle(
179 Box::new(window.clone()),209 Box::new(window.clone()),
...@@ -199,6 +229,7 @@ impl State {...@@ -199,6 +229,7 @@ impl State {
199 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;229 .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?;
200 eprintln!("Using {} for {target}", path.display());230 eprintln!("Using {} for {target}", path.display());
201 }231 }
232 let mut session = None;
202 let (editor, scene) = match input {233 let (editor, scene) = match input {
203 Input::Notes {234 Input::Notes {
204 document,235 document,
...@@ -217,7 +248,30 @@ impl State {...@@ -217,7 +248,30 @@ impl State {
217 let (scene, editor) = PageScene::from_page(page, &mut engine)?;248 let (scene, editor) = PageScene::from_page(page, &mut engine)?;
218 (editor, Some((scene, [0.0; 2])))249 (editor, Some((scene, [0.0; 2])))
219 }250 }
251 Input::Section { file, title, cache } => {
252 let section = notebook::session::Section::open(&file, cache, move || {
253 let _ = proxy.send_event(UserEvent::Sync);
254 })?;
255 let (space, _) = section
256 .pages()?
257 .into_iter()
258 .find(|(_, candidate)| *candidate == title)
259 .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?;
260 let before = section.page(space)?;
261 let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?;
262 session = Some(Session {
263 section,
264 space,
265 before,
266 title,
267 status: "",
268 });
269 (editor, Some((scene, [0.0; 2])))
270 }
220 };271 };
272 if let Some(session) = &session {
273 window.set_title(&session.window_title());
274 }
221 let initial_date = editor.date().map(|date| date.timestamp());275 let initial_date = editor.date().map(|date| date.timestamp());
222 let initial_layouts = editor276 let initial_layouts = editor
223 .object_layouts()277 .object_layouts()
...@@ -241,6 +295,7 @@ impl State {...@@ -241,6 +295,7 @@ impl State {
241 renderer,295 renderer,
242 engine,296 engine,
243 editor,297 editor,
298 session,
244 initial,299 initial,
245 initial_date,300 initial_date,
246 initial_layouts,301 initial_layouts,
...@@ -331,7 +386,8 @@ impl State {...@@ -331,7 +386,8 @@ impl State {
331 ),386 ),
332 );387 );
333 self.update_accessibility()?;388 self.update_accessibility()?;
334 if self.editor.active_outline().title {389 self.persist()?;
390 if self.session.is_none() && self.editor.active_outline().title {
335 let title = self391 let title = self
336 .editor392 .editor
337 .active_outline()393 .active_outline()
...@@ -357,6 +413,112 @@ impl State {...@@ -357,6 +413,112 @@ impl State {
357 Ok(())413 Ok(())
358 }414 }
359415
416 /// Saves the edited page to the section's replica; a page changed underneath the
417 /// editor is reloaded in place of the edit.
418 fn persist(&mut self) -> Result<(), Box<dyn Error>> {
419 let Some(session) = &mut self.session else {
420 return Ok(());
421 };
422 let after = self.editor.page()?;
423 match session
424 .section
425 .save(session.space, &session.before, &after, "snowbound")?
426 {
427 notebook::session::Save::Unchanged => {}
428 notebook::session::Save::Queued(_) => {
429 session.before = session.section.page(session.space)?;
430 session.status = " · saving";
431 self.window.set_title(&session.window_title());
432 }
433 notebook::session::Save::Stale => self.reload()?,
434 }
435 Ok(())
436 }
437
438 /// Replaces the editor with the page currently stored in the section.
439 fn reload(&mut self) -> Result<(), Box<dyn Error>> {
440 let Some(session) = &mut self.session else {
441 return Ok(());
442 };
443 let page = session.section.page(session.space)?;
444 let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?;
445 session.before = page;
446 self.editor = editor;
447 self.scene = Some((scene, [0.0; 2]));
448 self.drag = None;
449 self.read_only_focus = None;
450 self.window.set_title(&session.window_title());
451 self.update_accessibility()?;
452 self.window.request_redraw();
453 Ok(())
454 }
455
456 /// Reviews the oldest conflict on this page: `keep_mine` publishes the editor's page
457 /// over the remote change, otherwise the remote page replaces the editor's.
458 fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box<dyn Error>> {
459 let Some(session) = &mut self.session else {
460 return Ok(());
461 };
462 let Some((edit, _)) = session
463 .section
464 .conflicts()?
465 .into_iter()
466 .find(|(edit, _)| edit.space == session.space)
467 else {
468 return Ok(());
469 };
470 let reviewed = if keep_mine {
471 self.editor.page()?
472 } else {
473 session.section.remote_page(session.space)?
474 };
475 session.section.review(edit.id, &reviewed)?;
476 session.status = " · saving";
477 if keep_mine {
478 session.before = session.section.page(session.space)?;
479 self.window.set_title(&session.window_title());
480 Ok(())
481 } else {
482 self.reload()
483 }
484 }
485
486 /// Applies what the synchronization thread reported since the last poll.
487 fn synced(&mut self) -> Result<(), Box<dyn Error>> {
488 let Some(session) = &mut self.session else {
489 return Ok(());
490 };
491 let mut refreshed = false;
492 for event in session.section.events() {
493 use notebook::session::Event;
494 session.status = match event {
495 Event::Refreshed => {
496 refreshed = true;
497 continue;
498 }
499 Event::Attempt {
500 status: notebook::EditStatus::Published { .. },
501 ..
502 } => " · saved",
503 Event::Attempt {
504 status: notebook::EditStatus::Conflict(_),
505 ..
506 } => " · conflict",
507 Event::Attempt { .. } => " · saving",
508 Event::Unreachable(_) => " · offline",
509 Event::Failed(error) => {
510 eprintln!("Synchronization stopped: {error}");
511 " · not saving"
512 }
513 };
514 }
515 self.window.set_title(&session.window_title());
516 if refreshed && session.section.page(session.space)? != session.before {
517 self.reload()?;
518 }
519 Ok(())
520 }
521
360 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {522 fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> {
361 let mut error = None;523 let mut error = None;
362 let preview = self.preview();524 let preview = self.preview();
...@@ -659,6 +821,17 @@ impl State {...@@ -659,6 +821,17 @@ impl State {
659 macos::show_character_palette();821 macos::show_character_palette();
660 return Ok(());822 return Ok(());
661 }823 }
824 if command && shift && self.session.is_some() {
825 match key {
826 Key::Character(character) if character.eq_ignore_ascii_case("k") => {
827 return self.resolve_conflict(true);
828 }
829 Key::Character(character) if character.eq_ignore_ascii_case("t") => {
830 return self.resolve_conflict(false);
831 }
832 _ => {}
833 }
834 }
662 if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) {835 if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) {
663 if matches!(836 if matches!(
664 key,837 key,
...@@ -932,25 +1105,26 @@ impl State {...@@ -932,25 +1105,26 @@ impl State {
932impl App {1105impl App {
933 fn close(&self, event_loop: &ActiveEventLoop) {1106 fn close(&self, event_loop: &ActiveEventLoop) {
934 if self.state.as_ref().is_none_or(|state| {1107 if self.state.as_ref().is_none_or(|state| {
935 state1108 state.session.is_some()
936 .editor1109 || state
937 .caret_outline()1110 .editor
938 .is_none_or(TextOutline::is_empty)1111 .caret_outline()
939 && state.initial_date == state.editor.date().map(|date| date.timestamp())1112 .is_none_or(TextOutline::is_empty)
940 && state1113 && state.initial_date == state.editor.date().map(|date| date.timestamp())
941 .initial_layouts1114 && state
942 .iter()1115 .initial_layouts
943 .map(|(id, layout)| (*id, layout))1116 .iter()
944 .eq(state.editor.object_layouts())1117 .map(|(id, layout)| (*id, layout))
945 && state1118 .eq(state.editor.object_layouts())
946 .initial1119 && state
947 .iter()1120 .initial
948 .map(|(id, document)| (id, document))
949 .eq(state
950 .editor
951 .outlines()
952 .iter()1121 .iter()
953 .map(|outline| (&outline.id, outline.document())))1122 .map(|(id, document)| (id, document))
1123 .eq(state
1124 .editor
1125 .outlines()
1126 .iter()
1127 .map(|outline| (&outline.id, outline.document())))
954 }) || macos::discard_changes()1128 }) || macos::discard_changes()
955 {1129 {
956 event_loop.exit();1130 event_loop.exit();
...@@ -982,6 +1156,14 @@ impl ApplicationHandler<UserEvent> for App {...@@ -982,6 +1156,14 @@ impl ApplicationHandler<UserEvent> for App {
982 self.close(event_loop);1156 self.close(event_loop);
983 return;1157 return;
984 }1158 }
1159 UserEvent::Sync => {
1160 if let Some(state) = &mut self.state
1161 && let Err(error) = state.synced()
1162 {
1163 eprintln!("{error}");
1164 }
1165 return;
1166 }
985 UserEvent::Accessibility(event) => event,1167 UserEvent::Accessibility(event) => event,
986 };1168 };
987 let Some(state) = &mut self.state else {1169 let Some(state) = &mut self.state else {
...@@ -1812,12 +1994,33 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -1812,12 +1994,33 @@ fn main() -> Result<(), Box<dyn Error>> {
1812 let mut substitutes = Vec::new();1994 let mut substitutes = Vec::new();
1813 let mut reference = None;1995 let mut reference = None;
1814 let mut editable = false;1996 let mut editable = false;
1997 let mut section = None;
1998 let mut cache = None;
1815 while let Some(arg) = args.next() {1999 while let Some(arg) = args.next() {
1816 if arg == "--substitute-font" {2000 if arg == "--substitute-font" {
1817 substitutes.push(PathBuf::from(2001 substitutes.push(PathBuf::from(
1818 args.next()2002 args.next()
1819 .ok_or("Provide a font file after --substitute-font.")?,2003 .ok_or("Provide a font file after --substitute-font.")?,
1820 ));2004 ));
2005 } else if arg == "--section" {
2006 if reference.is_some() || section.is_some() {
2007 return Err("Only one page can be opened.".into());
2008 }
2009 let file = PathBuf::from(
2010 args.next()
2011 .ok_or("Provide a section file and page title after --section.")?,
2012 );
2013 let title = args
2014 .next()
2015 .ok_or("Provide a page title after the section file.")?
2016 .to_str()
2017 .ok_or("The page title must be valid Unicode.")?
2018 .to_owned();
2019 section = Some((file, title));
2020 } else if arg == "--cache" {
2021 cache = Some(PathBuf::from(
2022 args.next().ok_or("Provide a directory after --cache.")?,
2023 ));
1821 } else if arg == "--reference" || arg == "--page" {2024 } else if arg == "--reference" || arg == "--page" {
1822 if reference.is_some() {2025 if reference.is_some() {
1823 return Err("Only one page can be opened.".into());2026 return Err("Only one page can be opened.".into());
...@@ -1842,14 +2045,14 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -1842,14 +2045,14 @@ fn main() -> Result<(), Box<dyn Error>> {
1842 positional.push(arg);2045 positional.push(arg);
1843 }2046 }
1844 }2047 }
1845 if editable && !positional.is_empty() {2048 if (editable || section.is_some()) && !positional.is_empty() {
1846 return Err(2049 return Err(
1847 "Use --page with a section file and page title, without a text file or width.".into(),2050 "Use --page or --section with a section file and page title, without a text file or width.".into(),
1848 );2051 );
1849 }2052 }
1850 if positional.len() > 2 {2053 if positional.len() > 2 {
1851 return Err(2054 return Err(
1852 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..."2055 "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE [--cache DIR]] [--substitute-font FONT_FILE]..."
1853 .into(),2056 .into(),
1854 );2057 );
1855 }2058 }
...@@ -1867,7 +2070,14 @@ fn main() -> Result<(), Box<dyn Error>> {...@@ -1867,7 +2070,14 @@ fn main() -> Result<(), Box<dyn Error>> {
1867 } else {2070 } else {
1868 480.02071 480.0
1869 });2072 });
1870 let input = if editable {2073 let input = if let Some((file, title)) = section {
2074 let cache = match cache {
2075 Some(cache) => cache,
2076 None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?)
2077 .join("Library/Caches/snowbound"),
2078 };
2079 Input::Section { file, title, cache }
2080 } else if editable {
1871 Input::Page(reference.unwrap())2081 Input::Page(reference.unwrap())
1872 } else {2082 } else {
1873 Input::Notes {2083 Input::Notes {
tools/TESTING.md+1-1
...@@ -40,7 +40,7 @@ the run's commands, inputs, outputs and teardown evidence....@@ -40,7 +40,7 @@ the run's commands, inputs, outputs and teardown evidence.
40| Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison |40| Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison |
41| SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection |41| SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection |
42| SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks |42| SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks |
43| Retained cache migration | Ignored `migrate_retained_cache_copy` test | New destination, unchanged source, images, intent IDs, attempts and receipts |43| Application session on a share | `session_acceptance.py --help` | Page saves through `notebook::session` on the mounted Samba share, relaunch between launches, cold native reopen of the edited pages, owned VM and clone teardown |
4444
45To compare an additional **already captured** notebook hierarchy without running45To compare an additional **already captured** notebook hierarchy without running
46OneNote:46OneNote:
tools/session_acceptance.py created+149
...@@ -0,0 +1,149 @@
1#!/usr/bin/env python3
2"""Edit a notebook on the owned Samba share through the application's session, relaunch,
3then cold-open the result in a disposable OneNote clone and compare its text."""
4import argparse
5import html
6import json
7import os
8import re
9import shutil
10import subprocess
11import sys
12import tarfile
13import xml.etree.ElementTree as ET
14from pathlib import Path
15
16ROOT = Path(__file__).resolve().parent.parent
17sys.path.insert(0, str(ROOT / 'tools/w7'))
18sys.path.insert(0, str(ROOT / 'tools'))
19import linux_vm # noqa: E402
20
21FIXTURE = ROOT / 'corpus/outline-edit/before/notebook'
22NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'}
23
24
25def native_texts(read):
26 pages = {}
27 for path in sorted(read.glob('page-*.xml')):
28 root = ET.parse(path).getroot()
29 texts = []
30 for element in root.iter('{%s}T' % NS['one']):
31 texts.append(html.unescape(re.sub(r'<[^>]*>', '', element.text or '')))
32 pages[root.get('name')] = texts
33 return pages
34
35
36def compare(expected, native):
37 """The client's edits carry its launch label; other text is compared by the corpus lanes."""
38 mismatches = []
39 for title, texts in expected.items():
40 body = [text for text in native[title] if text != title]
41 wanted = [text for text in texts if 'launch' in text]
42 assert wanted, title
43 for text in wanted:
44 if text not in body:
45 mismatches.append({'page': title, 'missing': text, 'native': body})
46 return mismatches
47
48
49def main():
50 parser = argparse.ArgumentParser(description=__doc__)
51 parser.add_argument('output', type=Path)
52 parser.add_argument('--linux', help='Disposable Linux VM owned by this run')
53 parser.add_argument('--expected-pages', type=int, default=15)
54 parser.add_argument('--verify-only', action='store_true',
55 help='Compare an existing capture in OUTPUT without the lab.')
56 args = parser.parse_args()
57 output = args.output.resolve()
58 if not args.verify_only and not args.linux:
59 parser.error('--linux names the disposable Linux VM this run owns')
60 if args.verify_only:
61 expected = {}
62 for launch in ('first', 'second'):
63 for line in (output / f'{launch}.log').read_text().splitlines():
64 if line.startswith('{') and '"published"' in line:
65 event = json.loads(line)
66 expected[event['title']] = event['texts']
67 native = native_texts(output / 'result/cold/read')
68 mismatches = compare(expected, native)
69 (output / 'verification.json').write_text(json.dumps(
70 {'expected': expected, 'native': {title: native[title] for title in expected}, 'mismatches': mismatches},
71 indent=2, ensure_ascii=False))
72 assert not mismatches, mismatches
73 print(f'Session acceptance verified: {len(expected)} pages', flush=True)
74 return
75 output.mkdir(parents=True)
76 server = args.linux
77 mount = output / 'mount'
78 mount.mkdir()
79 client = ROOT / 'target/debug/examples/session_client'
80 subprocess.run(['cargo', 'build', '-p', 'notebook', '--example', 'session_client'], cwd=ROOT, check=True)
81 record = {'server': server, 'fixture': str(FIXTURE.relative_to(ROOT)), 'launches': []}
82
83 def unmount():
84 for options in ([], ['-f']):
85 if not os.path.ismount(mount):
86 return
87 subprocess.run(['/sbin/umount', *options, str(mount)], capture_output=True, text=True, timeout=60)
88 if os.path.ismount(mount):
89 raise RuntimeError('The owned SMB mount remains attached')
90
91 try:
92 if not linux_vm.instance_path(server).exists():
93 linux_vm.create_instance(server)
94 linux_vm.launch(server)
95 linux_vm.wait_instance(server, 600)
96 config = linux_vm.load_instance(server)
97 (output / 'linux.json').write_text(json.dumps(config, indent=2))
98 linux_vm.run_ssh(server, 'mkdir /srv/agent/m4-session').check_returncode()
99 with tarfile.open(output / 'input.tar', 'w', dereference=True) as archive:
100 for name in ('synthetic.one', 'Open Notebook.onetoc2'):
101 archive.add(FIXTURE / name, arcname=name)
102 with (output / 'input.tar').open('rb') as stream:
103 subprocess.run(linux_vm.ssh_argv(server, 'tar xf - -C /srv/agent/m4-session'), stdin=stream, check=True)
104 linux_vm.run_ssh(server, 'chmod u+w /srv/agent/m4-session/*').check_returncode()
105 subprocess.run(['/sbin/mount_smbfs', '-N', f'//guest@127.0.0.1:{config["samba_port"]}/agent', mount],
106 check=True, stdin=subprocess.DEVNULL)
107 section = mount / 'm4-session/synthetic.one'
108 assert section.read_bytes() == (FIXTURE / 'synthetic.one').read_bytes()
109 cache = output / 'cache'
110 expected = {}
111 for launch in ('First launch', 'Second launch'):
112 result = subprocess.run([client, section, cache, launch], capture_output=True, text=True, timeout=300)
113 (output / f'{launch.split()[0].lower()}.log').write_text(result.stdout + result.stderr)
114 result.check_returncode()
115 events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')]
116 assert events[0]['event'] == 'opened' and events[0]['pending'] == 0, events[0]
117 published = [e for e in events if e['event'] == 'published']
118 assert published and all(e['revision'] for e in published), published
119 for e in published:
120 assert e['stored'] == e['texts'], e
121 expected[e['title']] = e['texts']
122 record['launches'].append({'label': launch, 'published': published})
123 unmount()
124 result = output / 'result'
125 (result / 'notebook').mkdir(parents=True)
126 with (output / 'result.tar').open('wb') as stream:
127 subprocess.run(linux_vm.ssh_argv(server, 'tar cf - -C /srv/agent/m4-session .'), stdout=stream, check=True)
128 with tarfile.open(output / 'result.tar') as archive:
129 archive.extractall(result / 'notebook', filter='data')
130 subprocess.run([sys.executable, ROOT / 'tools/native_runner.py', result / 'notebook', result / 'cold',
131 '--expected-pages', str(args.expected_pages), '--collect-notebook'], check=True)
132 native = native_texts(result / 'cold/read')
133 mismatches = compare(expected, native)
134 record['native'] = {title: native[title] for title in expected}
135 record['mismatches'] = mismatches
136 (output / 'record.json').write_text(json.dumps(record, indent=2, ensure_ascii=False))
137 assert not mismatches, mismatches
138 print(f'Session acceptance passed: {len(expected)} pages edited twice, reopened natively', flush=True)
139 finally:
140 unmount()
141 if linux_vm.instance_path(server).exists():
142 if linux_vm.running(server):
143 linux_vm.shutdown(server, 60)
144 linux_vm.delete_instance(server)
145 shutil.rmtree(mount, ignore_errors=True)
146
147
148if __name__ == '__main__':
149 main()