1mod editor;
3mod view;
4
5use crate::{cli::PageArgs, config::Profile, desktop, error::Error, model, write};
6use editor::Editor;
7use ratatui::{
8 Terminal,
9 backend::CrosstermBackend,
10 crossterm::{
11 cursor::Show,
12 event::{
13 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent,
14 KeyEventKind, KeyModifiers,
15 },
16 execute,
17 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
18 },
19};
20use serde_json::{Value, json};
21use std::{
22 collections::HashMap,
23 io::{self, IsTerminal},
24 time::Duration,
25};
26use tokio::task::JoinHandle;
27
28const PAGE_SIZE: u16 = 50;
29
30struct TerminalGuard;
31impl TerminalGuard {
32 fn enter() -> Result<Self, Error> {
33 enable_raw_mode()?;
34 let guard = Self;
35 execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
36 Ok(guard)
37 }
38}
39impl Drop for TerminalGuard {
40 fn drop(&mut self) {
41 let _ = disable_raw_mode();
42 let _ = execute!(
43 io::stdout(),
44 DisableBracketedPaste,
45 LeaveAlternateScreen,
46 Show
47 );
48 }
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
52struct Entry {
53 id: String,
54 name: String,
55 path: String,
56 locked: bool,
57 modified: Option<String>,
58}
59impl Entry {
60 fn from_value(value: &Value) -> Option<Self> {
61 Some(Self {
62 id: value["id"].as_str()?.to_owned(),
63 name: value["name"].as_str().unwrap_or("Untitled").to_owned(),
64 path: value["path"].as_str().unwrap_or("").to_owned(),
65 locked: value["locked"].as_bool().unwrap_or(false),
66 modified: value["last_modified"].as_str().map(str::to_owned),
67 })
68 }
69}
70#[derive(Clone, Debug, PartialEq, Eq)]
71enum Location {
72 Notebooks,
73 Sections(Entry),
74 Pages { notebook: Entry, section: Entry },
75 Search { query: String, scan: bool },
76}
77impl Location {
78 fn key(&self) -> String {
79 match self {
80 Self::Notebooks => "notebooks".into(),
81 Self::Sections(book) => format!("sections:{}", book.id),
82 Self::Pages { section, .. } => format!("pages:{}", section.id),
83 Self::Search { query, scan } => format!("search:{scan}:{query}"),
84 }
85 }
86 fn label(&self) -> String {
87 match self {
88 Self::Notebooks => "Notebooks".into(),
89 Self::Sections(book) => format!("{} / Sections", book.name),
90 Self::Pages { notebook, section } => format!("{} / {}", notebook.name, section.name),
91 Self::Search { query, scan } => format!(
92 "{} search / {query}",
93 if *scan { "Literal" } else { "Indexed" }
94 ),
95 }
96 }
97 fn heading(&self) -> &'static str {
98 match self {
99 Self::Notebooks => "NOTEBOOKS",
100 Self::Sections(_) => "SECTIONS",
101 Self::Pages { .. } => "PAGES",
102 Self::Search { .. } => "SEARCH RESULTS",
103 }
104 }
105}
106#[derive(Clone)]
107enum Request {
108 List {
109 location: Location,
110 offset: u32,
111 },
112 Read {
113 page: Entry,
114 },
115 Write {
116 kind: WriteKind,
117 target: String,
118 title: Option<String>,
119 text: String,
120 },
121}
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123enum WriteKind {
124 Create,
125 Append,
126}
127impl WriteKind {
128 fn operation(self) -> &'static str {
129 match self {
130 Self::Create => "create",
131 Self::Append => "append",
132 }
133 }
134}
135enum Response {
136 List {
137 rows: Vec<Entry>,
138 total: usize,
139 next: Option<u32>,
140 warning: Option<String>,
141 },
142 Read(Value),
143 Written(Value),
144}
145struct Job {
146 request: Request,
147 task: JoinHandle<Result<Response, Error>>,
148}
149impl Drop for Job {
150 fn drop(&mut self) {
151 self.task.abort();
152 }
153}
154
155#[derive(Clone, Copy, PartialEq, Eq)]
156enum InputKind {
157 Indexed,
158 Literal,
159 Filter,
160}
161struct Compose {
162 kind: WriteKind,
163 target: String,
164 target_name: String,
165 title: Editor,
166 body: Editor,
167 title_focus: bool,
168 review_scroll: u16,
169}
170impl Compose {
171 fn new(
172 kind: WriteKind,
173 target: String,
174 target_name: String,
175 old: Option<(String, String)>,
176 ) -> Self {
177 let (title, body) = old.unwrap_or_default();
178 Self {
179 kind,
180 target,
181 target_name,
182 title: Editor::new(title, false),
183 body: Editor::new(body, true),
184 title_focus: kind == WriteKind::Create,
185 review_scroll: 0,
186 }
187 }
188 fn active(&mut self) -> &mut Editor {
189 if self.title_focus {
190 &mut self.title
191 } else {
192 &mut self.body
193 }
194 }
195 fn draft(&self) -> (String, String) {
196 (self.title.text().to_owned(), self.body.text().to_owned())
197 }
198}
199enum Overlay {
200 None,
201 Help,
202 Input { kind: InputKind, editor: Editor },
203 Compose(Compose),
204 Review(Compose),
205}
206struct App {
207 profile_name: String,
208 profile: Profile,
209 color: bool,
210 location: Location,
211 return_to: Option<Location>,
212 rows: Vec<Entry>,
213 selected: usize,
214 filter: String,
215 total: usize,
216 next: Option<u32>,
217 page: Option<Value>,
218 page_scroll: u16,
219 page_max_scroll: u16,
220 full_page: bool,
221 loading: bool,
222 writing: bool,
223 notice: String,
224 error: Option<String>,
225 uncertain: bool,
226 drafts: HashMap<String, (String, String)>,
227 selections: HashMap<String, String>,
228 overlay: Overlay,
229}
230impl App {
231 fn new(profile: Profile, name: Option<&str>, color: bool) -> Self {
232 Self {
233 profile_name: name.unwrap_or("default").into(),
234 profile,
235 color,
236 location: Location::Notebooks,
237 return_to: None,
238 rows: vec![],
239 selected: 0,
240 filter: String::new(),
241 total: 0,
242 next: None,
243 page: None,
244 page_scroll: 0,
245 page_max_scroll: 0,
246 full_page: false,
247 loading: false,
248 writing: false,
249 notice: "Choose a notebook · press ? for keys".into(),
250 error: None,
251 uncertain: false,
252 drafts: HashMap::new(),
253 selections: HashMap::new(),
254 overlay: Overlay::None,
255 }
256 }
257 fn selected_entry(&self) -> Option<&Entry> {
258 self.rows.get(self.selected)
259 }
260 fn visible_indices(&self) -> Vec<usize> {
261 let needle = self.filter.to_lowercase();
262 self.rows
263 .iter()
264 .enumerate()
265 .filter(|(_, row)| {
266 needle.is_empty()
267 || row.name.to_lowercase().contains(&needle)
268 || row.path.to_lowercase().contains(&needle)
269 })
270 .map(|(i, _)| i)
271 .collect()
272 }
273 fn move_selection(&mut self, delta: isize) -> Action {
274 let visible = self.visible_indices();
275 if visible.is_empty() {
276 return Action::None;
277 }
278 let current = visible
279 .iter()
280 .position(|i| *i == self.selected)
281 .unwrap_or(0);
282 let next = (current as isize + delta).clamp(0, visible.len() as isize - 1) as usize;
283 if self.selected == visible[next] {
284 return Action::None;
285 }
286 self.selected = visible[next];
287 self.page = None;
288 self.full_page = false;
289 self.page_scroll = 0;
290 self.read_request().map_or(Action::None, Action::Fetch)
291 }
292 fn read_request(&self) -> Option<Request> {
293 if matches!(
294 self.location,
295 Location::Pages { .. } | Location::Search { .. }
296 ) {
297 self.selected_entry()
298 .cloned()
299 .map(|page| Request::Read { page })
300 } else {
301 None
302 }
303 }
304 fn navigate(&mut self, location: Location) -> Action {
305 if let Some(row) = self.selected_entry() {
306 self.selections.insert(self.location.key(), row.id.clone());
307 }
308 self.location = location.clone();
309 self.rows.clear();
310 self.selected = 0;
311 self.total = 0;
312 self.next = None;
313 self.page = None;
314 self.page_scroll = 0;
315 self.page_max_scroll = 0;
316 self.full_page = false;
317 self.filter.clear();
318 self.error = None;
319 Action::Fetch(Request::List {
320 location,
321 offset: 0,
322 })
323 }
324 fn back(&mut self) -> Action {
325 if self.full_page {
326 self.full_page = false;
327 return Action::None;
328 }
329 match &self.location {
330 Location::Notebooks => Action::None,
331 Location::Sections(_) => self.navigate(Location::Notebooks),
332 Location::Pages { notebook, .. } => self.navigate(Location::Sections(notebook.clone())),
333 Location::Search { .. } => {
334 let location = self.return_to.take().unwrap_or(Location::Notebooks);
335 self.navigate(location)
336 }
337 }
338 }
339 fn enter(&mut self) -> Action {
340 let Some(row) = self.selected_entry().cloned() else {
341 return Action::None;
342 };
343 match &self.location {
344 Location::Notebooks => self.navigate(Location::Sections(row)),
345 Location::Sections(book) => {
346 if row.locked {
347 self.notice = "Section is locked · unlock it in OneNote desktop".into();
348 Action::None
349 } else {
350 self.navigate(Location::Pages {
351 notebook: book.clone(),
352 section: row,
353 })
354 }
355 }
356 Location::Pages { .. } | Location::Search { .. } => {
357 self.full_page = true;
358 if self.page.as_ref().and_then(|v| v["id"].as_str()) == Some(row.id.as_str()) {
359 Action::None
360 } else {
361 Action::Fetch(Request::Read { page: row })
362 }
363 }
364 }
365 }
366 fn complete(&mut self, request: &Request, result: Result<Response, Error>) -> Option<Request> {
367 self.loading = false;
368 self.writing = false;
369 let response = match result {
370 Ok(response) => {
371 self.error = None;
372 response
373 }
374 Err(error) => {
375 self.error = Some(error.message);
376 if matches!(request, Request::Write { .. }) {
377 self.uncertain = true;
378 self.notice =
379 "Write result unknown · inspect OneNote before another write".into();
380 } else {
381 self.notice = "Request failed · r retry".into();
382 }
383 return None;
384 }
385 };
386 match (request, response) {
387 (
388 Request::List { location, offset },
389 Response::List {
390 rows,
391 total,
392 next,
393 warning,
394 },
395 ) if self.location == *location => {
396 if *offset == 0 {
397 self.rows.clear();
398 self.selected = 0;
399 self.page = None;
400 }
401 for row in rows {
402 if !self.rows.iter().any(|old| old.id == row.id) {
403 self.rows.push(row);
404 }
405 }
406 self.total = total;
407 self.next = next;
408 if *offset == 0
409 && let Some(saved) = self.selections.get(&location.key())
410 && let Some(index) = self.rows.iter().position(|row| &row.id == saved)
411 {
412 self.selected = index;
413 }
414 self.notice = warning.unwrap_or_else(|| {
415 format!(
416 "{} of {} {} loaded",
417 self.rows.len(),
418 total,
419 location.heading().to_lowercase()
420 )
421 });
422 if *offset == 0 {
423 return self.read_request();
424 }
425 }
426 (Request::Read { page }, Response::Read(value))
427 if self.selected_entry().is_some_and(|row| row.id == page.id) =>
428 {
429 self.page = Some(value);
430 self.page_scroll = 0;
431 self.page_max_scroll = 0;
432 self.notice = format!("{} · ready", page.name);
433 }
434 (
435 Request::Write {
436 kind,
437 target,
438 title,
439 ..
440 },
441 Response::Written(value),
442 ) => {
443 self.drafts
444 .remove(&format!("{}:{target}", kind.operation()));
445 if *kind == WriteKind::Create
446 && let Some(row) = Entry::from_value(&value)
447 {
448 self.rows.retain(|old| old.id != row.id);
449 self.rows.insert(0, row);
450 self.selected = 0;
451 self.total += 1;
452 self.filter.clear();
453 }
454 if *kind == WriteKind::Append
455 && let Some(index) = self.rows.iter().position(|row| row.id == *target)
456 {
457 self.selected = index;
458 }
459 self.page = Some(value);
460 self.page_scroll = 0;
461 self.page_max_scroll = 0;
462 self.notice = match kind {
463 WriteKind::Create => format!(
464 "Created {} · verified in OneNote",
465 title.as_deref().unwrap_or("page")
466 ),
467 WriteKind::Append => "Append verified in OneNote".into(),
468 };
469 }
470 _ => {}
471 }
472 None
473 }
474 fn key(&mut self, key: KeyEvent) -> Action {
475 if self.writing {
476 return Action::None;
477 }
478 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
479 return Action::Quit;
480 }
481 match std::mem::replace(&mut self.overlay, Overlay::None) {
482 Overlay::Help => return Action::None,
483 Overlay::Input { kind, mut editor } => {
484 if key.code == KeyCode::Esc {
485 return Action::None;
486 }
487 if key.code == KeyCode::Enter {
488 let text = editor.text().trim().to_owned();
489 match kind {
490 InputKind::Filter => {
491 self.filter = text;
492 if let Some(first) = self.visible_indices().first() {
493 self.selected = *first;
494 self.page = None;
495 return self.read_request().map_or(Action::None, Action::Fetch);
496 }
497 return Action::None;
498 }
499 InputKind::Indexed | InputKind::Literal if !text.is_empty() => {
500 if !matches!(self.location, Location::Search { .. }) {
501 self.return_to = Some(self.location.clone());
502 }
503 return self.navigate(Location::Search {
504 query: text,
505 scan: kind == InputKind::Literal,
506 });
507 }
508 _ => {}
509 }
510 }
511 editor.key(key);
512 self.overlay = Overlay::Input { kind, editor };
513 return Action::None;
514 }
515 Overlay::Compose(mut draft) => {
516 if key.code == KeyCode::Esc {
517 self.drafts.insert(
518 format!("{}:{}", draft.kind.operation(), draft.target),
519 draft.draft(),
520 );
521 self.notice = "Draft kept for this session".into();
522 return Action::None;
523 }
524 if key.code == KeyCode::Tab {
525 draft.title_focus = !draft.title_focus && draft.kind == WriteKind::Create;
526 self.overlay = Overlay::Compose(draft);
527 return Action::None;
528 }
529 if key.modifiers.contains(KeyModifiers::CONTROL)
530 && matches!(key.code, KeyCode::Char('s') | KeyCode::Enter)
531 {
532 if draft.kind == WriteKind::Create
533 && let Err(e) = write::validate(draft.title.text(), true)
534 {
535 self.error = Some(e.message);
536 self.overlay = Overlay::Compose(draft);
537 return Action::None;
538 }
539 if let Err(e) = write::validate(draft.body.text(), false) {
540 self.error = Some(e.message);
541 self.overlay = Overlay::Compose(draft);
542 return Action::None;
543 }
544 self.error = None;
545 self.overlay = Overlay::Review(draft);
546 return Action::None;
547 }
548 draft.active().key(key);
549 self.error = None;
550 self.overlay = Overlay::Compose(draft);
551 return Action::None;
552 }
553 Overlay::Review(mut draft) => {
554 match key.code {
555 KeyCode::Esc => {
556 self.overlay = Overlay::Compose(draft);
557 return Action::None;
558 }
559 KeyCode::Enter => {
560 let title = if draft.kind == WriteKind::Create {
561 Some(draft.title.text().to_owned())
562 } else {
563 None
564 };
565 let text = draft.body.text().to_owned();
566 self.drafts.insert(
567 format!("{}:{}", draft.kind.operation(), draft.target),
568 draft.draft(),
569 );
570 return Action::Fetch(Request::Write {
571 kind: draft.kind,
572 target: draft.target,
573 title,
574 text,
575 });
576 }
577 KeyCode::Down | KeyCode::Char('j') => {
578 draft.review_scroll = draft.review_scroll.saturating_add(1)
579 }
580 KeyCode::Up | KeyCode::Char('k') => {
581 draft.review_scroll = draft.review_scroll.saturating_sub(1)
582 }
583 KeyCode::PageDown => {
584 draft.review_scroll = draft.review_scroll.saturating_add(8)
585 }
586 KeyCode::PageUp => draft.review_scroll = draft.review_scroll.saturating_sub(8),
587 KeyCode::Home => draft.review_scroll = 0,
588 KeyCode::End => draft.review_scroll = u16::MAX,
589 _ => {}
590 }
591 self.overlay = Overlay::Review(draft);
592 return Action::None;
593 }
594 Overlay::None => {}
595 }
596 match key.code {
597 KeyCode::Char('q') => Action::Quit,
598 KeyCode::Char('?') => {
599 self.overlay = Overlay::Help;
600 Action::None
601 }
602 KeyCode::Char('j') | KeyCode::Down => self.move_selection(1),
603 KeyCode::Char('k') | KeyCode::Up => self.move_selection(-1),
604 KeyCode::Enter | KeyCode::Right => self.enter(),
605 KeyCode::Esc | KeyCode::Left | KeyCode::Backspace => {
606 if self.full_page {
607 self.back()
608 } else if !self.filter.is_empty() {
609 self.filter.clear();
610 Action::None
611 } else {
612 self.back()
613 }
614 }
615 KeyCode::PageDown => {
616 self.page_scroll = self.page_scroll.saturating_add(8).min(self.page_max_scroll);
617 Action::None
618 }
619 KeyCode::PageUp => {
620 self.page_scroll = self.page_scroll.saturating_sub(8);
621 Action::None
622 }
623 KeyCode::Home => {
624 self.page_scroll = 0;
625 Action::None
626 }
627 KeyCode::End => {
628 self.page_scroll = self.page_max_scroll;
629 Action::None
630 }
631 KeyCode::Char('r') => Action::Fetch(Request::List {
632 location: self.location.clone(),
633 offset: 0,
634 }),
635 KeyCode::Char('n') if self.next.is_some() && !self.loading => {
636 Action::Fetch(Request::List {
637 location: self.location.clone(),
638 offset: self.next.unwrap(),
639 })
640 }
641 KeyCode::Char('/') => {
642 self.overlay = Overlay::Input {
643 kind: InputKind::Indexed,
644 editor: Editor::new(String::new(), false),
645 };
646 Action::None
647 }
648 KeyCode::Char('s') => {
649 self.overlay = Overlay::Input {
650 kind: InputKind::Literal,
651 editor: Editor::new(String::new(), false),
652 };
653 Action::None
654 }
655 KeyCode::Char('f') => {
656 self.overlay = Overlay::Input {
657 kind: InputKind::Filter,
658 editor: Editor::new(self.filter.clone(), false),
659 };
660 Action::None
661 }
662 KeyCode::Char('b') => self.navigate(Location::Notebooks),
663 KeyCode::Char('c') if !self.profile.read_only && !self.uncertain => {
664 if let Location::Pages { section, .. } = &self.location {
665 let target = section.id.clone();
666 let name = section.name.clone();
667 let old = self.drafts.remove(&format!("create:{target}"));
668 self.overlay =
669 Overlay::Compose(Compose::new(WriteKind::Create, target, name, old));
670 } else {
671 self.notice = "Open a section to create a page".into();
672 }
673 Action::None
674 }
675 KeyCode::Char('a') if !self.profile.read_only && !self.uncertain => {
676 if let Some(page) = self
677 .selected_entry()
678 .filter(|_| {
679 matches!(
680 self.location,
681 Location::Pages { .. } | Location::Search { .. }
682 )
683 })
684 .cloned()
685 {
686 let target = page.id.clone();
687 let old = self.drafts.remove(&format!("append:{target}"));
688 self.overlay =
689 Overlay::Compose(Compose::new(WriteKind::Append, target, page.name, old));
690 } else {
691 self.notice = "Select a page to append text".into();
692 }
693 Action::None
694 }
695 KeyCode::Char('c' | 'a') if self.profile.read_only => {
696 self.notice = "This profile is read-only".into();
697 Action::None
698 }
699 KeyCode::Char('c' | 'a') if self.uncertain => {
700 self.notice = "Write outcome unknown · inspect OneNote before another write".into();
701 Action::None
702 }
703 _ => Action::None,
704 }
705 }
706 fn paste(&mut self, text: &str) {
707 let truncated = match &mut self.overlay {
708 Overlay::Input { editor, .. } => editor.paste(text),
709 Overlay::Compose(draft) => draft.active().paste(text),
710 _ => false,
711 };
712 if truncated {
713 self.error =
714 Some("Paste reached the editor limit; review the text before continuing".into());
715 }
716 }
717}
718#[allow(clippy::large_enum_variant)] enum Action {
720 None,
721 Fetch(Request),
722 Quit,
723}
724
725fn request_start(profile: Profile, request: Request, app: &mut App) -> Job {
726 app.loading = true;
727 app.writing = matches!(request, Request::Write { .. });
728 app.error = None;
729 app.notice = match &request {
730 Request::List { .. } => "Loading from OneNote…",
731 Request::Read { .. } => "Reading page…",
732 Request::Write { .. } => "Writing to OneNote · please wait…",
733 }
734 .into();
735 let task_request = request.clone();
736 let task = tokio::spawn(async move {
737 match task_request {
738 Request::List { location, offset } => {
739 let page = PageArgs {
740 limit: PAGE_SIZE,
741 offset,
742 fields: vec![],
743 };
744 let value = match &location {
745 Location::Notebooks => model::collection(
746 &desktop::run(&profile, json!({"operation":"notebooks"})).await?,
747 "Notebook",
748 &page,
749 )?,
750 Location::Sections(book) => model::collection(
751 &desktop::run(&profile, json!({"operation":"sections","scope":book.id}))
752 .await?,
753 "Section",
754 &page,
755 )?,
756 Location::Pages { section, .. } => model::collection(
757 &desktop::run(&profile, json!({"operation":"pages","scope":section.id}))
758 .await?,
759 "Page",
760 &page,
761 )?,
762 Location::Search { query, scan: false } => model::search(
763 &desktop::run(&profile, json!({"operation":"search","query":query}))
764 .await?,
765 &page,
766 )?,
767 Location::Search { query, scan: true } => {
768 let batch = desktop::scan(&profile, json!({"operation":"scan","scope":null,"scan_limit":100,"scan_offset":0})).await?;
769 model::scan(&batch, query, &page)?
770 }
771 };
772 let rows = value["items"]
773 .as_array()
774 .map(|v| v.iter().filter_map(Entry::from_value).collect())
775 .unwrap_or_default();
776 let warning = if value["indexing_pending"] == true {
777 Some(format!(
778 "Search index is still updating · {} pages not indexed",
779 value["unindexed_count"]
780 ))
781 } else if value["incomplete"] == true {
782 Some(
783 "Literal scan incomplete · refine your query or use the CLI for more"
784 .into(),
785 )
786 } else {
787 None
788 };
789 Ok(Response::List {
790 rows,
791 total: value["total"].as_u64().unwrap_or(0) as usize,
792 next: value["next_offset"].as_u64().map(|n| n as u32),
793 warning,
794 })
795 }
796 Request::Read { page } => {
797 let xml = desktop::run(&profile, json!({"operation":"read","id":page.id})).await?;
798 Ok(Response::Read(model::read(&xml, &page.id, false)?))
799 }
800 Request::Write {
801 kind,
802 target,
803 title,
804 text,
805 } => {
806 let args = crate::cli::ContentArgs {
807 text: Some(text),
808 file: None,
809 dry_run: false,
810 };
811 let payload = write::prepare(kind.operation(), &target, title.as_deref(), &args)?;
812 let value = desktop::write(&profile, payload).await?;
813 let id = value["id"].as_str().ok_or_else(|| {
814 Error::new("write_uncertain", "Write response has no page ID")
815 })?;
816 let xml = value["xml"].as_str().ok_or_else(|| {
817 Error::new("write_uncertain", "Write response has no page content")
818 })?;
819 if value["verified"] != true {
820 return Err(Error::new(
821 "write_uncertain",
822 "OneNote did not verify the write",
823 ));
824 }
825 Ok(Response::Written(
826 model::read(xml, id, false)
827 .map_err(|e| Error::new("write_uncertain", e.message))?,
828 ))
829 }
830 }
831 });
832 Job { request, task }
833}
834
835pub async fn run(profile: Profile, name: Option<&str>, color: bool) -> Result<(), Error> {
836 if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
837 return Err(Error::invalid("`onenote tui` needs a terminal"));
838 }
839 let color = color
840 && std::env::var_os("NO_COLOR").is_none()
841 && std::env::var("TERM").as_deref() != Ok("dumb");
842 let mut app = App::new(profile.clone(), name, color);
843 let _guard = TerminalGuard::enter()?;
844 let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
845 let mut job = Some(request_start(
846 profile.clone(),
847 Request::List {
848 location: Location::Notebooks,
849 offset: 0,
850 },
851 &mut app,
852 ));
853 let mut dirty = true;
854 loop {
855 if job.as_ref().is_some_and(|j| j.task.is_finished()) {
856 let mut done = job.take().unwrap();
857 let result = (&mut done.task)
858 .await
859 .unwrap_or_else(|e| Err(Error::desktop(format!("Request stopped: {e}"))));
860 if let Some(next) = app.complete(&done.request, result) {
861 job = Some(request_start(profile.clone(), next, &mut app));
862 }
863 dirty = true;
864 }
865 if dirty {
866 terminal.draw(|frame| view::draw(frame, &mut app))?;
867 dirty = false;
868 }
869 if event::poll(Duration::from_millis(40))? {
870 dirty = true;
871 let action = match event::read()? {
872 Event::Key(key)
873 if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
874 {
875 app.key(key)
876 }
877 Event::Paste(text) => {
878 app.paste(&text);
879 Action::None
880 }
881 _ => Action::None,
882 };
883 match action {
884 Action::Quit => break,
885 Action::None => {}
886 Action::Fetch(request) => {
887 drop(job.take());
888 job = Some(request_start(profile.clone(), request, &mut app));
889 }
890 }
891 }
892 tokio::task::yield_now().await;
893 }
894 Ok(())
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use ratatui::{Terminal, backend::TestBackend};
901 fn row(id: &str, name: &str) -> Entry {
902 Entry {
903 id: id.into(),
904 name: name.into(),
905 path: name.into(),
906 locked: false,
907 modified: None,
908 }
909 }
910 fn key(code: KeyCode) -> KeyEvent {
911 KeyEvent::new(code, KeyModifiers::NONE)
912 }
913 #[test]
914 fn drill_down_and_back_preserve_hierarchy() {
915 let mut app = App::new(Profile::default(), None, false);
916 app.rows = vec![row("book", "Notebook")];
917 assert!(matches!(
918 app.key(key(KeyCode::Enter)),
919 Action::Fetch(Request::List {
920 location: Location::Sections(_),
921 ..
922 })
923 ));
924 app.rows = vec![row("first", "First"), row("section", "Ideas")];
925 app.selected = 1;
926 assert!(matches!(
927 app.key(key(KeyCode::Enter)),
928 Action::Fetch(Request::List {
929 location: Location::Pages { .. },
930 ..
931 })
932 ));
933 assert!(matches!(
934 app.key(key(KeyCode::Esc)),
935 Action::Fetch(Request::List {
936 location: Location::Sections(_),
937 ..
938 })
939 ));
940 let location = app.location.clone();
941 app.complete(
942 &Request::List {
943 location,
944 offset: 0,
945 },
946 Ok(Response::List {
947 rows: vec![row("first", "First"), row("section", "Ideas")],
948 total: 2,
949 next: None,
950 warning: None,
951 }),
952 );
953 assert_eq!(
954 app.selected_entry().map(|row| row.id.as_str()),
955 Some("section")
956 );
957 }
958 #[test]
959 fn writes_keep_captured_target_and_require_review() {
960 let mut app = App::new(Profile::default(), None, false);
961 app.location = Location::Pages {
962 notebook: row("book", "Notebook"),
963 section: row("section", "Ideas"),
964 };
965 app.rows = vec![row("page-1", "First"), row("page-2", "Second")];
966 app.key(key(KeyCode::Char('a')));
967 app.selected = 1;
968 app.paste("Safe note");
969 app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL));
970 assert!(matches!(app.overlay, Overlay::Review(_)));
971 assert!(
972 matches!(app.key(key(KeyCode::Enter)), Action::Fetch(Request::Write { target, text, .. }) if target == "page-1" && text == "Safe note")
973 );
974 }
975 #[test]
976 fn read_only_blocks_compose() {
977 let profile = Profile {
978 read_only: true,
979 ..Profile::default()
980 };
981 let mut app = App::new(profile, None, false);
982 app.location = Location::Pages {
983 notebook: row("book", "Notebook"),
984 section: row("section", "Ideas"),
985 };
986 app.rows.push(row("page", "First"));
987 app.key(key(KeyCode::Char('a')));
988 assert!(matches!(app.overlay, Overlay::None));
989 }
990 #[test]
991 fn verified_create_is_visible_and_uncertain_write_blocks_retries() {
992 let mut app = App::new(Profile::default(), None, false);
993 app.location = Location::Pages {
994 notebook: row("book", "Notebook"),
995 section: row("section", "Ideas"),
996 };
997 app.complete(
998 &Request::Write {
999 kind: WriteKind::Create,
1000 target: "section".into(),
1001 title: Some("New".into()),
1002 text: "body".into(),
1003 },
1004 Ok(Response::Written(
1005 json!({"id":"page","name":"New","path":"Notebook / Ideas / New","markdown":"body"}),
1006 )),
1007 );
1008 assert_eq!(
1009 app.selected_entry().map(|row| row.name.as_str()),
1010 Some("New")
1011 );
1012 assert_eq!(app.notice, "Created New · verified in OneNote");
1013 app.complete(
1014 &Request::Write {
1015 kind: WriteKind::Append,
1016 target: "page".into(),
1017 title: None,
1018 text: "again".into(),
1019 },
1020 Err(Error::new("write_uncertain", "Connection lost")),
1021 );
1022 app.key(key(KeyCode::Char('a')));
1023 assert!(app.uncertain);
1024 assert!(matches!(app.overlay, Overlay::None));
1025 }
1026 #[test]
1027 fn layouts_render_at_compact_medium_and_wide_sizes() {
1028 let mut app = App::new(Profile::default(), None, false);
1029 app.rows.push(row("book", "Notebook"));
1030 app.total = 1;
1031 for (width, height) in [(42, 12), (80, 24), (120, 35)] {
1032 let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
1033 terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1034 let screen: String = terminal
1035 .backend()
1036 .buffer()
1037 .content()
1038 .iter()
1039 .map(|cell| cell.symbol())
1040 .collect();
1041 assert!(screen.contains("ONENOTE"));
1042 assert!(screen.contains("Notebook"));
1043 }
1044 }
1045 #[test]
1046 fn long_write_review_can_reach_the_final_lines() {
1047 let mut app = App::new(Profile::default(), None, false);
1048 let mut draft = Compose::new(WriteKind::Append, "page".into(), "Page".into(), None);
1049 draft.body.paste(
1050 &(1..=35)
1051 .map(|n| format!("Line {n:02}"))
1052 .collect::<Vec<_>>()
1053 .join("\n"),
1054 );
1055 app.overlay = Overlay::Review(draft);
1056 app.key(key(KeyCode::End));
1057 let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
1058 terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1059 let screen: String = terminal
1060 .backend()
1061 .buffer()
1062 .content()
1063 .iter()
1064 .map(|cell| cell.symbol())
1065 .collect();
1066 assert!(screen.contains("Line 35"));
1067 assert!(
1068 matches!(app.key(key(KeyCode::Enter)), Action::Fetch(Request::Write { text, .. }) if text.ends_with("Line 35"))
1069 );
1070 }
1071 #[test]
1072 fn wrapped_editor_keeps_cursor_visible_on_compact_terminal() {
1073 let mut app = App::new(Profile::default(), None, false);
1074 let mut draft = Compose::new(WriteKind::Append, "page".into(), "Page".into(), None);
1075 draft.body.paste(&"long word ".repeat(45));
1076 app.overlay = Overlay::Compose(draft);
1077 let mut terminal = Terminal::new(TestBackend::new(42, 12)).unwrap();
1078 terminal.draw(|frame| view::draw(frame, &mut app)).unwrap();
1079 let screen: String = terminal
1080 .backend()
1081 .buffer()
1082 .content()
1083 .iter()
1084 .map(|cell| cell.symbol())
1085 .collect();
1086 assert!(screen.contains('▏'));
1087 }
1088 #[test]
1089 fn escape_leaves_full_page_before_clearing_filter() {
1090 let mut app = App::new(Profile::default(), None, false);
1091 app.location = Location::Pages {
1092 notebook: row("book", "Notebook"),
1093 section: row("section", "Ideas"),
1094 };
1095 app.rows.push(row("page", "Page"));
1096 app.filter = "Page".into();
1097 app.full_page = true;
1098 assert!(matches!(app.key(key(KeyCode::Esc)), Action::None));
1099 assert!(!app.full_page);
1100 assert_eq!(app.filter, "Page");
1101 }
1102}