| 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 | 2613 | "accesskit_winit", |
| 2614 | 2614 | "arboard", |
| 2615 | 2615 | "canvas", |
| 2616 | "notebook", | |
| 2616 | 2617 | "objc2 0.5.2", |
| 2617 | 2618 | "objc2-app-kit 0.2.2", |
| 2618 | 2619 | "objc2-foundation 0.2.2", |
crates/canvas/src/editor.rs+263-6| ... | ... | @@ -9,7 +9,7 @@ use crate::{ |
| 9 | 9 | }; |
| 10 | 10 | use onestore::ExGuid; |
| 11 | 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 | 13 | use parley::{ |
| 14 | 14 | Affinity, BoundingBox, |
| 15 | 15 | editing::{Cursor, Selection as ParagraphSelection}, |
| ... | ... | @@ -102,6 +102,7 @@ pub struct CanvasEditor { |
| 102 | 102 | date: Option<PageDate>, |
| 103 | 103 | outlines: Vec<TextOutline>, |
| 104 | 104 | definitions: BTreeMap<ExGuid, Definition>, |
| 105 | header: PageHeader, | |
| 105 | 106 | active: Focus, |
| 106 | 107 | undo: Vec<History>, |
| 107 | 108 | redo: Vec<History>, |
| ... | ... | @@ -109,6 +110,15 @@ pub struct CanvasEditor { |
| 109 | 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 | 122 | enum Focus { |
| 113 | 123 | Outline(usize), |
| 114 | 124 | Draft { |
| ... | ... | @@ -643,6 +653,7 @@ impl CanvasEditor { |
| 643 | 653 | definitions: BTreeMap::new(), |
| 644 | 654 | objects: Vec::new(), |
| 645 | 655 | date: None, |
| 656 | header: PageHeader::default(), | |
| 646 | 657 | active, |
| 647 | 658 | undo: Vec::new(), |
| 648 | 659 | redo: Vec::new(), |
| ... | ... | @@ -651,11 +662,13 @@ impl CanvasEditor { |
| 651 | 662 | }) |
| 652 | 663 | } |
| 653 | 664 | |
| 654 | pub fn from_page( | |
| 655 | mut page: onestore::page::Page, | |
| 656 | engine: &mut TextEngine, | |
| 657 | ) -> Result<Self, EditorError> { | |
| 658 | let (objects, mut outlines, date) = page::build(&mut page, engine, true)?; | |
| 665 | pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result<Self, EditorError> { | |
| 666 | let page::Import { | |
| 667 | objects, | |
| 668 | mut outlines, | |
| 669 | date, | |
| 670 | areas, | |
| 671 | } = page::build(&mut page, engine, true)?; | |
| 659 | 672 | let needs_caret = outlines.is_empty(); |
| 660 | 673 | if needs_caret { |
| 661 | 674 | let x = objects |
| ... | ... | @@ -680,6 +693,12 @@ impl CanvasEditor { |
| 680 | 693 | }; |
| 681 | 694 | } |
| 682 | 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 | 702 | let mut ids = BTreeSet::new(); |
| 684 | 703 | if !editor.object_layouts().all(|(id, _)| ids.insert(id)) { |
| 685 | 704 | return Err(EditError::InvalidStructure.into()); |
| ... | ... | @@ -687,6 +706,74 @@ impl CanvasEditor { |
| 687 | 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 | 777 | /// Whether this outline occupies a slot in the imported page's paint order. |
| 691 | 778 | pub fn has_page_outline(&self, id: ExGuid) -> bool { |
| 692 | 779 | self.objects |
| ... | ... | @@ -896,6 +983,7 @@ impl CanvasEditor { |
| 896 | 983 | definitions, |
| 897 | 984 | objects: Vec::new(), |
| 898 | 985 | date, |
| 986 | header: PageHeader::default(), | |
| 899 | 987 | active: Focus::Outline(0), |
| 900 | 988 | undo: Vec::new(), |
| 901 | 989 | redo: Vec::new(), |
| ... | ... | @@ -7108,4 +7196,173 @@ mod tests { |
| 7108 | 7196 | } |
| 7109 | 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 | }; |
| 7 | 7 | use onestore::page::text::Paragraph; |
| 8 | 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 | 26 | pub(crate) enum Content { |
| 11 | 27 | Date { |
| ... | ... | @@ -25,6 +41,8 @@ pub struct ReadOnlyObject { |
| 25 | 41 | pub source: PageObject, |
| 26 | 42 | pub message: &'static str, |
| 27 | 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 | 48 | impl Content { |
| ... | ... | @@ -51,7 +69,7 @@ impl Content { |
| 51 | 69 | |
| 52 | 70 | impl ReadOnlyObject { |
| 53 | 71 | fn new( |
| 54 | mut source: PageObject, | |
| 72 | source: PageObject, | |
| 55 | 73 | margin: [f32; 2], |
| 56 | 74 | message: &'static str, |
| 57 | 75 | engine: &mut TextEngine, |
| ... | ... | @@ -61,15 +79,9 @@ impl ReadOnlyObject { |
| 61 | 79 | } else { |
| 62 | 80 | [0.0; 2] |
| 63 | 81 | }; |
| 64 | let layout = source.layout_mut(); | |
| 82 | let layout = source.layout(); | |
| 65 | 83 | let x = layout.x.unwrap_or(0.0) + offset[0]; |
| 66 | 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 | 85 | let width = layout.max_width.unwrap_or(160.0); |
| 74 | 86 | let height = layout.max_height.unwrap_or(42.0); |
| 75 | 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 | 109 | source, |
| 98 | 110 | message, |
| 99 | 111 | label, |
| 112 | offset, | |
| 100 | 113 | })) |
| 101 | 114 | } |
| 102 | 115 | pub fn rect(&self) -> [f32; 4] { |
| 103 | 116 | let layout = self.source.layout(); |
| 104 | let x = layout.x.unwrap_or(0.0); | |
| 105 | let y = layout.y.unwrap_or(0.0); | |
| 117 | let x = layout.x.unwrap_or(0.0) + self.offset[0]; | |
| 118 | let y = layout.y.unwrap_or(0.0) + self.offset[1]; | |
| 106 | 119 | [ |
| 107 | 120 | x, |
| 108 | 121 | y, |
| ... | ... | @@ -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 | 131 | pub(crate) fn build( |
| 123 | 132 | page: &mut Page, |
| 124 | 133 | engine: &mut TextEngine, |
| 125 | 134 | editable: bool, |
| 126 | ) -> Result<(Vec<Content>, Vec<TextOutline>, Option<PageDate>), EditorError> { | |
| 135 | ) -> Result<Import, EditorError> { | |
| 127 | 136 | if page.margin_origin.iter().any(|v| !v.is_finite()) { |
| 128 | 137 | return Err(EditorError::InvalidGeometry); |
| 129 | 138 | } |
| 130 | 139 | let mut objects = Vec::new(); |
| 131 | 140 | let mut outlines = Vec::new(); |
| 141 | let mut areas = Vec::new(); | |
| 132 | 142 | let mut date = None; |
| 133 | 143 | for object in std::mem::take(&mut page.objects) { |
| 134 | 144 | match &object { |
| ... | ... | @@ -189,6 +199,16 @@ pub(crate) fn build( |
| 189 | 199 | } |
| 190 | 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 | 212 | let mut anchor = if editable { |
| 193 | 213 | title |
| 194 | 214 | .outlines |
| ... | ... | @@ -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 | 78 | |
| 79 | 79 | impl PageScene { |
| 80 | 80 | pub fn new(mut page: Page, engine: &mut TextEngine) -> Result<Self, SceneError> { |
| 81 | let (objects, _, _) = | |
| 82 | crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?; | |
| 81 | let objects = crate::editor::page::build(&mut page, engine, false) | |
| 82 | .map_err(SceneError::from)? | |
| 83 | .objects; | |
| 83 | 84 | let images = Self::decode_images(&objects)?; |
| 84 | 85 | Ok(Self { |
| 85 | 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 | 1 | //! The application's view of a notebook: sections opened through a local replica that |
| 2 | 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 | 7 | use onestore::{ |
| 6 | 8 | CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page, |
| 7 | 9 | }; |
| ... | ... | @@ -87,6 +89,14 @@ pub enum Save { |
| 87 | 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 | 100 | /// A section file with its replica and background publication. |
| 91 | 101 | pub struct Section { |
| 92 | 102 | file: PathBuf, |
| ... | ... | @@ -246,6 +256,57 @@ impl Section { |
| 246 | 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 | 310 | /// Events since the last poll, oldest first. |
| 250 | 311 | pub fn events(&self) -> Vec<Event> { |
| 251 | 312 | self.events.try_iter().collect() |
crates/notebook/tests/session.rs+81-2| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | use notebook::{ |
| 2 | EditStatus, | |
| 3 | session::{Event, Notebook, Save, Section}, | |
| 2 | ConflictKind, EditStatus, Recovery, | |
| 3 | session::{Event, Notebook, QueuedEdit, Save, Section}, | |
| 4 | 4 | }; |
| 5 | 5 | use onestore::{ExGuid, PreparedEdit, page::Page}; |
| 6 | 6 | use std::{ |
| ... | ... | @@ -601,3 +601,82 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { |
| 601 | 601 | ); |
| 602 | 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 | 7 | [dependencies] |
| 8 | 8 | canvas = { path = "../canvas", features = ["gpu"] } |
| 9 | 9 | onestore = { path = "../onestore" } |
| 10 | notebook = { path = "../notebook" } | |
| 10 | 11 | pollster = "0.4" |
| 11 | 12 | wgpu.workspace = true |
| 12 | 13 | parley.workspace = true |
crates/snowbound/src/accessibility.rs+5-1| ... | ... | @@ -1324,7 +1324,11 @@ mod tests { |
| 1324 | 1324 | .selection(editor.active_outline().id, selection) |
| 1325 | 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 | 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 | 39 | Quit, |
| 40 | 40 | InsertText(String), |
| 41 | 41 | Accessibility(accesskit_winit::Event), |
| 42 | /// The section's synchronization thread reported an event. | |
| 43 | Sync, | |
| 42 | 44 | } |
| 43 | 45 | |
| 44 | 46 | impl From<accesskit_winit::Event> for UserEvent { |
| ... | ... | @@ -75,6 +77,32 @@ enum Input { |
| 75 | 77 | reference: Option<Page>, |
| 76 | 78 | }, |
| 77 | 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 | 108 | enum Drag { |
| ... | ... | @@ -112,6 +140,7 @@ struct State { |
| 112 | 140 | renderer: Renderer, |
| 113 | 141 | engine: TextEngine, |
| 114 | 142 | editor: CanvasEditor, |
| 143 | session: Option<Session>, | |
| 115 | 144 | initial: Vec<(onestore::ExGuid, TextDocument)>, |
| 116 | 145 | initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, |
| 117 | 146 | initial_date: Option<u64>, |
| ... | ... | @@ -167,13 +196,14 @@ impl State { |
| 167 | 196 | Input::Notes { |
| 168 | 197 | reference: None, .. |
| 169 | 198 | } => "Untitled · Temporary page".into(), |
| 199 | Input::Section { title, .. } => title.clone(), | |
| 170 | 200 | }) |
| 171 | 201 | .with_inner_size(LogicalSize::new(1000.0, 720.0)), |
| 172 | 202 | )?, |
| 173 | 203 | ); |
| 174 | 204 | macos::install_text_input(&window); |
| 175 | 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 | 207 | window.set_visible(true); |
| 178 | 208 | let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( |
| 179 | 209 | Box::new(window.clone()), |
| ... | ... | @@ -199,6 +229,7 @@ impl State { |
| 199 | 229 | .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; |
| 200 | 230 | eprintln!("Using {} for {target}", path.display()); |
| 201 | 231 | } |
| 232 | let mut session = None; | |
| 202 | 233 | let (editor, scene) = match input { |
| 203 | 234 | Input::Notes { |
| 204 | 235 | document, |
| ... | ... | @@ -217,7 +248,30 @@ impl State { |
| 217 | 248 | let (scene, editor) = PageScene::from_page(page, &mut engine)?; |
| 218 | 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 | 275 | let initial_date = editor.date().map(|date| date.timestamp()); |
| 222 | 276 | let initial_layouts = editor |
| 223 | 277 | .object_layouts() |
| ... | ... | @@ -241,6 +295,7 @@ impl State { |
| 241 | 295 | renderer, |
| 242 | 296 | engine, |
| 243 | 297 | editor, |
| 298 | session, | |
| 244 | 299 | initial, |
| 245 | 300 | initial_date, |
| 246 | 301 | initial_layouts, |
| ... | ... | @@ -331,7 +386,8 @@ impl State { |
| 331 | 386 | ), |
| 332 | 387 | ); |
| 333 | 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 | 391 | let title = self |
| 336 | 392 | .editor |
| 337 | 393 | .active_outline() |
| ... | ... | @@ -357,6 +413,112 @@ impl State { |
| 357 | 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 | 522 | fn update_accessibility(&mut self) -> Result<(), Box<dyn Error>> { |
| 361 | 523 | let mut error = None; |
| 362 | 524 | let preview = self.preview(); |
| ... | ... | @@ -659,6 +821,17 @@ impl State { |
| 659 | 821 | macos::show_character_palette(); |
| 660 | 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 | 835 | if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) { |
| 663 | 836 | if matches!( |
| 664 | 837 | key, |
| ... | ... | @@ -932,25 +1105,26 @@ impl State { |
| 932 | 1105 | impl App { |
| 933 | 1106 | fn close(&self, event_loop: &ActiveEventLoop) { |
| 934 | 1107 | if self.state.as_ref().is_none_or(|state| { |
| 935 | state | |
| 936 | .editor | |
| 937 | .caret_outline() | |
| 938 | .is_none_or(TextOutline::is_empty) | |
| 939 | && state.initial_date == state.editor.date().map(|date| date.timestamp()) | |
| 940 | && state | |
| 941 | .initial_layouts | |
| 942 | .iter() | |
| 943 | .map(|(id, layout)| (*id, layout)) | |
| 944 | .eq(state.editor.object_layouts()) | |
| 945 | && state | |
| 946 | .initial | |
| 947 | .iter() | |
| 948 | .map(|(id, document)| (id, document)) | |
| 949 | .eq(state | |
| 950 | .editor | |
| 951 | .outlines() | |
| 1108 | state.session.is_some() | |
| 1109 | || state | |
| 1110 | .editor | |
| 1111 | .caret_outline() | |
| 1112 | .is_none_or(TextOutline::is_empty) | |
| 1113 | && state.initial_date == state.editor.date().map(|date| date.timestamp()) | |
| 1114 | && state | |
| 1115 | .initial_layouts | |
| 1116 | .iter() | |
| 1117 | .map(|(id, layout)| (*id, layout)) | |
| 1118 | .eq(state.editor.object_layouts()) | |
| 1119 | && state | |
| 1120 | .initial | |
| 952 | 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 | 1128 | }) || macos::discard_changes() |
| 955 | 1129 | { |
| 956 | 1130 | event_loop.exit(); |
| ... | ... | @@ -982,6 +1156,14 @@ impl ApplicationHandler<UserEvent> for App { |
| 982 | 1156 | self.close(event_loop); |
| 983 | 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 | 1167 | UserEvent::Accessibility(event) => event, |
| 986 | 1168 | }; |
| 987 | 1169 | let Some(state) = &mut self.state else { |
| ... | ... | @@ -1812,12 +1994,33 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 1812 | 1994 | let mut substitutes = Vec::new(); |
| 1813 | 1995 | let mut reference = None; |
| 1814 | 1996 | let mut editable = false; |
| 1997 | let mut section = None; | |
| 1998 | let mut cache = None; | |
| 1815 | 1999 | while let Some(arg) = args.next() { |
| 1816 | 2000 | if arg == "--substitute-font" { |
| 1817 | 2001 | substitutes.push(PathBuf::from( |
| 1818 | 2002 | args.next() |
| 1819 | 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 | 2024 | } else if arg == "--reference" || arg == "--page" { |
| 1822 | 2025 | if reference.is_some() { |
| 1823 | 2026 | return Err("Only one page can be opened.".into()); |
| ... | ... | @@ -1842,14 +2045,14 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 1842 | 2045 | positional.push(arg); |
| 1843 | 2046 | } |
| 1844 | 2047 | } |
| 1845 | if editable && !positional.is_empty() { | |
| 2048 | if (editable || section.is_some()) && !positional.is_empty() { | |
| 1846 | 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 | 2053 | if positional.len() > 2 { |
| 1851 | 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 | 2056 | .into(), |
| 1854 | 2057 | ); |
| 1855 | 2058 | } |
| ... | ... | @@ -1867,7 +2070,14 @@ fn main() -> Result<(), Box<dyn Error>> { |
| 1867 | 2070 | } else { |
| 1868 | 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 | 2081 | Input::Page(reference.unwrap()) |
| 1872 | 2082 | } else { |
| 1873 | 2083 | Input::Notes { |
tools/TESTING.md+1-1| ... | ... | @@ -40,7 +40,7 @@ the run's commands, inputs, outputs and teardown evidence. |
| 40 | 40 | | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | |
| 41 | 41 | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | |
| 42 | 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 | 45 | To compare an additional **already captured** notebook hierarchy without running |
| 46 | 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() |