| author | |
| committer | |
| log | c2174276845a26013191ed67398382758312ac8f |
| tree | 1f19b0c6ceb9679db8438c5a67be3032f101fe88 |
| parent | 206bd8fba08030b8e592fdf5adcc4743005bd83a |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
- Paste plain text into focused input fields, including dialogs and search.
- Reject oversized files before reading them, import accepted files off the frame thread, and bound Live Share uploads and encoding.
- Keep Unicode search matches on character boundaries.
- Insert attachments from the title into the page body so they save correctly.
Assisted-by: gpt-6.1-sol18 files changed, 497 insertions(+), 90 deletions(-)
crates/canvas/src/editor.rs+3| ... | ... | @@ -2503,6 +2503,9 @@ impl CanvasEditor { |
| 2503 | 2503 | engine: &mut TextEngine, |
| 2504 | 2504 | mut file: onestore::page::Attachment, |
| 2505 | 2505 | ) -> Result<(), EditorError> { |
| 2506 | if self.active_outline().title && self.leave_title_for_body(engine)? { | |
| 2507 | return self.insert_in_flow(engine, ParagraphContent::Attachment(file)); | |
| 2508 | } | |
| 2506 | 2509 | if let Focus::Caret { outline, .. } = &self.active { |
| 2507 | 2510 | [file.layout.x, file.layout.y] = outline.origin().map(Some); |
| 2508 | 2511 | let layout = Box::new(crate::outline::page_file(engine, &file)?); |
crates/canvas/src/search.rs+1-5| ... | ... | @@ -516,11 +516,7 @@ pub fn page_matches(editor: &CanvasEditor, query: &Query) -> Vec<PageMatch> { |
| 516 | 516 | start = span.end; |
| 517 | 517 | } |
| 518 | 518 | for hit in query.find(&shown) { |
| 519 | let end = source[hit.end - 1] | |
| 520 | + paragraph.text()[source[hit.end - 1]..] | |
| 521 | .chars() | |
| 522 | .next() | |
| 523 | .map_or(0, char::len_utf8); | |
| 519 | let end = source[hit.end - 1] + 1; | |
| 524 | 520 | let (Ok(from), Ok(to)) = ( |
| 525 | 521 | paragraph.utf16_offset(source[hit.start]), |
| 526 | 522 | paragraph.utf16_offset(end), |
crates/canvas/tests/attachment_insert.rs+76-2| ... | ... | @@ -7,8 +7,8 @@ |
| 7 | 7 | use canvas::{editor::CanvasEditor, layout::TextEngine}; |
| 8 | 8 | use draw::edit::Movement; |
| 9 | 9 | use onestore::{ |
| 10 | Arena, Section, Store, | |
| 11 | op::{Edit, Op}, | |
| 10 | Arena, PageCreation, Section, Store, | |
| 11 | op::{Edit, Op, SectionOp}, | |
| 12 | 12 | page::{Attachment, PageObject, ParagraphContent}, |
| 13 | 13 | }; |
| 14 | 14 | use std::sync::Arc; |
| ... | ... | @@ -62,6 +62,80 @@ fn file(name: &str, bytes: Vec<u8>, preview: Option<Arc<[u8]>>) -> Attachment { |
| 62 | 62 | } |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | #[test] | |
| 66 | fn a_file_attached_from_the_title_stores_in_the_body() { | |
| 67 | let mut image = onestore::create_section("files.one", "First page", "Author").unwrap(); | |
| 68 | let arena = Arena::default(); | |
| 69 | let mut section = Section::open(&arena, image.clone()).unwrap(); | |
| 70 | section | |
| 71 | .apply( | |
| 72 | "Author", | |
| 73 | &Edit { | |
| 74 | at: 133_000_000_000_000_000, | |
| 75 | ops: vec![Op::Section(SectionOp::Create( | |
| 76 | PageCreation::new(None, Some("Attachment from title"), "Author").unwrap(), | |
| 77 | ))], | |
| 78 | }, | |
| 79 | ) | |
| 80 | .unwrap(); | |
| 81 | let (space, ..) = section.pages().unwrap()[1].clone(); | |
| 82 | let mut engine = TextEngine::default(); | |
| 83 | let mut editor = CanvasEditor::from_page(section.page(space).unwrap(), &mut engine).unwrap(); | |
| 84 | let title = editor | |
| 85 | .outlines() | |
| 86 | .iter() | |
| 87 | .find(|outline| outline.title) | |
| 88 | .unwrap() | |
| 89 | .id; | |
| 90 | editor.focus_outline(title).unwrap(); | |
| 91 | let attached = file( | |
| 92 | "attachment.txt", | |
| 93 | b"Snowbound bounded import fixture\n".to_vec(), | |
| 94 | Some(native_icon()), | |
| 95 | ); | |
| 96 | editor | |
| 97 | .insert_attachment(&mut engine, attached.clone()) | |
| 98 | .unwrap(); | |
| 99 | assert!(!editor.active_outline().title); | |
| 100 | let ops = editor | |
| 101 | .take_ops() | |
| 102 | .unwrap() | |
| 103 | .into_iter() | |
| 104 | .map(|op| Op::Page { space, op }) | |
| 105 | .collect(); | |
| 106 | section | |
| 107 | .apply( | |
| 108 | "Author", | |
| 109 | &Edit { | |
| 110 | at: 133_000_000_010_000_000, | |
| 111 | ops, | |
| 112 | }, | |
| 113 | ) | |
| 114 | .unwrap(); | |
| 115 | section.seal().unwrap().unwrap().apply(&mut image).unwrap(); | |
| 116 | let arena = Arena::default(); | |
| 117 | let mut reopened = Section::open(&arena, image.clone()).unwrap(); | |
| 118 | assert_eq!(reopened.pages().unwrap()[1].1, "Attachment from title"); | |
| 119 | let page = reopened.page(space).unwrap(); | |
| 120 | let files: Vec<_> = page | |
| 121 | .objects | |
| 122 | .iter() | |
| 123 | .filter_map(|object| match object { | |
| 124 | PageObject::Outline(outline) if !outline.title => Some(&outline.paragraphs), | |
| 125 | _ => None, | |
| 126 | }) | |
| 127 | .flatten() | |
| 128 | .filter_map(|paragraph| match &paragraph.content { | |
| 129 | ParagraphContent::Attachment(file) => Some(file), | |
| 130 | _ => None, | |
| 131 | }) | |
| 132 | .collect(); | |
| 133 | assert_eq!(files, [&attached]); | |
| 134 | if let Some(path) = std::env::var_os("CANVAS_TITLE_ATTACHMENT_EXPORT") { | |
| 135 | std::fs::write(path, image).unwrap(); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 65 | 139 | #[test] |
| 66 | 140 | fn attached_files_store_their_bytes_between_the_text_around_them() { |
| 67 | 141 | let source = onestore::create_section("files.one", "Before the file", "Author").unwrap(); |
crates/canvas/tests/search.rs+21| ... | ... | @@ -135,6 +135,27 @@ fn find_on_page_counts_what_onenote_counts() { |
| 135 | 135 | assert!(page_matches(&editor, &Query::new("zzq")).is_empty()); |
| 136 | 136 | } |
| 137 | 137 | |
| 138 | #[test] | |
| 139 | fn find_on_page_keeps_unicode_matches_on_character_boundaries() { | |
| 140 | let mut engine = TextEngine::default(); | |
| 141 | let page = page(&mut engine, "Unicode", "雪 ☃ 🦀 café"); | |
| 142 | let editor = CanvasEditor::from_page(page, &mut engine).unwrap(); | |
| 143 | for (query, offsets) in [ | |
| 144 | ("雪", [0, 1]), | |
| 145 | ("☃", [2, 3]), | |
| 146 | ("🦀", [4, 6]), | |
| 147 | ("cafe", [7, 11]), | |
| 148 | ] { | |
| 149 | let matches = page_matches(&editor, &Query::new(query)); | |
| 150 | assert_eq!(matches.len(), 1, "{query}"); | |
| 151 | assert_eq!( | |
| 152 | matches[0].1.positions.map(|at| at.offset), | |
| 153 | offsets, | |
| 154 | "{query}" | |
| 155 | ); | |
| 156 | } | |
| 157 | } | |
| 158 | ||
| 138 | 159 | #[test] |
| 139 | 160 | fn folding_ignores_case_and_diacritics() { |
| 140 | 161 | assert_eq!(fold("Crème BRÛLÉE"), "creme brulee"); |
crates/notebook/src/bin/onestore-diagnostic.rs+1-1| ... | ... | @@ -83,7 +83,7 @@ fn run(args: &[std::ffi::OsString]) -> Result<Value, Box<dyn std::error::Error>> |
| 83 | 83 | &mut notebook::discover::Local::open(root)?, |
| 84 | 84 | notebook::discover::Limits { |
| 85 | 85 | entries: 100_000, |
| 86 | bytes_per_file: 256 * 1024 * 1024, | |
| 86 | bytes_per_file: notebook::MAX_FILE_BYTES, | |
| 87 | 87 | depth: 64, |
| 88 | 88 | }, |
| 89 | 89 | )?; |
crates/notebook/src/fs.rs+45| ... | ... | @@ -20,7 +20,52 @@ mod web; |
| 20 | 20 | #[cfg(target_arch = "wasm32")] |
| 21 | 21 | pub use web::*; |
| 22 | 22 | |
| 23 | /// Reads a regular file within `limit`, including when it grows during the read. | |
| 24 | pub fn read_limited(path: impl AsRef<std::path::Path>, limit: usize) -> std::io::Result<Vec<u8>> { | |
| 25 | use std::io::Read; | |
| 26 | let file = File::open(path)?; | |
| 27 | let metadata = file.metadata()?; | |
| 28 | if !metadata.is_file() { | |
| 29 | return Err(std::io::ErrorKind::InvalidInput.into()); | |
| 30 | } | |
| 31 | if metadata.len() > limit as u64 { | |
| 32 | return Err(std::io::ErrorKind::FileTooLarge.into()); | |
| 33 | } | |
| 34 | let mut bytes = Vec::new(); | |
| 35 | file.take((limit as u64).saturating_add(1)) | |
| 36 | .read_to_end(&mut bytes)?; | |
| 37 | if bytes.len() > limit { | |
| 38 | return Err(std::io::ErrorKind::FileTooLarge.into()); | |
| 39 | } | |
| 40 | Ok(bytes) | |
| 41 | } | |
| 42 | ||
| 23 | 43 | #[cfg(not(target_arch = "wasm32"))] |
| 24 | 44 | pub async fn durable() -> std::io::Result<()> { |
| 25 | 45 | Ok(()) |
| 26 | 46 | } |
| 47 | ||
| 48 | #[cfg(all(test, not(target_arch = "wasm32")))] | |
| 49 | mod tests { | |
| 50 | use super::*; | |
| 51 | ||
| 52 | #[test] | |
| 53 | fn bounded_reads_refuse_an_eight_gib_file() { | |
| 54 | let file = tempfile::NamedTempFile::new().unwrap(); | |
| 55 | write(file.path(), b"hello").unwrap(); | |
| 56 | assert_eq!(read_limited(file.path(), 5).unwrap(), b"hello"); | |
| 57 | assert_eq!( | |
| 58 | read_limited(file.path(), 4).unwrap_err().kind(), | |
| 59 | std::io::ErrorKind::FileTooLarge | |
| 60 | ); | |
| 61 | file.as_file().set_len(8 << 30).unwrap(); | |
| 62 | assert_eq!( | |
| 63 | read_limited(file.path(), crate::MAX_FILE_BYTES) | |
| 64 | .unwrap_err() | |
| 65 | .kind(), | |
| 66 | std::io::ErrorKind::FileTooLarge | |
| 67 | ); | |
| 68 | let folder = tempfile::tempdir().unwrap(); | |
| 69 | assert!(read_limited(folder.path(), crate::MAX_FILE_BYTES).is_err()); | |
| 70 | } | |
| 71 | } |
crates/notebook/src/lib.rs+3| ... | ... | @@ -49,6 +49,9 @@ mod working; |
| 49 | 49 | pub use smb::SmbRemote; |
| 50 | 50 | pub use worker::SyncWorker; |
| 51 | 51 | |
| 52 | /// The largest file a notebook session or Live Share reads or writes whole. | |
| 53 | pub const MAX_FILE_BYTES: usize = 256 << 20; | |
| 54 | ||
| 52 | 55 | #[derive(Debug, thiserror::Error)] |
| 53 | 56 | pub enum Error { |
| 54 | 57 | #[error(transparent)] |
crates/notebook/src/live/share.rs+89-13| ... | ... | @@ -42,8 +42,7 @@ const CHUNK: usize = 128 << 10; |
| 42 | 42 | const WINDOW: usize = 512 << 10; |
| 43 | 43 | /// How long a request waits for its reply. |
| 44 | 44 | const TIMEOUT: Duration = Duration::from_secs(60); |
| 45 | /// The largest file read or written whole. | |
| 46 | const LIMIT: usize = 256 << 20; | |
| 45 | use crate::MAX_FILE_BYTES as LIMIT; | |
| 47 | 46 | /// Snapshots of files being read, per guest, and how long one is kept unread. |
| 48 | 47 | const SNAPSHOTS: usize = 8; |
| 49 | 48 | const SNAPSHOT_AGE: Duration = Duration::from_secs(120); |
| ... | ... | @@ -501,14 +500,28 @@ impl Served { |
| 501 | 500 | let handle = request.handle.unwrap_or_default(); |
| 502 | 501 | let bytes = request.bytes.unwrap_or_default(); |
| 503 | 502 | let mut puts = self.puts.lock().unwrap(); |
| 504 | let held = puts.entry((*peer, handle)).or_default(); | |
| 505 | if request.offset != Some(held.len() as u64) || held.len() + bytes.len() > LIMIT { | |
| 503 | let key = (*peer, handle); | |
| 504 | let length = puts.get(&key).map_or(0, Vec::len); | |
| 505 | if request.offset != Some(length as u64) || bytes.is_empty() { | |
| 506 | puts.remove(&key); | |
| 506 | 507 | return Err(refused( |
| 507 | 508 | io::ErrorKind::InvalidInput, |
| 508 | 509 | "An upload out of order", |
| 509 | 510 | )); |
| 510 | 511 | } |
| 511 | held.extend_from_slice(&bytes); | |
| 512 | let held: usize = puts | |
| 513 | .iter() | |
| 514 | .filter(|((guest, _), _)| guest == peer) | |
| 515 | .map(|(_, bytes)| bytes.len()) | |
| 516 | .sum(); | |
| 517 | if bytes.len() > LIMIT.saturating_sub(held) { | |
| 518 | puts.remove(&key); | |
| 519 | return Err(refused( | |
| 520 | io::ErrorKind::FileTooLarge, | |
| 521 | "An upload is too large", | |
| 522 | )); | |
| 523 | } | |
| 524 | puts.entry(key).or_default().extend_from_slice(&bytes); | |
| 512 | 525 | done |
| 513 | 526 | } |
| 514 | 527 | kind::COMMIT => { |
| ... | ... | @@ -1228,6 +1241,9 @@ impl Guest { |
| 1228 | 1241 | request: &mut Request, |
| 1229 | 1242 | bytes: Vec<u8>, |
| 1230 | 1243 | ) -> std::result::Result<(), Failed> { |
| 1244 | if bytes.len() > LIMIT { | |
| 1245 | return Err(Failed::Unsent(io::ErrorKind::FileTooLarge.into())); | |
| 1246 | } | |
| 1231 | 1247 | if bytes.len() <= CHUNK { |
| 1232 | 1248 | request.bytes = Some(bytes); |
| 1233 | 1249 | return Ok(()); |
| ... | ... | @@ -1436,16 +1452,19 @@ impl Guest { |
| 1436 | 1452 | edits: &[crate::PendingEdit], |
| 1437 | 1453 | revisions: &BTreeMap<onestore::ExGuid, onestore::ExGuid>, |
| 1438 | 1454 | ) -> std::result::Result<(), CommitError> { |
| 1439 | let bytes = serde_json::to_vec(&batch::Edits { | |
| 1440 | edits: edits | |
| 1441 | .iter() | |
| 1442 | .map(|edit| (edit.author.clone(), edit.edit.clone())) | |
| 1443 | .collect(), | |
| 1444 | revisions: revisions.clone(), | |
| 1445 | }) | |
| 1455 | let bytes = batch::encode( | |
| 1456 | &batch::Edits { | |
| 1457 | edits: edits | |
| 1458 | .iter() | |
| 1459 | .map(|edit| (edit.author.clone(), edit.edit.clone())) | |
| 1460 | .collect(), | |
| 1461 | revisions: revisions.clone(), | |
| 1462 | }, | |
| 1463 | LIMIT, | |
| 1464 | ) | |
| 1446 | 1465 | .map_err(|error| CommitError { |
| 1447 | 1466 | state: CommitState::NotCommitted, |
| 1448 | error: io::Error::other(error), | |
| 1467 | error, | |
| 1449 | 1468 | })?; |
| 1450 | 1469 | let mut request = Request { |
| 1451 | 1470 | path: path.to_owned(), |
| ... | ... | @@ -1877,6 +1896,9 @@ impl Storage for Hosted { |
| 1877 | 1896 | } |
| 1878 | 1897 | |
| 1879 | 1898 | fn create(&self, path: &str, bytes: &[u8]) -> Result<()> { |
| 1899 | if bytes.len() > LIMIT { | |
| 1900 | return Err(io::Error::from(io::ErrorKind::FileTooLarge).into()); | |
| 1901 | } | |
| 1880 | 1902 | let mut request = Request { |
| 1881 | 1903 | path: path.to_owned(), |
| 1882 | 1904 | ..Request::default() |
| ... | ... | @@ -1954,6 +1976,60 @@ impl Storage for Hosted { |
| 1954 | 1976 | mod tests { |
| 1955 | 1977 | use super::*; |
| 1956 | 1978 | |
| 1979 | #[test] | |
| 1980 | fn refused_uploads_release_their_bytes_and_share_one_guest_budget() { | |
| 1981 | let directory = tempfile::tempdir().unwrap(); | |
| 1982 | std::fs::create_dir(directory.path().join("notebook")).unwrap(); | |
| 1983 | let served = Arc::new(Served { | |
| 1984 | storage: crate::session::Notebook::open( | |
| 1985 | directory.path().join("notebook"), | |
| 1986 | directory.path().join("cache"), | |
| 1987 | ) | |
| 1988 | .unwrap() | |
| 1989 | .into_storage(), | |
| 1990 | images: Mutex::default(), | |
| 1991 | snapshots: Mutex::default(), | |
| 1992 | puts: Mutex::default(), | |
| 1993 | guests: Mutex::default(), | |
| 1994 | writers: Mutex::default(), | |
| 1995 | host: Mutex::default(), | |
| 1996 | room: Mutex::default(), | |
| 1997 | }); | |
| 1998 | let peer = [1; 16]; | |
| 1999 | let put = |handle, offset, bytes| { | |
| 2000 | served.answer( | |
| 2001 | &peer, | |
| 2002 | kind::PUT, | |
| 2003 | Request { | |
| 2004 | handle: Some(handle), | |
| 2005 | offset: Some(offset), | |
| 2006 | bytes: Some(bytes), | |
| 2007 | ..Request::default() | |
| 2008 | }, | |
| 2009 | ) | |
| 2010 | }; | |
| 2011 | put(1, 0, vec![1; 3]).unwrap(); | |
| 2012 | put(2, 0, vec![2; 2]).unwrap(); | |
| 2013 | assert!(put(1, 2, vec![3]).is_err()); | |
| 2014 | assert_eq!( | |
| 2015 | served.puts.lock().unwrap().get(&(peer, 2)).unwrap(), | |
| 2016 | &[2; 2] | |
| 2017 | ); | |
| 2018 | assert!(!served.puts.lock().unwrap().contains_key(&(peer, 1))); | |
| 2019 | put(1, 0, vec![4]).unwrap(); | |
| 2020 | assert!(put(1, 1, Vec::new()).is_err()); | |
| 2021 | served | |
| 2022 | .puts | |
| 2023 | .lock() | |
| 2024 | .unwrap() | |
| 2025 | .insert((peer, 3), vec![0; LIMIT - 2]); | |
| 2026 | assert!(put(2, 2, vec![5]).is_err()); | |
| 2027 | assert!(!served.puts.lock().unwrap().contains_key(&(peer, 2))); | |
| 2028 | assert!(put(3, (LIMIT - 2) as u64, vec![6; 3]).is_err()); | |
| 2029 | assert!(served.puts.lock().unwrap().is_empty()); | |
| 2030 | put(4, 0, vec![7]).unwrap(); | |
| 2031 | } | |
| 2032 | ||
| 1957 | 2033 | /// A commit's writes, found by comparing images, rebuild the image after it from the one |
| 1958 | 2034 | /// before, and a change touching most of the file is left to be read whole. |
| 1959 | 2035 | #[test] |
crates/notebook/src/live/share/batch.rs+90| ... | ... | @@ -10,6 +10,37 @@ pub(super) struct Edits { |
| 10 | 10 | pub revisions: BTreeMap<ExGuid, ExGuid>, |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | pub(super) fn encode(edits: &Edits, limit: usize) -> io::Result<Vec<u8>> { | |
| 14 | struct Buffer { | |
| 15 | bytes: Vec<u8>, | |
| 16 | limit: usize, | |
| 17 | } | |
| 18 | impl io::Write for Buffer { | |
| 19 | fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { | |
| 20 | if bytes.len() > self.limit.saturating_sub(self.bytes.len()) { | |
| 21 | return Err(io::ErrorKind::FileTooLarge.into()); | |
| 22 | } | |
| 23 | self.bytes.extend_from_slice(bytes); | |
| 24 | Ok(bytes.len()) | |
| 25 | } | |
| 26 | ||
| 27 | fn flush(&mut self) -> io::Result<()> { | |
| 28 | Ok(()) | |
| 29 | } | |
| 30 | } | |
| 31 | let mut buffer = Buffer { | |
| 32 | bytes: Vec::new(), | |
| 33 | limit, | |
| 34 | }; | |
| 35 | serde_json::to_writer(&mut buffer, edits).map_err(|error| { | |
| 36 | io::Error::new( | |
| 37 | error.io_error_kind().unwrap_or(io::ErrorKind::InvalidData), | |
| 38 | error, | |
| 39 | ) | |
| 40 | })?; | |
| 41 | Ok(buffer.bytes) | |
| 42 | } | |
| 43 | ||
| 13 | 44 | pub(super) struct Waiting { |
| 14 | 45 | peer: [u8; 16], |
| 15 | 46 | request: Request, |
| ... | ... | @@ -262,3 +293,62 @@ impl Served { |
| 262 | 293 | Ok(transaction) |
| 263 | 294 | } |
| 264 | 295 | } |
| 296 | ||
| 297 | #[cfg(test)] | |
| 298 | mod tests { | |
| 299 | use super::*; | |
| 300 | use onestore::{ | |
| 301 | op::{Op, PageOp}, | |
| 302 | page::{Attachment, PageObject}, | |
| 303 | }; | |
| 304 | ||
| 305 | #[test] | |
| 306 | fn attachment_encoding_stops_at_the_upload_budget() { | |
| 307 | let id = ExGuid { | |
| 308 | guid: [1; 16], | |
| 309 | n: 1, | |
| 310 | }; | |
| 311 | let bytes: Arc<[u8]> = (0..4096) | |
| 312 | .map(|at| (at % 251) as u8) | |
| 313 | .collect::<Vec<_>>() | |
| 314 | .into(); | |
| 315 | let edits = Edits { | |
| 316 | edits: vec![( | |
| 317 | "Ada".into(), | |
| 318 | Edit { | |
| 319 | at: 0, | |
| 320 | ops: vec![Op::Page { | |
| 321 | space: id, | |
| 322 | op: PageOp::Add { | |
| 323 | object: PageObject::Attachment(Attachment { | |
| 324 | id, | |
| 325 | filename: "payload.bin".into(), | |
| 326 | source_path: None, | |
| 327 | size: None, | |
| 328 | layout: Default::default(), | |
| 329 | bytes: Some(Arc::clone(&bytes)), | |
| 330 | preview: None, | |
| 331 | recording: None, | |
| 332 | tags: Vec::new(), | |
| 333 | }), | |
| 334 | before: None, | |
| 335 | }, | |
| 336 | }], | |
| 337 | }, | |
| 338 | )], | |
| 339 | revisions: BTreeMap::new(), | |
| 340 | }; | |
| 341 | let encoded = serde_json::to_vec(&edits).unwrap(); | |
| 342 | assert_eq!(encode(&edits, encoded.len()).unwrap(), encoded); | |
| 343 | let decoded: Edits = serde_json::from_slice(&encoded).unwrap(); | |
| 344 | assert_eq!(decoded.edits, edits.edits); | |
| 345 | assert_eq!( | |
| 346 | encode(&edits, encoded.len() - 1).unwrap_err().kind(), | |
| 347 | io::ErrorKind::FileTooLarge | |
| 348 | ); | |
| 349 | assert_eq!( | |
| 350 | encode(&edits, 128).unwrap_err().kind(), | |
| 351 | io::ErrorKind::FileTooLarge | |
| 352 | ); | |
| 353 | } | |
| 354 | } |
crates/notebook/src/session.rs+2-2| ... | ... | @@ -326,7 +326,7 @@ impl Storage for Share { |
| 326 | 326 | fn read(&self, path: &str) -> Result<Vec<u8>> { |
| 327 | 327 | Ok(self |
| 328 | 328 | .client |
| 329 | .read_storage(&self.path(path), 256 * 1024 * 1024)?) | |
| 329 | .read_storage(&self.path(path), crate::MAX_FILE_BYTES)?) | |
| 330 | 330 | } |
| 331 | 331 | |
| 332 | 332 | fn read_file(&self, path: &str, limit: usize) -> Result<Vec<u8>> { |
| ... | ... | @@ -403,7 +403,7 @@ impl Storage for Share { |
| 403 | 403 | |
| 404 | 404 | pub(crate) const LIMITS: discover::Limits = discover::Limits { |
| 405 | 405 | entries: 100_000, |
| 406 | bytes_per_file: 256 * 1024 * 1024, | |
| 406 | bytes_per_file: crate::MAX_FILE_BYTES, | |
| 407 | 407 | depth: 64, |
| 408 | 408 | }; |
| 409 | 409 |
crates/onestore/src/page/mod.rs+11-5| ... | ... | @@ -22,17 +22,23 @@ pub use math::Math; |
| 22 | 22 | /// Picture and attachment payloads travel with the model (queued intents replay them), |
| 23 | 23 | /// as base64 text. |
| 24 | 24 | mod payload { |
| 25 | use base64::Engine; | |
| 25 | use base64::{Engine, display::Base64Display, engine::general_purpose::STANDARD}; | |
| 26 | 26 | use std::sync::Arc; |
| 27 | 27 | |
| 28 | struct Encoded<'a>(&'a [u8]); | |
| 29 | ||
| 30 | impl serde::Serialize for Encoded<'_> { | |
| 31 | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 32 | serializer.collect_str(&Base64Display::new(self.0, &STANDARD)) | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 28 | 36 | pub fn serialize<S: serde::Serializer>( |
| 29 | 37 | bytes: &Option<Arc<[u8]>>, |
| 30 | 38 | serializer: S, |
| 31 | 39 | ) -> Result<S::Ok, S::Error> { |
| 32 | 40 | match bytes { |
| 33 | Some(bytes) => { | |
| 34 | serializer.serialize_some(&base64::engine::general_purpose::STANDARD.encode(bytes)) | |
| 35 | } | |
| 41 | Some(bytes) => serializer.serialize_some(&Encoded(bytes)), | |
| 36 | 42 | None => serializer.serialize_none(), |
| 37 | 43 | } |
| 38 | 44 | } |
| ... | ... | @@ -42,7 +48,7 @@ mod payload { |
| 42 | 48 | ) -> Result<Option<Arc<[u8]>>, D::Error> { |
| 43 | 49 | let text: Option<String> = serde::Deserialize::deserialize(deserializer)?; |
| 44 | 50 | text.map(|text| { |
| 45 | base64::engine::general_purpose::STANDARD | |
| 51 | STANDARD | |
| 46 | 52 | .decode(text) |
| 47 | 53 | .map(Arc::from) |
| 48 | 54 | .map_err(serde::de::Error::custom) |
crates/snowbound/src/attachment.rs+76-34| ... | ... | @@ -2,44 +2,76 @@ |
| 2 | 2 | |
| 3 | 3 | use crate::{State, platform}; |
| 4 | 4 | use onestore::page::Attachment; |
| 5 | use std::{error::Error, path::Path}; | |
| 5 | use std::{ | |
| 6 | error::Error, | |
| 7 | path::{Path, PathBuf}, | |
| 8 | }; | |
| 6 | 9 | |
| 7 | 10 | impl State { |
| 8 | /// Attach File, or a file dropped at `at`, a window point: a copy of the file's bytes | |
| 9 | /// at the caret or at `at`, with the icon the system shows for it; audio and video are | |
| 10 | /// recordings, as OneNote attaches them. | |
| 11 | pub(crate) fn attach( | |
| 12 | &mut self, | |
| 13 | path: &Path, | |
| 14 | at: Option<[f32; 2]>, | |
| 15 | ) -> Result<(), Box<dyn Error>> { | |
| 11 | /// Inserts a file at the caret or window point `at`, as a picture when requested. | |
| 12 | pub(crate) fn import_file(&mut self, path: &Path, at: Option<[f32; 2]>, picture: bool) { | |
| 16 | 13 | if self.session.as_ref().is_some_and(crate::Session::read_only) { |
| 17 | return Ok(()); | |
| 14 | return; | |
| 18 | 15 | } |
| 19 | let (Some(name), Ok(bytes)) = ( | |
| 20 | path.file_name().and_then(|name| name.to_str()), | |
| 21 | notebook::fs::read(path), | |
| 22 | ) else { | |
| 23 | platform::alert("Couldn't attach the file", "Choose a file you can open."); | |
| 24 | return Ok(()); | |
| 25 | }; | |
| 26 | let file = Attachment { | |
| 27 | id: onestore::page::text::new_id()?, | |
| 28 | filename: name.to_owned(), | |
| 29 | source_path: path.to_str().map(str::to_owned), | |
| 30 | size: Some(canvas::gpu::page::ICON_SIZE), | |
| 31 | layout: Default::default(), | |
| 32 | preview: platform::file_icon(path).map(Into::into), | |
| 33 | recording: canvas::recording::attached(name, &bytes), | |
| 34 | bytes: Some(bytes.into()), | |
| 35 | tags: Vec::new(), | |
| 36 | }; | |
| 37 | let response = match at { | |
| 38 | Some(point) => self.view.drop_attachment(self.page_point(point), file)?, | |
| 39 | None => self.view.insert_attachment(file)?, | |
| 40 | }; | |
| 41 | self.respond(response); | |
| 42 | Ok(()) | |
| 16 | let loading = self.loading; | |
| 17 | let reply = self.reply( | |
| 18 | move |state, (path, read): (PathBuf, std::io::Result<Vec<u8>>)| { | |
| 19 | let bytes = match read { | |
| 20 | Ok(bytes) => bytes, | |
| 21 | Err(error) if error.kind() == std::io::ErrorKind::FileTooLarge => { | |
| 22 | too_large(); | |
| 23 | return Ok(()); | |
| 24 | } | |
| 25 | Err(_) => { | |
| 26 | platform::alert("Couldn't insert the file", "Choose a file you can open."); | |
| 27 | return Ok(()); | |
| 28 | } | |
| 29 | }; | |
| 30 | if state.loading != loading { | |
| 31 | platform::alert( | |
| 32 | "File wasn't inserted", | |
| 33 | "Return to the page and insert the file again.", | |
| 34 | ); | |
| 35 | return Ok(()); | |
| 36 | } | |
| 37 | if state | |
| 38 | .session | |
| 39 | .as_ref() | |
| 40 | .is_some_and(crate::Session::read_only) | |
| 41 | { | |
| 42 | return Ok(()); | |
| 43 | } | |
| 44 | if picture && draw::RasterImage::measure(&bytes).is_ok() { | |
| 45 | return state.insert_picture(bytes, at); | |
| 46 | } | |
| 47 | let name = path | |
| 48 | .file_name() | |
| 49 | .and_then(|name| name.to_str()) | |
| 50 | .ok_or("No file name")?; | |
| 51 | let file = Attachment { | |
| 52 | id: onestore::page::text::new_id()?, | |
| 53 | filename: name.to_owned(), | |
| 54 | source_path: path.to_str().map(str::to_owned), | |
| 55 | size: Some(canvas::gpu::page::ICON_SIZE), | |
| 56 | layout: Default::default(), | |
| 57 | preview: platform::file_icon(&path).map(Into::into), | |
| 58 | recording: canvas::recording::attached(name, &bytes), | |
| 59 | bytes: Some(bytes.into()), | |
| 60 | tags: Vec::new(), | |
| 61 | }; | |
| 62 | let response = match at { | |
| 63 | Some(point) => state.view.drop_attachment(state.page_point(point), file)?, | |
| 64 | None => state.view.insert_attachment(file)?, | |
| 65 | }; | |
| 66 | state.respond(response); | |
| 67 | Ok(()) | |
| 68 | }, | |
| 69 | ); | |
| 70 | let path = path.to_owned(); | |
| 71 | crate::spawn(move || { | |
| 72 | let bytes = notebook::fs::read_limited(&path, notebook::MAX_FILE_BYTES); | |
| 73 | reply.send((path, bytes)); | |
| 74 | }); | |
| 43 | 75 | } |
| 44 | 76 | |
| 45 | 77 | /// Open: a recording plays; another file opens as a copy, in a folder of its own, with |
| ... | ... | @@ -89,6 +121,16 @@ impl State { |
| 89 | 121 | } |
| 90 | 122 | } |
| 91 | 123 | |
| 124 | pub(crate) fn too_large() { | |
| 125 | platform::alert( | |
| 126 | "File is too large", | |
| 127 | &format!( | |
| 128 | "Choose files totaling at most {} MiB.", | |
| 129 | notebook::MAX_FILE_BYTES >> 20 | |
| 130 | ), | |
| 131 | ); | |
| 132 | } | |
| 133 | ||
| 92 | 134 | /// A file another program stored beside the section rather than in it. |
| 93 | 135 | fn unstored() { |
| 94 | 136 | platform::alert( |
crates/snowbound/src/commands.rs+16-2| ... | ... | @@ -1247,7 +1247,7 @@ impl State { |
| 1247 | 1247 | Id::Undo | Id::Redo => enabled(writable && !field && self.can_step(id == Id::Redo)), |
| 1248 | 1248 | Id::Cut => enabled(writable && selected), |
| 1249 | 1249 | Id::Copy => enabled(selected), |
| 1250 | Id::Paste => enabled(text && !field), | |
| 1250 | Id::Paste => enabled(field || text), | |
| 1251 | 1251 | Id::SelectAll => enabled(field || page), |
| 1252 | 1252 | Id::Back | Id::Forward => { |
| 1253 | 1253 | enabled(!modal && self.can_travel()[usize::from(id == Id::Forward)]) |
| ... | ... | @@ -1573,6 +1573,17 @@ impl State { |
| 1573 | 1573 | self.respond(response); |
| 1574 | 1574 | return Ok(()); |
| 1575 | 1575 | } |
| 1576 | Id::Paste if field.is_some() => { | |
| 1577 | let text = match &mut self.clipboard { | |
| 1578 | crate::Clipboard::System(clipboard) => clipboard.get_text().ok(), | |
| 1579 | crate::Clipboard::Memory(text, _) => Some(text.clone()), | |
| 1580 | }; | |
| 1581 | if let Some(text) = text { | |
| 1582 | self.ui | |
| 1583 | .event(ui::Event::Ime(winit::event::Ime::Commit(text))); | |
| 1584 | } | |
| 1585 | return Ok(()); | |
| 1586 | } | |
| 1576 | 1587 | Id::Paste => Work::Page(Request::Paste), |
| 1577 | 1588 | Id::FormatPainter => { |
| 1578 | 1589 | self.painter = match self.painter { |
| ... | ... | @@ -1659,7 +1670,10 @@ impl State { |
| 1659 | 1670 | return Ok(()); |
| 1660 | 1671 | } |
| 1661 | 1672 | Id::Attachment => { |
| 1662 | let reply = self.reply(|state, path: std::path::PathBuf| state.attach(&path, None)); | |
| 1673 | let reply = self.reply(|state, path: std::path::PathBuf| { | |
| 1674 | state.import_file(&path, None, false); | |
| 1675 | Ok(()) | |
| 1676 | }); | |
| 1663 | 1677 | platform::pick_file("Attach File", &[], reply); |
| 1664 | 1678 | return Ok(()); |
| 1665 | 1679 | } |
crates/snowbound/src/main.rs+1-1| ... | ... | @@ -6630,7 +6630,7 @@ impl ApplicationHandler<UserEvent> for App { |
| 6630 | 6630 | WindowEvent::Ime(ime) => state.input(ui::Event::Ime(ime)), |
| 6631 | 6631 | WindowEvent::DroppedFile(path) => { |
| 6632 | 6632 | let at = platform::drop_point(&state.window); |
| 6633 | state.place_file(&path, at)?; | |
| 6633 | state.import_file(&path, at, true); | |
| 6634 | 6634 | state.window.request_redraw(); |
| 6635 | 6635 | } |
| 6636 | 6636 | WindowEvent::PinchGesture { delta, .. } => state.pinch(1.0 + delta as f32)?, |
crates/snowbound/src/paste.rs+2-20| ... | ... | @@ -3,10 +3,7 @@ |
| 3 | 3 | |
| 4 | 4 | use crate::{State, platform}; |
| 5 | 5 | use canvas::editor::{Clip, Piece}; |
| 6 | use std::{ | |
| 7 | error::Error, | |
| 8 | path::{Path, PathBuf}, | |
| 9 | }; | |
| 6 | use std::{error::Error, path::PathBuf}; | |
| 10 | 7 | |
| 11 | 8 | /// Copy offers text and HTML, or a picture as PNG, beside Snowbound's lossless clip. |
| 12 | 9 | pub(crate) struct Copied { |
| ... | ... | @@ -123,7 +120,7 @@ impl State { |
| 123 | 120 | match self.clipboard.pasted() { |
| 124 | 121 | Some(Pasted::Files(paths)) => { |
| 125 | 122 | for path in paths { |
| 126 | self.place_file(&path, None)?; | |
| 123 | self.import_file(&path, None, true); | |
| 127 | 124 | } |
| 128 | 125 | } |
| 129 | 126 | Some(Pasted::Clip(clip)) => { |
| ... | ... | @@ -180,21 +177,6 @@ impl State { |
| 180 | 177 | Ok(()) |
| 181 | 178 | } |
| 182 | 179 | |
| 183 | /// A pasted file, or one dropped at `at`, a window point: a picture file as its picture, | |
| 184 | /// as OneNote 2010 pastes one (lab, 2026-09-30), and any other file attached. | |
| 185 | pub(crate) fn place_file( | |
| 186 | &mut self, | |
| 187 | path: &Path, | |
| 188 | at: Option<[f32; 2]>, | |
| 189 | ) -> Result<(), Box<dyn Error>> { | |
| 190 | match notebook::fs::read(path) { | |
| 191 | Ok(bytes) if draw::RasterImage::measure(&bytes).is_ok() => { | |
| 192 | self.insert_picture(bytes, at) | |
| 193 | } | |
| 194 | _ => self.attach(path, at), | |
| 195 | } | |
| 196 | } | |
| 197 | ||
| 198 | 180 | /// Puts an encoded picture at the caret, or where it is dropped at `at`, a window point, |
| 199 | 181 | /// at the size its resolution gives it, as OneNote 2010 inserts and pastes one. |
| 200 | 182 | pub(crate) fn insert_picture( |
crates/snowbound/src/web.rs+12-1| ... | ... | @@ -1376,6 +1376,16 @@ pub fn appearance_changed(dark: bool) { |
| 1376 | 1376 | send(UserEvent::Appearance); |
| 1377 | 1377 | } |
| 1378 | 1378 | |
| 1379 | /// Rejects a selection before JavaScript reads its files. | |
| 1380 | #[wasm_bindgen] | |
| 1381 | pub fn accepts_files(size: f64) -> bool { | |
| 1382 | let accepted = size.is_finite() && size >= 0.0 && size <= notebook::MAX_FILE_BYTES as f64; | |
| 1383 | if !accepted { | |
| 1384 | crate::attachment::too_large(); | |
| 1385 | } | |
| 1386 | accepted | |
| 1387 | } | |
| 1388 | ||
| 1379 | 1389 | /// Files chosen or dropped, as `[name, bytes]` pairs, for `purpose`: `open` opens them as |
| 1380 | 1390 | /// notebooks, sections or packages, `place` puts them at the caret. |
| 1381 | 1391 | #[wasm_bindgen] |
| ... | ... | @@ -1388,7 +1398,8 @@ pub fn files(purpose: &str, files: js_sys::Array) { |
| 1388 | 1398 | _ => { |
| 1389 | 1399 | for path in keep(files, CHOSEN) { |
| 1390 | 1400 | send(UserEvent::Then(Box::new(move |state| { |
| 1391 | state.place_file(&path, None) | |
| 1401 | state.import_file(&path, None, true); | |
| 1402 | Ok(()) | |
| 1392 | 1403 | }))); |
| 1393 | 1404 | } |
| 1394 | 1405 | } |
crates/snowbound/tests/replay.rs+42| ... | ... | @@ -606,6 +606,48 @@ fn the_find_bar_keeps_room_for_the_query() { |
| 606 | 606 | ); |
| 607 | 607 | } |
| 608 | 608 | |
| 609 | #[test] | |
| 610 | fn paste_replaces_a_focused_input_without_editing_the_page() { | |
| 611 | let scratch = Scratch::new("paste-field"); | |
| 612 | let notebook = | |
| 613 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 614 | let steps = [ | |
| 615 | "move 900 600", | |
| 616 | "press", | |
| 617 | "release", | |
| 618 | "type 雪 paste ☃", | |
| 619 | "settle", | |
| 620 | "modifiers command shift", | |
| 621 | "key Left", | |
| 622 | "modifiers command", | |
| 623 | "key c", | |
| 624 | "modifiers", | |
| 625 | "settle", | |
| 626 | "modifiers command", | |
| 627 | "key f", | |
| 628 | "modifiers", | |
| 629 | "settle", | |
| 630 | "type replace me", | |
| 631 | "settle", | |
| 632 | "modifiers command", | |
| 633 | "key a", | |
| 634 | "modifiers", | |
| 635 | "settle", | |
| 636 | "modifiers command", | |
| 637 | "key v", | |
| 638 | "modifiers", | |
| 639 | "accessibility pasted", | |
| 640 | ]; | |
| 641 | let [pasted] = replay(&scratch, Some(&notebook), &steps) | |
| 642 | .try_into() | |
| 643 | .unwrap(); | |
| 644 | assert!( | |
| 645 | pasted.contains(r#"SearchInput "Find on Page" = "雪 paste ☃" [focused]"#), | |
| 646 | "{pasted}" | |
| 647 | ); | |
| 648 | assert_eq!(pasted.matches("雪 paste ☃").count(), 2, "{pasted}"); | |
| 649 | } | |
| 650 | ||
| 609 | 651 | /// A launch shows the page each notebook was left on, as the settings recall them: the |
| 610 | 652 | /// notebook shown at once, and another as its section is opened. |
| 611 | 653 | #[test] |
crates/snowbound/web/glue.js+6-4| ... | ... | @@ -611,10 +611,12 @@ function modifiers(event) { |
| 611 | 611 | ); |
| 612 | 612 | } |
| 613 | 613 | |
| 614 | function read(list) { | |
| 615 | return Promise.all( | |
| 616 | [...list].map(async (file) => [file.name, new Uint8Array(await file.arrayBuffer())]), | |
| 617 | ); | |
| 614 | async function read(list) { | |
| 615 | const files = [...list]; | |
| 616 | if (!wasm.accepts_files(files.reduce((size, file) => size + file.size, 0))) return []; | |
| 617 | const read = []; | |
| 618 | for (const file of files) read.push([file.name, new Uint8Array(await file.arrayBuffer())]); | |
| 619 | return read; | |
| 618 | 620 | } |
| 619 | 621 | |
| 620 | 622 | const NOTEBOOK_FILES = /\.(one|onetoc2|onepkg)$/i; |