1//! Expresses the editor's actions as page-model edits saved through the replica, so tests
2//! can state intents in terms of paragraphs and ranges.
3#![allow(dead_code)]
4
5use notebook::Replica;
6use onestore::{
7 ExGuid, RevisionIndex, Store,
8 document::{Document, Format},
9 page::{
10 Outline, Page, PageObject, PageParagraph, Paragraph, ParagraphContent, TextObject,
11 text::{Edit, new_id},
12 },
13};
14use std::ops::Range;
15
16pub const AUTHOR: &str = "Offline author";
17
18pub fn page_of(bytes: &[u8], space: ExGuid) -> Page {
19 let store = Store::parse(bytes).unwrap();
20 let index = RevisionIndex::parse(&store).unwrap();
21 let document = Document::parse(&index).unwrap();
22 Page::from_space(&document, space).unwrap()
23}
24
25/// The page space and page model containing the text object.
26pub fn locate(bytes: &[u8], text: ExGuid) -> (ExGuid, Page) {
27 let store = Store::parse(bytes).unwrap();
28 let index = RevisionIndex::parse(&store).unwrap();
29 let document = Document::parse(&index).unwrap();
30 document
31 .pages()
32 .unwrap()
33 .into_iter()
34 .find_map(|(space, _)| {
35 let page = Page::from_space(&document, space).ok()?;
36 paragraph_with(&page, text)
37 .is_some()
38 .then_some((space, page))
39 })
40 .expect("text object belongs to an active page")
41}
42
43pub fn outlines_mut(page: &mut Page) -> Vec<&mut Outline> {
44 let mut out = Vec::new();
45 for object in &mut page.objects {
46 match object {
47 PageObject::Outline(outline) => out.push(outline),
48 PageObject::Title(title) => out.extend(title.outlines.iter_mut()),
49 _ => {}
50 }
51 }
52 out
53}
54
55pub fn paragraph_with(page: &Page, text: ExGuid) -> Option<&PageParagraph> {
56 page.objects.iter().find_map(|object| {
57 let outlines: Vec<&Outline> = match object {
58 PageObject::Outline(outline) => vec![outline],
59 PageObject::Title(title) => title.outlines.iter().collect(),
60 _ => Vec::new(),
61 };
62 outlines
63 .into_iter()
64 .flat_map(|o| o.paragraphs.iter())
65 .find(|p| p.text().is_some_and(|t| t.id == text))
66 })
67}
68
69fn text_mut(page: &mut Page, text: ExGuid) -> &mut TextObject {
70 outlines_mut(page)
71 .into_iter()
72 .flat_map(|o| o.paragraphs.iter_mut())
73 .find(|p| p.text().is_some_and(|t| t.id == text))
74 .and_then(|p| p.text_mut())
75 .expect("text object is on the page")
76}
77
78/// Replaces a UTF-16 range of a text object in the model; the replacement takes the format
79/// of the first replaced character, or of the character before an insertion point.
80pub fn replace_text(page: &mut Page, text: ExGuid, range: Range<u32>, replacement: &str) {
81 let target = text_mut(page, text);
82 let format = if range.is_empty() {
83 target.text.format_at(range.start).unwrap().clone()
84 } else {
85 target.text.slice(range.clone()).unwrap().spans()[0]
86 .format
87 .clone()
88 };
89 target
90 .text
91 .apply(Edit {
92 range,
93 replacement: Paragraph::new(replacement.into(), format),
94 })
95 .unwrap();
96}
97
98/// Rewrites the formats over a UTF-16 range of a text object.
99pub fn restyle(page: &mut Page, text: ExGuid, range: Range<u32>, change: impl Fn(&mut Format)) {
100 let target = text_mut(page, text);
101 let slice = target.text.slice(range.clone()).unwrap();
102 let mut runs = Vec::new();
103 let mut from = 0;
104 for span in slice.spans() {
105 let mut format = span.format.clone();
106 change(&mut format);
107 runs.push((slice.text()[from..span.end].to_owned(), format));
108 from = span.end;
109 }
110 target
111 .text
112 .apply(Edit {
113 range,
114 replacement: Paragraph::from_runs(runs),
115 })
116 .unwrap();
117}
118
119/// A plain new paragraph carrying fresh identities and the template's resolved format.
120pub fn fresh_paragraph(template: &PageParagraph, text: &str) -> PageParagraph {
121 let mut paragraph = template.clone();
122 paragraph.id = new_id().unwrap();
123 paragraph.parent = None;
124 paragraph.level = 1;
125 paragraph.lists.clear();
126 paragraph.tags.clear();
127 paragraph.style = None;
128 paragraph.collapsed = false;
129 let format = template.text().unwrap().text.format_at(0).unwrap().clone();
130 paragraph.content = ParagraphContent::Text(TextObject {
131 id: new_id().unwrap(),
132 date_field: None,
133 text: Paragraph::new(text.into(), format),
134 tags: Vec::new(),
135 });
136 paragraph
137}
138
139/// Inserts a plain paragraph after `after` (or first) in the outline containing it, at the
140/// same level and parent, returning the new paragraph and text identities.
141pub fn insert_after(page: &mut Page, after: ExGuid, text: &str) -> (ExGuid, ExGuid) {
142 for outline in outlines_mut(page) {
143 if let Some(at) = outline
144 .paragraphs
145 .iter()
146 .position(|p| p.text().is_some_and(|t| t.id == after))
147 {
148 let mut fresh = fresh_paragraph(&outline.paragraphs[at], text);
149 fresh.parent = outline.paragraphs[at].parent;
150 fresh.level = outline.paragraphs[at].level;
151 let ids = (fresh.id, fresh.text().unwrap().id);
152 // Skip the anchor's descendants so the new paragraph follows its whole subtree.
153 let mut end = at + 1;
154 while end < outline.paragraphs.len()
155 && outline.paragraphs[end].level > outline.paragraphs[at].level
156 {
157 end += 1;
158 }
159 outline.paragraphs.insert(end, fresh);
160 return ids;
161 }
162 }
163 panic!("anchor text is on the page");
164}
165
166/// Adds a body outline with one plain paragraph at the given point coordinates.
167pub fn insert_outline(page: &mut Page, x: f32, y: f32, text: &str) -> (ExGuid, ExGuid, ExGuid) {
168 let template = page
169 .objects
170 .iter()
171 .find_map(|object| match object {
172 PageObject::Outline(outline) => outline
173 .paragraphs
174 .iter()
175 .find(|p| p.text().is_some())
176 .cloned(),
177 _ => None,
178 })
179 .expect("a text paragraph to copy formatting from");
180 let paragraph = fresh_paragraph(&template, text);
181 let ids = (
182 new_id().unwrap(),
183 paragraph.id,
184 paragraph.text().unwrap().id,
185 );
186 let outline = Outline {
187 id: ids.0,
188 title: false,
189 min_width: None,
190 layout: onestore::document::Layout {
191 x: Some(x),
192 y: Some(y),
193 ..Default::default()
194 },
195 indents: Vec::new(),
196 paragraphs: vec![paragraph],
197 unsupported: Vec::new(),
198 };
199 let at = page
200 .objects
201 .iter()
202 .position(|o| matches!(o, PageObject::Title(_)))
203 .unwrap_or(page.objects.len());
204 page.objects.insert(at, PageObject::Outline(outline));
205 ids
206}
207
208/// Removes a paragraph and its descendants.
209pub fn delete_paragraph(page: &mut Page, text: ExGuid) {
210 for outline in outlines_mut(page) {
211 if let Some(at) = outline
212 .paragraphs
213 .iter()
214 .position(|p| p.text().is_some_and(|t| t.id == text))
215 {
216 let level = outline.paragraphs[at].level;
217 let mut end = at + 1;
218 while end < outline.paragraphs.len() && outline.paragraphs[end].level > level {
219 end += 1;
220 }
221 outline.paragraphs.drain(at..end);
222 return;
223 }
224 }
225 panic!("text is on the page");
226}
227
228/// FILETIME now.
229pub fn now() -> u64 {
230 let unix = std::time::SystemTime::now()
231 .duration_since(std::time::UNIX_EPOCH)
232 .unwrap();
233 (unix.as_secs() + 11_644_473_600) * 10_000_000
234}
235
236/// Queues the ops taking the page in `space` to `after`, or nothing when they are equal.
237pub fn save_as(
238 cache: &Replica,
239 space: ExGuid,
240 after: &Page,
241 author: &str,
242) -> Result<Option<u64>, notebook::Error> {
243 save_from(cache, space, &cache.page(space)?, after, author)
244}
245
246/// Queues the ops taking `before`, the page in `space` as it was read, to `after`.
247pub fn save_from(
248 cache: &Replica,
249 space: ExGuid,
250 before: &Page,
251 after: &Page,
252 author: &str,
253) -> Result<Option<u64>, notebook::Error> {
254 let ops = onestore::op::lower_page(before, after)?;
255 if ops.is_empty() {
256 return Ok(None);
257 }
258 cache
259 .apply(
260 author,
261 onestore::op::Edit {
262 at: now(),
263 ops: ops
264 .into_iter()
265 .map(|op| onestore::op::Op::Page { space, op })
266 .collect(),
267 },
268 )
269 .map(Some)
270}
271
272/// Queues `edit` of the page in `space`.
273pub fn save_page(
274 cache: &Replica,
275 space: ExGuid,
276 edit: impl FnOnce(&mut Page),
277) -> Result<Option<u64>, notebook::Error> {
278 let before = cache.page(space)?;
279 let mut after = before.clone();
280 edit(&mut after);
281 save_from(cache, space, &before, &after, AUTHOR)
282}
283
284/// Queues `edit` of the page containing `text`.
285pub fn save(
286 cache: &Replica,
287 text: ExGuid,
288 edit: impl FnOnce(&mut Page),
289) -> Result<Option<u64>, notebook::Error> {
290 let mut space = None;
291 for (listed, ..) in cache.pages()? {
292 if paragraph_with(&cache.page(listed)?, text).is_some() {
293 space = Some(listed);
294 break;
295 }
296 }
297 save_page(
298 cache,
299 space.expect("text object belongs to an active page"),
300 edit,
301 )
302}