diff --git a/Cargo.lock b/Cargo.lock index 773eff13833a5ec5fa04a8116edd92ce7c27b988..24516b7ede164bf84a339556872b17e9ed28f497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2613,6 +2613,7 @@ dependencies = [ "accesskit_winit", "arboard", "canvas", + "notebook", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index d6fd0cae58730e43148de4f94752376be0c04f31..3d1a811ea53147e1a523b61498127267119c2447 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -9,7 +9,7 @@ use crate::{ }; use onestore::ExGuid; use onestore::page::text::{EditError, Paragraph}; -use onestore::page::{Definition, Outline, PageParagraph}; +use onestore::page::{Definition, Outline, Page, PageObject, PageParagraph, Title}; use parley::{ Affinity, BoundingBox, editing::{Cursor, Selection as ParagraphSelection}, @@ -102,6 +102,7 @@ pub struct CanvasEditor { date: Option, outlines: Vec, definitions: BTreeMap, + header: PageHeader, active: Focus, undo: Vec, redo: Vec, @@ -109,6 +110,15 @@ pub struct CanvasEditor { preferred_x: Option, } +/// Imported page state the editable content does not carry. +#[derive(Default)] +struct PageHeader { + title: String, + created: Option, + margin_origin: [f32; 2], + areas: Vec, +} + enum Focus { Outline(usize), Draft { @@ -643,6 +653,7 @@ impl CanvasEditor { definitions: BTreeMap::new(), objects: Vec::new(), date: None, + header: PageHeader::default(), active, undo: Vec::new(), redo: Vec::new(), @@ -651,11 +662,13 @@ impl CanvasEditor { }) } - pub fn from_page( - mut page: onestore::page::Page, - engine: &mut TextEngine, - ) -> Result { - let (objects, mut outlines, date) = page::build(&mut page, engine, true)?; + pub fn from_page(mut page: Page, engine: &mut TextEngine) -> Result { + let page::Import { + objects, + mut outlines, + date, + areas, + } = page::build(&mut page, engine, true)?; let needs_caret = outlines.is_empty(); if needs_caret { let x = objects @@ -680,6 +693,12 @@ impl CanvasEditor { }; } editor.objects = objects; + editor.header = PageHeader { + title: page.title, + created: page.created, + margin_origin: page.margin_origin, + areas, + }; let mut ids = BTreeSet::new(); if !editor.object_layouts().all(|(id, _)| ids.insert(id)) { return Err(EditError::InvalidStructure.into()); @@ -687,6 +706,74 @@ impl CanvasEditor { Ok(editor) } + /// Rebuilds the stored page, restoring the title areas and read-only objects import split up. + pub fn page(&self) -> Result { + let mut objects: Vec = Vec::new(); + for content in &self.objects { + let mut outline = match content { + page::Content::Editable(id) => { + match self.outlines.iter().find(|outline| outline.id == *id) { + Some(outline) => outline.snapshot(), + None => continue, + } + } + page::Content::Outline { source, .. } => source.clone(), + page::Content::Date { .. } => self + .date + .as_ref() + .ok_or(EditError::InvalidStructure)? + .source() + .clone(), + page::Content::Image(image) => { + objects.push(PageObject::Image(image.clone())); + continue; + } + page::Content::ReadOnly(object) => { + objects.push(object.source.clone()); + continue; + } + }; + let area = self + .header + .areas + .iter() + .find_map(|area| Some((area, *area.origins.get(&outline.id)?))); + let Some((area, origin)) = area else { + objects.push(PageObject::Outline(outline)); + continue; + }; + [outline.layout.x, outline.layout.y] = origin; + match objects.last_mut() { + Some(PageObject::Title(title)) if title.id == area.id => { + title.outlines.push(outline) + } + _ => objects.push(PageObject::Title(Title { + id: area.id, + date: area.date, + layout: area.layout.clone(), + outlines: vec![outline], + })), + } + } + objects.extend( + self.outlines + .iter() + .filter(|outline| !self.has_page_outline(outline.id)) + .map(|outline| PageObject::Outline(outline.snapshot())), + ); + Ok(Page { + title: self.header.title.clone(), + created: self + .date + .as_ref() + .map(PageDate::timestamp) + .or(self.header.created), + margin_origin: self.header.margin_origin, + objects, + definitions: self.definitions.clone(), + }) + } + /// Whether this outline occupies a slot in the imported page's paint order. pub fn has_page_outline(&self, id: ExGuid) -> bool { self.objects @@ -896,6 +983,7 @@ impl CanvasEditor { definitions, objects: Vec::new(), date, + header: PageHeader::default(), active: Focus::Outline(0), undo: Vec::new(), redo: Vec::new(), @@ -7108,4 +7196,173 @@ mod tests { } assert!(!editor.redo(&mut engine).unwrap()); } + + const CORPUS: [&[u8]; 3] = [ + include_bytes!("../../../corpus/canvas/baseline-anchors.one"), + include_bytes!("../../../corpus/outline-edit/before/notebook/synthetic.one"), + include_bytes!("../../../corpus/paragraph-edit/before/notebook/synthetic.one"), + ]; + + fn corpus_pages(section: &[u8]) -> Vec<(ExGuid, Page)> { + use onestore::{RevisionIndex, Store, document::Document}; + let store = Store::parse(section).unwrap(); + let index = RevisionIndex::parse(&store).unwrap(); + let document = Document::parse(&index).unwrap(); + let mut spaces = document + .pages() + .unwrap() + .into_iter() + .map(|(space, _)| space) + .collect::>(); + spaces.dedup(); + spaces + .into_iter() + .map(|space| (space, Page::from_space(&document, space).unwrap())) + .collect() + } + + fn corpus_page(section: &[u8], title: &str) -> (ExGuid, Page) { + corpus_pages(section) + .into_iter() + .find(|(_, page)| page.title == title) + .unwrap() + } + + fn body_text(page: &Page, id: ExGuid) -> Option { + page.objects.iter().find_map(|object| match object { + PageObject::Outline(outline) if outline.id == id => Some( + outline + .paragraphs + .iter() + .map(|paragraph| paragraph.text().unwrap().text.text()) + .collect(), + ), + _ => None, + }) + } + + #[test] + fn an_unedited_page_rebuilds_into_the_model_it_was_imported_from() { + let mut pages = 0; + for section in CORPUS { + for (_, page) in corpus_pages(section) { + let mut engine = TextEngine::default(); + let editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + assert_eq!(editor.page().unwrap(), page); + pages += 1; + } + } + assert_eq!(pages, 30); + } + + #[test] + fn typing_moving_resizing_adding_and_deleting_reach_the_rebuilt_page() { + let mut engine = TextEngine::default(); + let (_, source) = corpus_page(CORPUS[0], "Baseline anchors"); + let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap(); + let removed = editor + .outlines + .iter() + .find(|outline| { + !outline.title + && outline.document.nodes().len() == 1 + && outline.document.validate_flat().is_ok() + }) + .unwrap() + .id; + let bodies = editor + .outlines + .iter() + .filter(|outline| !outline.title && outline.id != removed) + .map(|outline| outline.id) + .collect::>(); + editor.focus_outline(bodies[0]).unwrap(); + editor.insert(&mut engine, "typed").unwrap(); + editor.move_outline(bodies[1], [123.0, 456.0]).unwrap(); + editor.focus_outline(bodies[2]).unwrap(); + editor.resize(&mut engine, 200.0).unwrap(); + editor.focus_outline(removed).unwrap(); + editor.select_all().unwrap(); + assert!(editor.delete(&mut engine, false).unwrap()); + let added = editor + .create_outline(&mut engine, [24.0, 600.0], 300.0) + .unwrap(); + editor.insert(&mut engine, "added").unwrap(); + + let page = editor.page().unwrap(); + assert_eq!(page.title, source.title); + assert_eq!(page.created, source.created); + assert_eq!(page.margin_origin, source.margin_origin); + assert!(body_text(&page, bodies[0]).unwrap().starts_with("typed")); + let moved = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.id == bodies[1] => Some(&outline.layout), + _ => None, + }) + .unwrap(); + assert_eq!([moved.x, moved.y], [Some(123.0), Some(456.0)]); + let resized = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) if outline.id == bodies[2] => Some(&outline.layout), + _ => None, + }) + .unwrap(); + assert_eq!(resized.max_width, Some(200.0)); + assert_eq!(resized.width_set_by_user, Some(true)); + assert!(body_text(&page, removed).is_none()); + assert_eq!(body_text(&page, added).as_deref(), Some("added")); + assert_eq!(page.objects.last().unwrap().id(), added); + assert_eq!( + page.objects.iter().map(PageObject::id).collect::>(), + source + .objects + .iter() + .map(PageObject::id) + .filter(|id| *id != removed) + .chain([added]) + .collect::>() + ); + assert_eq!( + page.objects + .iter() + .find(|object| matches!(object, PageObject::Title(_))), + source + .objects + .iter() + .find(|object| matches!(object, PageObject::Title(_))) + ); + + let mut engine = TextEngine::default(); + let reimported = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + assert_eq!(reimported.page().unwrap(), page); + } + + #[test] + fn an_edited_page_writes_back_through_the_page_writer() { + let mut engine = TextEngine::default(); + let (space, source) = corpus_page(CORPUS[2], "Split middle"); + let mut editor = CanvasEditor::from_page(source.clone(), &mut engine).unwrap(); + let body = editor + .outlines + .iter() + .find(|outline| !outline.title) + .unwrap() + .id; + editor.focus_outline(body).unwrap(); + editor.insert(&mut engine, "Edited ").unwrap(); + let page = editor.page().unwrap(); + let written = onestore::PreparedEdit::page(CORPUS[2], space, &page, "Author").unwrap(); + let reread = corpus_pages(written.as_bytes()) + .into_iter() + .find_map(|(candidate, page)| (candidate == space).then_some(page)) + .unwrap(); + let edited = body_text(&page, body).unwrap(); + assert!(edited.starts_with("Edited ")); + assert_eq!(body_text(&reread, body).as_deref(), Some(edited.as_str())); + assert_ne!(body_text(&source, body).as_deref(), Some(edited.as_str())); + } } diff --git a/crates/canvas/src/editor/page.rs b/crates/canvas/src/editor/page.rs index ec274270d42c2ef46c3d6dadc8e82c90569f9c8b..84301fd840fe982b0f4f03d79de48a984d4eac8f 100644 --- a/crates/canvas/src/editor/page.rs +++ b/crates/canvas/src/editor/page.rs @@ -6,6 +6,22 @@ use crate::{ }; use onestore::page::text::Paragraph; use onestore::page::{Image, Outline, Page, PageObject}; +use std::collections::BTreeMap; + +/// A title object's own state, plus the child origins `build` replaces with page coordinates. +pub(crate) struct TitleArea { + pub id: onestore::ExGuid, + pub date: Option, + pub layout: onestore::document::Layout, + pub origins: BTreeMap; 2]>, +} + +pub(crate) struct Import { + pub objects: Vec, + pub outlines: Vec, + pub date: Option, + pub areas: Vec, +} pub(crate) enum Content { Date { @@ -25,6 +41,8 @@ pub struct ReadOnlyObject { pub source: PageObject, pub message: &'static str, pub(crate) label: TextLayout, + /// Title coordinates are stored relative to the page margin; nothing else is offset. + offset: [f32; 2], } impl Content { @@ -51,7 +69,7 @@ impl Content { impl ReadOnlyObject { fn new( - mut source: PageObject, + source: PageObject, margin: [f32; 2], message: &'static str, engine: &mut TextEngine, @@ -61,15 +79,9 @@ impl ReadOnlyObject { } else { [0.0; 2] }; - let layout = source.layout_mut(); + let layout = source.layout(); let x = layout.x.unwrap_or(0.0) + offset[0]; let y = layout.y.unwrap_or(0.0) + offset[1]; - if offset[0] != 0.0 { - layout.x = Some(x); - } - if offset[1] != 0.0 { - layout.y = Some(y); - } let width = layout.max_width.unwrap_or(160.0); let height = layout.max_height.unwrap_or(42.0); if [x, y, width, height].iter().any(|v| !v.is_finite()) || width <= 0.0 || height <= 0.0 { @@ -97,12 +109,13 @@ impl ReadOnlyObject { source, message, label, + offset, })) } pub fn rect(&self) -> [f32; 4] { let layout = self.source.layout(); - let x = layout.x.unwrap_or(0.0); - let y = layout.y.unwrap_or(0.0); + let x = layout.x.unwrap_or(0.0) + self.offset[0]; + let y = layout.y.unwrap_or(0.0) + self.offset[1]; [ x, y, @@ -115,20 +128,17 @@ impl ReadOnlyObject { } } -#[expect( - clippy::type_complexity, - reason = "the three owned parts of an imported page" -)] pub(crate) fn build( page: &mut Page, engine: &mut TextEngine, editable: bool, -) -> Result<(Vec, Vec, Option), EditorError> { +) -> Result { if page.margin_origin.iter().any(|v| !v.is_finite()) { return Err(EditorError::InvalidGeometry); } let mut objects = Vec::new(); let mut outlines = Vec::new(); + let mut areas = Vec::new(); let mut date = None; for object in std::mem::take(&mut page.objects) { match &object { @@ -189,6 +199,16 @@ pub(crate) fn build( } Err(error) => return Err(error.into()), }; + areas.push(TitleArea { + id: title.id, + date: title.date, + layout: title.layout.clone(), + origins: title + .outlines + .iter() + .map(|outline| (outline.id, [outline.layout.x, outline.layout.y])) + .collect(), + }); let mut anchor = if editable { title .outlines @@ -309,5 +329,10 @@ pub(crate) fn build( )?)), } } - Ok((objects, outlines, date)) + Ok(Import { + objects, + outlines, + date, + areas, + }) } diff --git a/crates/canvas/src/gpu/page.rs b/crates/canvas/src/gpu/page.rs index 839fe1f13d9f4c38ef3337d50c475d1fd14dc439..12d41d8c471aec7b13c03692db6d08b709690127 100644 --- a/crates/canvas/src/gpu/page.rs +++ b/crates/canvas/src/gpu/page.rs @@ -78,8 +78,9 @@ impl From for SceneError { impl PageScene { pub fn new(mut page: Page, engine: &mut TextEngine) -> Result { - let (objects, _, _) = - crate::editor::page::build(&mut page, engine, false).map_err(SceneError::from)?; + let objects = crate::editor::page::build(&mut page, engine, false) + .map_err(SceneError::from)? + .objects; let images = Self::decode_images(&objects)?; Ok(Self { reference: Some(objects), diff --git a/crates/notebook/examples/session_client.rs b/crates/notebook/examples/session_client.rs new file mode 100644 index 0000000000000000000000000000000000000000..ac36e7f32806a4676341fd9b01ec6c10dc24c68f --- /dev/null +++ b/crates/notebook/examples/session_client.rs @@ -0,0 +1,195 @@ +//! Drives a section the way the application does: open it through a session, save +//! page-model edits, wait for their publication and report the page texts. +//! `session_client SECTION CACHE_DIR LABEL` edits the first body paragraph of every +//! page whose title starts with "Move" and adds one outline per page; each launch +//! reports the pending queue it found and the receipts it obtained. + +use notebook::{ + EditStatus, + session::{Event, Save, Section}, +}; +use onestore::{ + ExGuid, + page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id}, +}; +use std::{ + sync::mpsc, + time::{Duration, Instant}, +}; + +fn body_text(page: &Page) -> Option { + page.objects.iter().find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find_map(|p| p.text().map(|t| t.id)), + _ => None, + }) +} + +fn texts(page: &Page) -> Vec { + let mut out = Vec::new(); + for object in &page.objects { + let outlines: Vec<&Outline> = match object { + PageObject::Outline(outline) => vec![outline], + PageObject::Title(title) => title.outlines.iter().collect(), + _ => Vec::new(), + }; + for outline in outlines { + for paragraph in &outline.paragraphs { + if let Some(text) = paragraph.text() { + out.push(text.text.text().to_owned()); + } + } + } + } + out +} + +fn edit(page: &mut Page, label: &str) { + let text = body_text(page).expect("a body paragraph"); + for object in &mut page.objects { + let PageObject::Outline(outline) = object else { + continue; + }; + if let Some(paragraph) = outline + .paragraphs + .iter_mut() + .find(|p| p.text().is_some_and(|t| t.id == text)) + { + let target = paragraph.text_mut().unwrap(); + let format = target.text.format_at(0).unwrap().clone(); + target + .text + .apply(onestore::page::text::Edit { + range: 0..0, + replacement: onestore::page::Paragraph::new(format!("{label} "), format), + }) + .unwrap(); + } + } + let template = page + .objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => outline + .paragraphs + .iter() + .find(|p| p.text().is_some()) + .cloned(), + _ => None, + }) + .unwrap(); + let mut paragraph: PageParagraph = template.clone(); + paragraph.id = new_id().unwrap(); + paragraph.parent = None; + paragraph.level = 1; + paragraph.lists.clear(); + paragraph.tags.clear(); + paragraph.style = None; + paragraph.collapsed = false; + paragraph.content = ParagraphContent::Text(TextObject { + id: new_id().unwrap(), + date_field: None, + text: onestore::page::Paragraph::new( + format!("{label} outline 馃 茅"), + template.text().unwrap().text.format_at(0).unwrap().clone(), + ), + tags: Vec::new(), + }); + let outline = Outline { + id: new_id().unwrap(), + title: false, + min_width: None, + layout: onestore::document::Layout { + x: Some(72.0), + y: Some(520.0), + ..Default::default() + }, + indents: Vec::new(), + paragraphs: vec![paragraph], + unsupported: Vec::new(), + }; + let at = page + .objects + .iter() + .position(|o| matches!(o, PageObject::Title(_))) + .unwrap_or(page.objects.len()); + page.objects.insert(at, PageObject::Outline(outline)); +} + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let [_, file, cache, label] = args.as_slice() else { + return Err("Usage: session_client SECTION CACHE_DIR LABEL".into()); + }; + let (notify, notified) = mpsc::channel(); + let section = Section::open(file, cache, move || { + let _ = notify.send(()); + })?; + let pending = section.pending()?; + println!( + "{}", + serde_json::json!({"event": "opened", "pending": pending.len(), "file": section.file()}) + ); + let pages = section.pages()?; + let mut queued = Vec::new(); + for (space, title) in pages.iter().filter(|(_, title)| title.starts_with("Move")) { + let before = section.page(*space)?; + let mut after = before.clone(); + edit(&mut after, label); + match section.save(*space, &before, &after, "session client")? { + Save::Queued(id) => queued.push((id, *space, title.clone(), texts(&after))), + other => return Err(format!("{title}: {other:?}").into()), + } + } + let deadline = Instant::now() + Duration::from_secs(120); + let mut receipts = Vec::new(); + while queued + .iter() + .any(|(id, ..)| !receipts.iter().any(|(n, _)| n == id)) + { + if Instant::now() > deadline { + return Err("Publication timed out".into()); + } + let _ = notified.recv_timeout(Duration::from_millis(200)); + for event in section.events() { + match event { + Event::Attempt { + id, + status: EditStatus::Published { revision }, + } => receipts.push((id, revision)), + Event::Attempt { id, status } => { + println!( + "{}", + serde_json::json!({"event": "attempt", "id": id, "status": format!("{status:?}")}) + ); + } + Event::Unreachable(error) => { + println!( + "{}", + serde_json::json!({"event": "unreachable", "error": error.to_string()}) + ); + } + Event::Failed(error) => return Err(error.into()), + Event::Refreshed => {} + } + } + } + for (id, space, title, expected) in &queued { + let revision = receipts + .iter() + .find(|(n, _)| n == id) + .map(|(_, r)| r.to_string()); + let stored = texts(§ion.page(*space)?); + println!( + "{}", + serde_json::json!({"event": "published", "id": id, "space": space.to_string(), "title": title, + "revision": revision, "texts": expected, "stored": stored}) + ); + } + assert!(section.pending()?.is_empty()); + section.close()?; + println!("{}", serde_json::json!({"event": "closed"})); + Ok(()) +} diff --git a/crates/notebook/src/session.rs b/crates/notebook/src/session.rs index 4278c063709873d1c02f6c17a8cdb59afa445aa4..86f5d0c257d03e68266369aff1ac65cf7adc850d 100644 --- a/crates/notebook/src/session.rs +++ b/crates/notebook/src/session.rs @@ -1,7 +1,9 @@ //! The application's view of a notebook: sections opened through a local replica that //! publishes page saves to the section file in the background. -use crate::{EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover}; +use crate::{ + ConflictKind, EditStatus, Error, PendingEdit, Remote, Replica, Result, SyncWorker, discover, +}; use onestore::{ CommitError, ExGuid, PreparedEdit, RevisionIndex, Store, document::Document, page::Page, }; @@ -87,6 +89,14 @@ pub enum Save { Stale, } +/// A queued edit and its durable state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct QueuedEdit { + pub id: u64, + pub space: ExGuid, + pub status: EditStatus, +} + /// A section file with its replica and background publication. pub struct Section { file: PathBuf, @@ -246,6 +256,57 @@ impl Section { self.replica.pending() } + /// Every queued edit with its state: pending, awaiting confirmation of a retained + /// attempt, or a conflict awaiting review. + pub fn queue(&self) -> Result> { + self.replica + .pending()? + .into_iter() + .map(|edit| { + Ok(QueuedEdit { + id: edit.id, + space: edit.space, + status: self.replica.status(edit.id)?.unwrap_or(EditStatus::Pending), + }) + }) + .collect() + } + + /// The queued edits whose publication conflicted with a remote change. + pub fn conflicts(&self) -> Result> { + Ok(self + .queue()? + .into_iter() + .filter_map(|edit| match edit.status { + EditStatus::Conflict(kind) => Some((edit, kind)), + _ => None, + }) + .collect()) + } + + /// The page as last observed in the section file, for reviewing a conflict. + pub fn remote_page(&self, space: ExGuid) -> Result { + let snapshot = self.replica.remote_snapshot()?; + let store = Store::parse(&snapshot)?; + let index = RevisionIndex::parse(&store)?; + Ok(Page::from_space(&Document::parse(&index)?, space)?) + } + + /// Resolves the oldest conflict with a page reviewed against `remote_page`; the + /// reviewed model publishes as a whole, keeping the edit's id. + pub fn review(&self, id: u64, after: &Page) -> Result<()> { + let local = self.replica.snapshot()?; + let remote = self.replica.remote_snapshot()?; + self.replica.review_page(id, &local, &remote, after)?; + self.wake(); + Ok(()) + } + + /// Captures both images, the queue and its states in a read-only archive. + pub fn export_recovery(&self, path: impl AsRef) -> Result<()> { + self.replica.export_recovery(path) + } + /// Events since the last poll, oldest first. pub fn events(&self) -> Vec { self.events.try_iter().collect() diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 609d289f56719ec769c5a19542122a47db618002..2d221c31d5c24564a079b187656bb8de04028e4a 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -1,6 +1,6 @@ use notebook::{ - EditStatus, - session::{Event, Notebook, Save, Section}, + ConflictKind, EditStatus, Recovery, + session::{Event, Notebook, QueuedEdit, Save, Section}, }; use onestore::{ExGuid, PreparedEdit, page::Page}; use std::{ @@ -601,3 +601,82 @@ fn dropping_during_connection_keeps_cache_owned_until_the_worker_finishes() { ); assert!(connecting.try_recv().is_err()); } + +#[test] +fn a_conflicting_save_is_reviewed_against_the_remote_page_and_archived_for_recovery() { + let directory = tempfile::tempdir().unwrap(); + let file = directory.path().join("notes.one"); + let cache = directory.path().join("cache"); + std::fs::write( + &file, + onestore::create_section("notes.one", "Original", "Author").unwrap(), + ) + .unwrap(); + let (section, _) = open(&file, &cache); + let space = section.pages().unwrap()[0].0; + let before = section.page(space).unwrap(); + let text = first_text(&before); + use std::os::unix::fs::PermissionsExt; + let permissions = std::fs::metadata(&file).unwrap().permissions(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap(); + let mut local = before.clone(); + model_ops::replace_text(&mut local, text, 0..8, "Local"); + let Save::Queued(id) = section.save(space, &before, &local, "Editor").unwrap() else { + panic!() + }; + wait(§ion, |event| matches!(event, Event::Unreachable(_))); + std::fs::set_permissions(&file, permissions).unwrap(); + let mut native = before.clone(); + model_ops::replace_text(&mut native, text, 0..8, "Native"); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let bytes = onestore::read_file(&file).unwrap(); + match PreparedEdit::page(&bytes, space, &native, "Native") + .unwrap() + .commit_file(&file) + { + Ok(()) => break, + Err(error) if error.error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(20)); + } + Err(error) => panic!("{error:?}"), + } + } + section.wake(); + wait( + §ion, + |event| matches!(event, Event::Attempt { id: n, status: EditStatus::Conflict(ConflictKind::ContentChanged) } if *n == id), + ); + let conflicts = section.conflicts().unwrap(); + assert_eq!( + conflicts, + [( + QueuedEdit { + id, + space, + status: EditStatus::Conflict(ConflictKind::ContentChanged) + }, + ConflictKind::ContentChanged + )] + ); + assert_same(section.page(space).unwrap(), &local); + let remote = section.remote_page(space).unwrap(); + assert_same(remote.clone(), &native); + let archive = directory.path().join("review.sqlite"); + section.export_recovery(&archive).unwrap(); + let recovery = Recovery::open(&archive).unwrap(); + assert_eq!(recovery.pending().unwrap().len(), 1); + assert_eq!( + recovery.status(id).unwrap(), + Some(EditStatus::Conflict(ConflictKind::ContentChanged)) + ); + let mut reviewed = remote.clone(); + model_ops::replace_text(&mut reviewed, text, 0..6, "Native and local"); + section.review(id, &reviewed).unwrap(); + assert_eq!(section.status(id).unwrap(), Some(EditStatus::Pending)); + published(§ion, id); + assert_same(stored_page(&file, space), &reviewed); + assert!(section.queue().unwrap().is_empty()); + section.close().unwrap(); +} diff --git a/crates/snowbound/Cargo.toml b/crates/snowbound/Cargo.toml index af56d3e3b13831eda5cc080cad7565e1532b4ff5..dc6a9c3cfb15d7d40f2803726ce389ee3eadeb86 100644 --- a/crates/snowbound/Cargo.toml +++ b/crates/snowbound/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] canvas = { path = "../canvas", features = ["gpu"] } onestore = { path = "../onestore" } +notebook = { path = "../notebook" } pollster = "0.4" wgpu.workspace = true parley.workspace = true diff --git a/crates/snowbound/src/accessibility.rs b/crates/snowbound/src/accessibility.rs index 289955ef6e98fdf870c04eaaae3b299ce579c7e1..6185b2f576be29180026d3557eae913b1d18fd51 100644 --- a/crates/snowbound/src/accessibility.rs +++ b/crates/snowbound/src/accessibility.rs @@ -1324,7 +1324,11 @@ mod tests { .selection(editor.active_outline().id, selection) .unwrap() ); - let count = editor.outlines().iter().map(|o| o.layouts().count()).sum(); + let count = editor + .outlines() + .iter() + .map(|o| o.layouts().count()) + .sum::(); assert_eq!(cached.paragraphs.len(), count); } }; diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 19ff3a3e07e4ce2eedc0a3800859b2b5ac774d07..7fd0920b7c91164c8ed28f071db70061d595c98d 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -39,6 +39,8 @@ enum UserEvent { Quit, InsertText(String), Accessibility(accesskit_winit::Event), + /// The section's synchronization thread reported an event. + Sync, } impl From for UserEvent { @@ -75,6 +77,32 @@ enum Input { reference: Option, }, Page(Page), + Section { + file: PathBuf, + title: String, + cache: PathBuf, + }, +} + +/// The opened section and the stored model the editor's page was loaded from. +struct Session { + section: notebook::session::Section, + space: onestore::ExGuid, + before: Page, + title: String, + status: &'static str, +} + +impl Session { + fn window_title(&self) -> String { + let file = self + .section + .file() + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{} 路 {file}{}", self.title, self.status) + } } enum Drag { @@ -112,6 +140,7 @@ struct State { renderer: Renderer, engine: TextEngine, editor: CanvasEditor, + session: Option, initial: Vec<(onestore::ExGuid, TextDocument)>, initial_layouts: Vec<(onestore::ExGuid, onestore::document::Layout)>, initial_date: Option, @@ -167,13 +196,14 @@ impl State { Input::Notes { reference: None, .. } => "Untitled 路 Temporary page".into(), + Input::Section { title, .. } => title.clone(), }) .with_inner_size(LogicalSize::new(1000.0, 720.0)), )?, ); macos::install_text_input(&window); let access_adapter = - accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy); + accesskit_winit::Adapter::with_event_loop_proxy(event_loop, &window, proxy.clone()); window.set_visible(true); let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_with_display_handle( Box::new(window.clone()), @@ -199,6 +229,7 @@ impl State { .register_substitute(parley::fontique::Blob::new(Arc::new(std::fs::read(path)?)))?; eprintln!("Using {} for {target}", path.display()); } + let mut session = None; let (editor, scene) = match input { Input::Notes { document, @@ -217,7 +248,30 @@ impl State { let (scene, editor) = PageScene::from_page(page, &mut engine)?; (editor, Some((scene, [0.0; 2]))) } + Input::Section { file, title, cache } => { + let section = notebook::session::Section::open(&file, cache, move || { + let _ = proxy.send_event(UserEvent::Sync); + })?; + let (space, _) = section + .pages()? + .into_iter() + .find(|(_, candidate)| *candidate == title) + .ok_or_else(|| format!("No page titled {title:?} in {}", file.display()))?; + let before = section.page(space)?; + let (scene, editor) = PageScene::from_page(before.clone(), &mut engine)?; + session = Some(Session { + section, + space, + before, + title, + status: "", + }); + (editor, Some((scene, [0.0; 2]))) + } }; + if let Some(session) = &session { + window.set_title(&session.window_title()); + } let initial_date = editor.date().map(|date| date.timestamp()); let initial_layouts = editor .object_layouts() @@ -241,6 +295,7 @@ impl State { renderer, engine, editor, + session, initial, initial_date, initial_layouts, @@ -331,7 +386,8 @@ impl State { ), ); self.update_accessibility()?; - if self.editor.active_outline().title { + self.persist()?; + if self.session.is_none() && self.editor.active_outline().title { let title = self .editor .active_outline() @@ -357,6 +413,112 @@ impl State { Ok(()) } + /// Saves the edited page to the section's replica; a page changed underneath the + /// editor is reloaded in place of the edit. + fn persist(&mut self) -> Result<(), Box> { + let Some(session) = &mut self.session else { + return Ok(()); + }; + let after = self.editor.page()?; + match session + .section + .save(session.space, &session.before, &after, "snowbound")? + { + notebook::session::Save::Unchanged => {} + notebook::session::Save::Queued(_) => { + session.before = session.section.page(session.space)?; + session.status = " 路 saving"; + self.window.set_title(&session.window_title()); + } + notebook::session::Save::Stale => self.reload()?, + } + Ok(()) + } + + /// Replaces the editor with the page currently stored in the section. + fn reload(&mut self) -> Result<(), Box> { + let Some(session) = &mut self.session else { + return Ok(()); + }; + let page = session.section.page(session.space)?; + let (scene, editor) = PageScene::from_page(page.clone(), &mut self.engine)?; + session.before = page; + self.editor = editor; + self.scene = Some((scene, [0.0; 2])); + self.drag = None; + self.read_only_focus = None; + self.window.set_title(&session.window_title()); + self.update_accessibility()?; + self.window.request_redraw(); + Ok(()) + } + + /// Reviews the oldest conflict on this page: `keep_mine` publishes the editor's page + /// over the remote change, otherwise the remote page replaces the editor's. + fn resolve_conflict(&mut self, keep_mine: bool) -> Result<(), Box> { + let Some(session) = &mut self.session else { + return Ok(()); + }; + let Some((edit, _)) = session + .section + .conflicts()? + .into_iter() + .find(|(edit, _)| edit.space == session.space) + else { + return Ok(()); + }; + let reviewed = if keep_mine { + self.editor.page()? + } else { + session.section.remote_page(session.space)? + }; + session.section.review(edit.id, &reviewed)?; + session.status = " 路 saving"; + if keep_mine { + session.before = session.section.page(session.space)?; + self.window.set_title(&session.window_title()); + Ok(()) + } else { + self.reload() + } + } + + /// Applies what the synchronization thread reported since the last poll. + fn synced(&mut self) -> Result<(), Box> { + let Some(session) = &mut self.session else { + return Ok(()); + }; + let mut refreshed = false; + for event in session.section.events() { + use notebook::session::Event; + session.status = match event { + Event::Refreshed => { + refreshed = true; + continue; + } + Event::Attempt { + status: notebook::EditStatus::Published { .. }, + .. + } => " 路 saved", + Event::Attempt { + status: notebook::EditStatus::Conflict(_), + .. + } => " 路 conflict", + Event::Attempt { .. } => " 路 saving", + Event::Unreachable(_) => " 路 offline", + Event::Failed(error) => { + eprintln!("Synchronization stopped: {error}"); + " 路 not saving" + } + }; + } + self.window.set_title(&session.window_title()); + if refreshed && session.section.page(session.space)? != session.before { + self.reload()?; + } + Ok(()) + } + fn update_accessibility(&mut self) -> Result<(), Box> { let mut error = None; let preview = self.preview(); @@ -659,6 +821,17 @@ impl State { macos::show_character_palette(); return Ok(()); } + if command && shift && self.session.is_some() { + match key { + Key::Character(character) if character.eq_ignore_ascii_case("k") => { + return self.resolve_conflict(true); + } + Key::Character(character) if character.eq_ignore_ascii_case("t") => { + return self.resolve_conflict(false); + } + _ => {} + } + } if matches!(self.drag, Some(Drag::Outline { .. } | Drag::Resize { .. })) { if matches!( key, @@ -932,25 +1105,26 @@ impl State { impl App { fn close(&self, event_loop: &ActiveEventLoop) { if self.state.as_ref().is_none_or(|state| { - state - .editor - .caret_outline() - .is_none_or(TextOutline::is_empty) - && state.initial_date == state.editor.date().map(|date| date.timestamp()) - && state - .initial_layouts - .iter() - .map(|(id, layout)| (*id, layout)) - .eq(state.editor.object_layouts()) - && state - .initial - .iter() - .map(|(id, document)| (id, document)) - .eq(state - .editor - .outlines() + state.session.is_some() + || state + .editor + .caret_outline() + .is_none_or(TextOutline::is_empty) + && state.initial_date == state.editor.date().map(|date| date.timestamp()) + && state + .initial_layouts .iter() - .map(|outline| (&outline.id, outline.document()))) + .map(|(id, layout)| (*id, layout)) + .eq(state.editor.object_layouts()) + && state + .initial + .iter() + .map(|(id, document)| (id, document)) + .eq(state + .editor + .outlines() + .iter() + .map(|outline| (&outline.id, outline.document()))) }) || macos::discard_changes() { event_loop.exit(); @@ -982,6 +1156,14 @@ impl ApplicationHandler for App { self.close(event_loop); return; } + UserEvent::Sync => { + if let Some(state) = &mut self.state + && let Err(error) = state.synced() + { + eprintln!("{error}"); + } + return; + } UserEvent::Accessibility(event) => event, }; let Some(state) = &mut self.state else { @@ -1812,12 +1994,33 @@ fn main() -> Result<(), Box> { let mut substitutes = Vec::new(); let mut reference = None; let mut editable = false; + let mut section = None; + let mut cache = None; while let Some(arg) = args.next() { if arg == "--substitute-font" { substitutes.push(PathBuf::from( args.next() .ok_or("Provide a font file after --substitute-font.")?, )); + } else if arg == "--section" { + if reference.is_some() || section.is_some() { + return Err("Only one page can be opened.".into()); + } + let file = PathBuf::from( + args.next() + .ok_or("Provide a section file and page title after --section.")?, + ); + let title = args + .next() + .ok_or("Provide a page title after the section file.")? + .to_str() + .ok_or("The page title must be valid Unicode.")? + .to_owned(); + section = Some((file, title)); + } else if arg == "--cache" { + cache = Some(PathBuf::from( + args.next().ok_or("Provide a directory after --cache.")?, + )); } else if arg == "--reference" || arg == "--page" { if reference.is_some() { return Err("Only one page can be opened.".into()); @@ -1842,14 +2045,14 @@ fn main() -> Result<(), Box> { positional.push(arg); } } - if editable && !positional.is_empty() { + if (editable || section.is_some()) && !positional.is_empty() { return Err( - "Use --page with a section file and page title, without a text file or width.".into(), + "Use --page or --section with a section file and page title, without a text file or width.".into(), ); } if positional.len() > 2 { return Err( - "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE] [--substitute-font FONT_FILE]..." + "Usage: snowbound [TEXT_FILE] [WIDTH_POINTS] [--reference SECTION PAGE_TITLE | --page SECTION PAGE_TITLE | --section SECTION PAGE_TITLE [--cache DIR]] [--substitute-font FONT_FILE]..." .into(), ); } @@ -1867,7 +2070,14 @@ fn main() -> Result<(), Box> { } else { 480.0 }); - let input = if editable { + let input = if let Some((file, title)) = section { + let cache = match cache { + Some(cache) => cache, + None => PathBuf::from(std::env::var_os("HOME").ok_or("HOME is not set.")?) + .join("Library/Caches/snowbound"), + }; + Input::Section { file, title, cache } + } else if editable { Input::Page(reference.unwrap()) } else { Input::Notes { diff --git a/tools/TESTING.md b/tools/TESTING.md index 6b96a7e66780c72867c2a5d57c974cc2ea2a6504..f839f1e364fa7883959d8152467b8a1d5bf24624 100644 --- a/tools/TESTING.md +++ b/tools/TESTING.md @@ -40,7 +40,7 @@ the run's commands, inputs, outputs and teardown evidence. | Mixed native/Rust/offline writers | `native_collaboration.py --help` | Recorded intents, durable receipts, independent server state and cold native comparison | | SMB directory pagination | `test_smb_directory.py --help` | Caller-owned Linux VM, native filesystem oracle, interrupted-page rejection | | SMB publication and payload interruptions | Ignored tests in `notebook` (feature `smb`) | Explicit `ONESTORE_SMB_*` lab inputs, retained protocol traces and independent recovery checks | -| Retained cache migration | Ignored `migrate_retained_cache_copy` test | New destination, unchanged source, images, intent IDs, attempts and receipts | +| 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 | To compare an additional **already captured** notebook hierarchy without running OneNote: diff --git a/tools/session_acceptance.py b/tools/session_acceptance.py new file mode 100644 index 0000000000000000000000000000000000000000..bbee5cc6f47d5cdd7b7b17d3087a5b937e559940 --- /dev/null +++ b/tools/session_acceptance.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Edit a notebook on the owned Samba share through the application's session, relaunch, +then cold-open the result in a disposable OneNote clone and compare its text.""" +import argparse +import html +import json +import os +import re +import shutil +import subprocess +import sys +import tarfile +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / 'tools/w7')) +sys.path.insert(0, str(ROOT / 'tools')) +import linux_vm # noqa: E402 + +FIXTURE = ROOT / 'corpus/outline-edit/before/notebook' +NS = {'one': 'http://schemas.microsoft.com/office/onenote/2010/onenote'} + + +def native_texts(read): + pages = {} + for path in sorted(read.glob('page-*.xml')): + root = ET.parse(path).getroot() + texts = [] + for element in root.iter('{%s}T' % NS['one']): + texts.append(html.unescape(re.sub(r'<[^>]*>', '', element.text or ''))) + pages[root.get('name')] = texts + return pages + + +def compare(expected, native): + """The client's edits carry its launch label; other text is compared by the corpus lanes.""" + mismatches = [] + for title, texts in expected.items(): + body = [text for text in native[title] if text != title] + wanted = [text for text in texts if 'launch' in text] + assert wanted, title + for text in wanted: + if text not in body: + mismatches.append({'page': title, 'missing': text, 'native': body}) + return mismatches + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('output', type=Path) + parser.add_argument('--linux', help='Disposable Linux VM owned by this run') + parser.add_argument('--expected-pages', type=int, default=15) + parser.add_argument('--verify-only', action='store_true', + help='Compare an existing capture in OUTPUT without the lab.') + args = parser.parse_args() + output = args.output.resolve() + if not args.verify_only and not args.linux: + parser.error('--linux names the disposable Linux VM this run owns') + if args.verify_only: + expected = {} + for launch in ('first', 'second'): + for line in (output / f'{launch}.log').read_text().splitlines(): + if line.startswith('{') and '"published"' in line: + event = json.loads(line) + expected[event['title']] = event['texts'] + native = native_texts(output / 'result/cold/read') + mismatches = compare(expected, native) + (output / 'verification.json').write_text(json.dumps( + {'expected': expected, 'native': {title: native[title] for title in expected}, 'mismatches': mismatches}, + indent=2, ensure_ascii=False)) + assert not mismatches, mismatches + print(f'Session acceptance verified: {len(expected)} pages', flush=True) + return + output.mkdir(parents=True) + server = args.linux + mount = output / 'mount' + mount.mkdir() + client = ROOT / 'target/debug/examples/session_client' + subprocess.run(['cargo', 'build', '-p', 'notebook', '--example', 'session_client'], cwd=ROOT, check=True) + record = {'server': server, 'fixture': str(FIXTURE.relative_to(ROOT)), 'launches': []} + + def unmount(): + for options in ([], ['-f']): + if not os.path.ismount(mount): + return + subprocess.run(['/sbin/umount', *options, str(mount)], capture_output=True, text=True, timeout=60) + if os.path.ismount(mount): + raise RuntimeError('The owned SMB mount remains attached') + + try: + if not linux_vm.instance_path(server).exists(): + linux_vm.create_instance(server) + linux_vm.launch(server) + linux_vm.wait_instance(server, 600) + config = linux_vm.load_instance(server) + (output / 'linux.json').write_text(json.dumps(config, indent=2)) + linux_vm.run_ssh(server, 'mkdir /srv/agent/m4-session').check_returncode() + with tarfile.open(output / 'input.tar', 'w', dereference=True) as archive: + for name in ('synthetic.one', 'Open Notebook.onetoc2'): + archive.add(FIXTURE / name, arcname=name) + with (output / 'input.tar').open('rb') as stream: + subprocess.run(linux_vm.ssh_argv(server, 'tar xf - -C /srv/agent/m4-session'), stdin=stream, check=True) + linux_vm.run_ssh(server, 'chmod u+w /srv/agent/m4-session/*').check_returncode() + subprocess.run(['/sbin/mount_smbfs', '-N', f'//guest@127.0.0.1:{config["samba_port"]}/agent', mount], + check=True, stdin=subprocess.DEVNULL) + section = mount / 'm4-session/synthetic.one' + assert section.read_bytes() == (FIXTURE / 'synthetic.one').read_bytes() + cache = output / 'cache' + expected = {} + for launch in ('First launch', 'Second launch'): + result = subprocess.run([client, section, cache, launch], capture_output=True, text=True, timeout=300) + (output / f'{launch.split()[0].lower()}.log').write_text(result.stdout + result.stderr) + result.check_returncode() + events = [json.loads(line) for line in result.stdout.splitlines() if line.startswith('{')] + assert events[0]['event'] == 'opened' and events[0]['pending'] == 0, events[0] + published = [e for e in events if e['event'] == 'published'] + assert published and all(e['revision'] for e in published), published + for e in published: + assert e['stored'] == e['texts'], e + expected[e['title']] = e['texts'] + record['launches'].append({'label': launch, 'published': published}) + unmount() + result = output / 'result' + (result / 'notebook').mkdir(parents=True) + with (output / 'result.tar').open('wb') as stream: + subprocess.run(linux_vm.ssh_argv(server, 'tar cf - -C /srv/agent/m4-session .'), stdout=stream, check=True) + with tarfile.open(output / 'result.tar') as archive: + archive.extractall(result / 'notebook', filter='data') + subprocess.run([sys.executable, ROOT / 'tools/native_runner.py', result / 'notebook', result / 'cold', + '--expected-pages', str(args.expected_pages), '--collect-notebook'], check=True) + native = native_texts(result / 'cold/read') + mismatches = compare(expected, native) + record['native'] = {title: native[title] for title in expected} + record['mismatches'] = mismatches + (output / 'record.json').write_text(json.dumps(record, indent=2, ensure_ascii=False)) + assert not mismatches, mismatches + print(f'Session acceptance passed: {len(expected)} pages edited twice, reopened natively', flush=True) + finally: + unmount() + if linux_vm.instance_path(server).exists(): + if linux_vm.running(server): + linux_vm.shutdown(server, 60) + linux_vm.delete_instance(server) + shutil.rmtree(mount, ignore_errors=True) + + +if __name__ == '__main__': + main()