| author | |
| committer | |
| log | cffb5ce4bca95f8c242b5513d981a708206cee41 |
| tree | cb09fcef5f6794e742e6945aec25c7e37da23252 |
| parent | 7d2feb6f84ba766193b4041b9d6ee375a354e98e |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.112 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 | }; |
| 10 | use onestore::ExGuid; | 10 | use onestore::ExGuid; |
| 11 | use onestore::page::text::{EditError, Paragraph}; | 11 | use onestore::page::text::{EditError, Paragraph}; |
| 12 | use onestore::page::{Definition, Outline, PageParagraph}; | 12 | use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; |
| 13 | use parley::{ | 13 | use 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 | } |
| 111 | 112 | ||
| 113 | /// Imported page state the editable content does not carry. | ||
| 114 | #[derive(Default)] | ||
| 115 | struct PageHeader { | ||
| 116 | title: String, | ||
| 117 | created: Option<u64>, | ||
| 118 | margin_origin: [f32; 2], | ||
| 119 | areas: Vec<page::TitleArea>, | ||
| 120 | } | ||
| 121 | |||
| 112 | enum Focus { | 122 | enum 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 | } |
| 653 | 664 | ||
| 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 = objects | 674 | 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 | } |
| 689 | 708 | ||
| 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.objects | 779 | 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 | }; |
| 7 | use onestore::page::text::Paragraph; | 7 | use onestore::page::text::Paragraph; |
| 8 | use onestore::page::{Image, Outline, Page, PageObject}; | 8 | use onestore::page::{Image, Outline, Page, PageObject}; |
| 9 | use std::collections::BTreeMap; | ||
| 10 | |||
| 11 | /// A title object's own state, plus the child origins `build` replaces with page coordinates. | ||
| 12 | pub(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 | |||
| 19 | pub(crate) struct Import { | ||
| 20 | pub objects: Vec<Content>, | ||
| 21 | pub outlines: Vec<TextOutline>, | ||
| 22 | pub date: Option<PageDate>, | ||
| 23 | pub areas: Vec<TitleArea>, | ||
| 24 | } | ||
| 9 | 25 | ||
| 10 | pub(crate) enum Content { | 26 | pub(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 | } |
| 29 | 47 | ||
| 30 | impl Content { | 48 | impl Content { |
| ... | @@ -51,7 +69,7 @@ impl Content { | ... | @@ -51,7 +69,7 @@ impl Content { |
| 51 | 69 | ||
| 52 | impl ReadOnlyObject { | 70 | impl 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 | } |
| 117 | 130 | ||
| 118 | #[expect( | ||
| 119 | clippy::type_complexity, | ||
| 120 | reason = "the three owned parts of an imported page" | ||
| 121 | )] | ||
| 122 | pub(crate) fn build( | 131 | pub(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 | title | 213 | title |
| 194 | .outlines | 214 | .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 { |
| 78 | 78 | ||
| 79 | impl PageScene { | 79 | impl 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 | |||
| 7 | use notebook::{ | ||
| 8 | EditStatus, | ||
| 9 | session::{Event, Save, Section}, | ||
| 10 | }; | ||
| 11 | use onestore::{ | ||
| 12 | ExGuid, | ||
| 13 | page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, | ||
| 14 | }; | ||
| 15 | use std::{ | ||
| 16 | sync::mpsc, | ||
| 17 | time::{Duration, Instant}, | ||
| 18 | }; | ||
| 19 | |||
| 20 | fn 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 | |||
| 30 | fn 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 | |||
| 49 | fn 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 | |||
| 121 | fn 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 that | 1 | //! 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. |
| 3 | 3 | ||
| 4 | use crate::{EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover}; | 4 | use crate::{ |
| 5 | ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover, | ||
| 6 | }; | ||
| 5 | use onestore::{ | 7 | use 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 | } |
| 89 | 91 | ||
| 92 | /// A queued edit and its durable state. | ||
| 93 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 94 | pub 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. |
| 91 | pub struct Section { | 101 | pub 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 | } |
| 248 | 258 | ||
| 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 @@ |
| 1 | use notebook::{ | 1 | use notebook::{ |
| 2 | EditStatus, | 2 | ConflictKind, EditStatus, Recovery, |
| 3 | session::{Event, Notebook, Save, Section}, | 3 | session::{Event, Notebook, QueuedEdit, Save, Section}, |
| 4 | }; | 4 | }; |
| 5 | use onestore::{ExGuid, PreparedEdit, page::Page}; | 5 | use onestore::{ExGuid, PreparedEdit, page::Page}; |
| 6 | use std::{ | 6 | use 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] | ||
| 606 | fn 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] |
| 8 | canvas = { path = "../canvas", features = ["gpu"] } | 8 | canvas = { path = "../canvas", features = ["gpu"] } |
| 9 | onestore = { path = "../onestore" } | 9 | onestore = { path = "../onestore" } |
| 10 | notebook = { path = "../notebook" } | ||
| 10 | pollster = "0.4" | 11 | pollster = "0.4" |
| 11 | wgpu.workspace = true | 12 | wgpu.workspace = true |
| 12 | parley.workspace = true | 13 | parley.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 | } |
| 43 | 45 | ||
| 44 | impl From<accesskit_winit::Event> for UserEvent { | 46 | impl 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. | ||
| 88 | struct Session { | ||
| 89 | section: notebook::session::Section, | ||
| 90 | space: onestore::ExGuid, | ||
| 91 | before: Page, | ||
| 92 | title: String, | ||
| 93 | status: &'static str, | ||
| 94 | } | ||
| 95 | |||
| 96 | impl 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 | } |
| 79 | 107 | ||
| 80 | enum Drag { | 108 | enum 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 = editor | 276 | 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 = self | 391 | let title = self |
| 336 | .editor | 392 | .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 | } |
| 359 | 415 | ||
| 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 { |
| 932 | impl App { | 1105 | impl 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 | state | 1108 | state.session.is_some() |
| 936 | .editor | 1109 | || 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 | && state | 1113 | && state.initial_date == state.editor.date().map(|date| date.timestamp()) |
| 941 | .initial_layouts | 1114 | && 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 | && state | 1118 | .eq(state.editor.object_layouts()) |
| 946 | .initial | 1119 | && 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.0 | 2071 | 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 | |
| 44 | 44 | ||
| 45 | To compare an additional **already captured** notebook hierarchy without running | 45 | To compare an additional **already captured** notebook hierarchy without running |
| 46 | OneNote: | 46 | OneNote: |
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, | ||
| 3 | then cold-open the result in a disposable OneNote clone and compare its text.""" | ||
| 4 | import argparse | ||
| 5 | import html | ||
| 6 | import json | ||
| 7 | import os | ||
| 8 | import re | ||
| 9 | import shutil | ||
| 10 | import subprocess | ||
| 11 | import sys | ||
| 12 | import tarfile | ||
| 13 | import xml.etree.ElementTree as ET | ||
| 14 | from pathlib import Path | ||
| 15 | |||
| 16 | ROOT = Path(__file__).resolve().parent.parent | ||
| 17 | sys.path.insert(0, str(ROOT / 'tools/w7')) | ||
| 18 | sys.path.insert(0, str(ROOT / 'tools')) | ||
| 19 | import linux_vm # noqa: E402 | ||
| 20 | |||
| 21 | FIXTURE = ROOT / 'corpus/outline-edit/before/notebook' | ||
| 22 | NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'} | ||
| 23 | |||
| 24 | |||
| 25 | def 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 | |||
| 36 | def 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 | |||
| 49 | def 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 | |||
| 148 | if __name__ == '__main__': | ||
| 149 | main() | ||