1//! Drives a section the way the application does: open it through a session, apply
2//! 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
7use notebook::{
8 EditStatus,
9 session::{Event, Section},
10};
11use onestore::{
12 ExGuid,
13 op::PageOp,
14 page::{Outline, Page, PageObject, PageParagraph, ParagraphContent, TextObject, text::new_id},
15};
16use std::{
17 sync::mpsc,
18 time::{Duration, Instant},
19};
20
21fn body_text(page: &Page) -> Option<ExGuid> {
22 page.objects.iter().find_map(|object| match object {
23 PageObject::Outline(outline) => outline
24 .paragraphs
25 .iter()
26 .find_map(|p| p.text().map(|t| t.id)),
27 _ => None,
28 })
29}
30
31fn texts(page: &Page) -> Vec<String> {
32 let mut out = Vec::new();
33 for object in &page.objects {
34 let outlines: Vec<&Outline> = match object {
35 PageObject::Outline(outline) => vec![outline],
36 PageObject::Title(title) => title.outlines.iter().collect(),
37 _ => Vec::new(),
38 };
39 for outline in outlines {
40 for paragraph in &outline.paragraphs {
41 if let Some(text) = paragraph.text() {
42 out.push(text.text.text().to_owned());
43 }
44 }
45 }
46 }
47 out
48}
49
50/// Types `label` before the first body paragraph and adds an outline below the body.
51fn edit(page: &Page, label: &str) -> Vec<PageOp> {
52 let text = body_text(page).expect("a body paragraph");
53 let template = page
54 .objects
55 .iter()
56 .find_map(|object| match object {
57 PageObject::Outline(outline) => outline
58 .paragraphs
59 .iter()
60 .find(|p| p.text().is_some())
61 .cloned(),
62 _ => None,
63 })
64 .unwrap();
65 let mut paragraph: PageParagraph = template.clone();
66 paragraph.id = new_id().unwrap();
67 paragraph.parent = None;
68 paragraph.level = 1;
69 paragraph.lists.clear();
70 paragraph.tags.clear();
71 paragraph.style = None;
72 paragraph.collapsed = false;
73 paragraph.content = ParagraphContent::Text(TextObject {
74 id: new_id().unwrap(),
75 date_field: None,
76 text: onestore::page::Paragraph::new(
77 format!("{label} outline 🦀 é"),
78 template.text().unwrap().text.format_at(0).unwrap().clone(),
79 ),
80 tags: Vec::new(),
81 });
82 let outline = Outline {
83 id: new_id().unwrap(),
84 title: false,
85 min_width: None,
86 layout: onestore::document::Layout {
87 x: Some(72.0),
88 y: Some(520.0),
89 ..Default::default()
90 },
91 indents: Vec::new(),
92 paragraphs: vec![paragraph],
93 unsupported: Vec::new(),
94 };
95 let title = page.objects.iter().find_map(|object| match object {
96 PageObject::Title(title) => Some(title.id),
97 _ => None,
98 });
99 vec![
100 PageOp::Text {
101 text,
102 range: 0..0,
103 with: format!("{label} "),
104 },
105 PageOp::Add {
106 object: PageObject::Outline(outline),
107 before: title,
108 },
109 ]
110}
111
112fn main() -> Result<(), Box<dyn std::error::Error>> {
113 let args: Vec<String> = std::env::args().collect();
114 let [_, file, cache, label] = args.as_slice() else {
115 return Err("Usage: session_client SECTION CACHE_DIR LABEL".into());
116 };
117 let (notify, notified) = mpsc::channel();
118 let section = Section::open(file, cache, move || {
119 let _ = notify.send(());
120 })?;
121 let pending = section.pending()?;
122 println!(
123 "{}",
124 serde_json::json!({"event": "opened", "pending": pending.len(), "file": section.file()})
125 );
126 let pages = section.pages()?;
127 let mut queued = Vec::new();
128 for (space, title, _) in pages
129 .iter()
130 .filter(|(_, title, _)| title.starts_with("Move"))
131 {
132 let mut page = section.page(*space)?;
133 let ops = edit(&page, label);
134 for op in &ops {
135 onestore::op::predict(&mut page, op)?;
136 }
137 let unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?;
138 let edit = onestore::op::Edit {
139 at: (unix.as_secs() + 11_644_473_600) * 10_000_000,
140 ops: ops
141 .into_iter()
142 .map(|op| onestore::op::Op::Page { space: *space, op })
143 .collect(),
144 };
145 let id = section.replica().apply("session client", edit)?;
146 queued.push((id, *space, title.clone(), texts(&page)));
147 }
148 let deadline = Instant::now() + Duration::from_secs(120);
149 let mut receipts = Vec::new();
150 while queued
151 .iter()
152 .any(|(id, ..)| !receipts.iter().any(|(n, _)| n == id))
153 {
154 if Instant::now() > deadline {
155 return Err("Publication timed out".into());
156 }
157 let _ = notified.recv_timeout(Duration::from_millis(200));
158 for event in section.events() {
159 match event {
160 Event::Attempt {
161 status: EditStatus::Published { .. },
162 ..
163 } => {}
164 Event::Attempt { id, status } => {
165 println!(
166 "{}",
167 serde_json::json!({"event": "attempt", "id": id, "status": format!("{status:?}")})
168 );
169 }
170 Event::Unreachable(error) => {
171 println!(
172 "{}",
173 serde_json::json!({"event": "unreachable", "error": error.to_string()})
174 );
175 }
176 Event::Failed(error) => return Err(error.into()),
177 Event::Rejected { error, .. } => return Err(error.into()),
178 Event::Changed(_) => {}
179 }
180 }
181 // A batch reports its newest edit; each edit's receipt is its own.
182 for (id, ..) in &queued {
183 if !receipts.iter().any(|(n, _)| n == id)
184 && let Some(EditStatus::Published { revision }) = section.status(*id)?
185 {
186 receipts.push((*id, revision));
187 }
188 }
189 }
190 for (id, space, title, expected) in &queued {
191 let revision = receipts
192 .iter()
193 .find(|(n, _)| n == id)
194 .map(|(_, r)| r.to_string());
195 let stored = texts(&section.page(*space)?);
196 println!(
197 "{}",
198 serde_json::json!({"event": "published", "id": id, "space": space.to_string(), "title": title,
199 "revision": revision, "texts": expected, "stored": stored})
200 );
201 }
202 assert!(section.pending()?.is_empty());
203 section.close()?;
204 println!("{}", serde_json::json!({"event": "closed"}));
205 Ok(())
206}