use std::time::Duration; use ratatui::{crossterm::event::KeyCode, widgets::ListState}; use serde::{Deserialize, Serialize}; use tokio::{ sync::mpsc::{self, error::SendError}, time::sleep, }; use crate::snowballing::{ Publication, SnowballingHistory, SnowballingStep, get_publication_by_id, }; use log::{error, info, warn}; #[derive(Serialize, Deserialize, Default, PartialEq)] pub enum ActivePane { IncludedPublications, #[default] PendingPublications, } #[derive(Serialize, Deserialize, Clone, Debug)] pub enum StatusMessage { Info(String), Warning(String), Error(String), } impl Default for StatusMessage { fn default() -> Self { StatusMessage::Info("".to_string()) } } #[derive(Serialize, Deserialize)] struct SerializableListState { pub offset: usize, pub selected: Option, } pub mod liststate_serde { use serde::{Deserializer, Serializer}; use super::*; pub fn serialize( state: &ListState, serializer: S, ) -> Result where S: Serializer, { let surrogate = SerializableListState { offset: state.offset(), selected: state.selected(), }; surrogate.serialize(serializer) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = SerializableListState::deserialize(deserializer)?; Ok(ListState::default() .with_offset(s.offset) .with_selected(s.selected)) } } #[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)] pub enum Tab { #[default] Seeding, Snowballing, } #[derive(Clone, Debug)] pub enum SeedingAction { EnterChar(char), EnterBackspace, ClearInput, } #[derive(Clone, Debug)] pub enum SnowballingAction { SelectLeftPane, SelectRightPane, SearchForSelected, NextItem, PrevItem, ShowIncludedPublication(Publication), ShowPendingPublication(Publication), } #[derive(Clone, Debug)] pub enum GlobalAction { TriggerNextSnowballingStep, ShowStatusMessage(StatusMessage), ClearStatusMessage, AddIncludedPublication(Publication), SubmitSeedLink, NextTab, Quit, } #[derive(Clone, Debug)] pub enum Action { Snowballing(SnowballingAction), Seeding(SeedingAction), Global(GlobalAction), } impl From for Action { fn from(action: GlobalAction) -> Self { Action::Global(action) } } impl From for Action { fn from(action: SnowballingAction) -> Self { Action::Snowballing(action) } } impl From for Action { fn from(action: SeedingAction) -> Self { Action::Seeding(action) } } trait Component { fn handle_action( &mut self, action: T, tx: &mpsc::UnboundedSender, ) -> Result<(), SendError>; } #[derive(Serialize, Deserialize, Default)] pub struct SeedingComponent { pub input: String, #[serde(skip)] pub included_publications: Vec, } impl Component for SeedingComponent { fn handle_action( &mut self, action: SeedingAction, action_tx: &mpsc::UnboundedSender, ) -> Result<(), SendError> { match action { SeedingAction::ClearInput => { self.input.clear(); Ok(()) } SeedingAction::EnterChar(c) => { self.input.push(c); Ok(()) } SeedingAction::EnterBackspace => { if self.input.len() > 0 { self.input.truncate(self.input.len() - 1); } Ok(()) } } } } #[derive(Serialize, Deserialize, Default)] pub struct SnowballingComponent { #[serde(with = "liststate_serde")] pub included_list_state: ListState, #[serde(with = "liststate_serde")] pub pending_list_state: ListState, pub active_pane: ActivePane, /// Local component copy of the included publications list #[serde(skip)] pub included_publications: Vec, /// Local component copy of the pending publications list #[serde(skip)] pub pending_publications: Vec, } impl SnowballingComponent { fn next_list_item( list_state: &mut ListState, publications: &Vec, ) { let i = match list_state.selected() { Some(i) => { if i >= publications.len().wrapping_sub(1) { 0 } else { i + 1 } } None => 0, }; list_state.select(Some(i)); } fn prev_list_item( list_state: &mut ListState, publications: &Vec, ) { let i = match list_state.selected() { Some(i) => { if i == 0 { publications.len().wrapping_sub(1) } else { i - 1 } } None => 0, }; list_state.select(Some(i)); } } impl Component for SnowballingComponent { fn handle_action( &mut self, action: SnowballingAction, action_tx: &mpsc::UnboundedSender, ) -> Result<(), SendError> { match action { SnowballingAction::SelectLeftPane => { self.active_pane = ActivePane::IncludedPublications; if let None = self.included_list_state.selected() { self.included_list_state.select(Some(0)); } Ok(()) } SnowballingAction::SelectRightPane => { self.active_pane = ActivePane::IncludedPublications; if let None = self.included_list_state.selected() { self.included_list_state.select(Some(0)); } Ok(()) } SnowballingAction::SearchForSelected => match self.active_pane { ActivePane::IncludedPublications => { if let Some(idx) = self.included_list_state.selected() { open::that(&self.included_publications[idx].id) .unwrap(); } Ok(()) } ActivePane::PendingPublications => { if let Some(idx) = self.pending_list_state.selected() { open::that(&self.pending_publications[idx].id).unwrap(); } Ok(()) } }, SnowballingAction::NextItem => match self.active_pane { ActivePane::IncludedPublications => { Self::next_list_item( &mut self.included_list_state, &self.included_publications, ); Ok(()) } ActivePane::PendingPublications => { Self::next_list_item( &mut self.pending_list_state, &self.pending_publications, ); Ok(()) } }, SnowballingAction::PrevItem => match self.active_pane { ActivePane::IncludedPublications => { Self::prev_list_item( &mut self.included_list_state, &self.included_publications, ); Ok(()) } ActivePane::PendingPublications => { Self::prev_list_item( &mut self.pending_list_state, &self.pending_publications, ); Ok(()) } }, _ => Ok(()), } } } #[derive(Serialize, Deserialize, Default)] pub struct AppState { // UI state pub seeding: SeedingComponent, pub snowballing: SnowballingComponent, pub current_tab: Tab, #[serde(skip)] pub status_message: StatusMessage, // Internal state pub history: SnowballingHistory, } impl AppState { pub fn refresh_component_states(&mut self) { self.seeding.included_publications = self.history.get_all_included(); self.snowballing.included_publications = self.history.get_all_included(); self.snowballing.pending_publications = self.history.get_all_pending(); } } pub struct App { pub state: AppState, pub should_quit: bool, pub action_tx: &'static mpsc::UnboundedSender, } macro_rules! status_info { ($action_tx:expr, $text:expr, $($args:expr)*) => { $action_tx.send( GlobalAction::ShowStatusMessage(StatusMessage::Info(format!($text, $($args)*))) .into(), ) }; } macro_rules! status_warn { ($action_tx:expr, $text:expr, $($args:expr)*) => { $action_tx.send( GlobalAction::ShowStatusMessage(StatusMessage::Warning(format!($text, $($args)*))) .into(), ) }; } macro_rules! status_error { ($action_tx:expr, $text:expr $(, $args:expr)*) => { $action_tx.send( GlobalAction::ShowStatusMessage(StatusMessage::Error(format!($text, $($args)*))) .into(), ) }; } // TODO: Implement moving through steps and iterations (populating pending papers) // TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars) // TODO: Implement possibility of pushing excluded papers back into pending // TODO: Implement export of included papers as csv for keywording with a spreadsheet // TODO: Implement export of included papers into zotero (Use RIS format somehow) impl App { // TODO: Have status messages always last the same amount of time fn handle_global_action( &mut self, action: GlobalAction, action_tx: &'static mpsc::UnboundedSender, ) -> Result<(), SendError> { match action { GlobalAction::NextTab => { self.state.current_tab = match self.state.current_tab { Tab::Seeding => Tab::Snowballing, Tab::Snowballing => Tab::Seeding, }; Ok(()) } GlobalAction::ShowStatusMessage(msg) => { match &msg { StatusMessage::Error(_) => { error!("Status message: {:?}", msg) } StatusMessage::Warning(_) => { warn!("Status message: {:?}", msg) } StatusMessage::Info(_) => { info!("Status message: {:?}", msg) } } self.state.status_message = msg; tokio::spawn(async move { sleep(Duration::from_millis(1000)).await; if let Err(err) = action_tx.send(GlobalAction::ClearStatusMessage.into()) { error!("{}", err); } }); Ok(()) } GlobalAction::ClearStatusMessage => { self.state.status_message = StatusMessage::Info("".to_string()); Ok(()) } GlobalAction::TriggerNextSnowballingStep => { // if self.pending_publications.len() > 0 { // warn!( // "The next snowballing step can only be initiated \ // after screening all pending publications" // ); // self.set_status_message(StatusMessage::Warning( // "The next snowballing step can only be initiated \ // after screening all pending publications" // .to_string(), // )); // return; // } // // match self.snowballing_step { // SnowballingStep::Forward => { // // TODO: Implement // } // SnowballingStep::Backward => { // self.set_status_message(StatusMessage::Info( // "Fetching references...".to_string(), // )); // // // TODO: Find a way to not clone the publications // for publication in self.included_publications.clone() { // // TODO: In addition to the referenced_works do // // an API call for citations // for reference in &publication.referenced_works { // let api_link = format!( // "https://api.openalex.org/{}", // &reference[21..] // ); // let publ = get_publication_by_id( // &api_link, // "an.tsouchlos@gmail.com", // ) // .await; // // match publ { // Ok(publ) => { // self.pending_publications.push(publ) // } // Err(err) => { // warn!( // "Failed to get publication\ // metadata using OpenAlex API: \ // {}", // err // ); // // self.set_status_message( // StatusMessage::Error(format!( // "Failed to get publication\ // metadata using OpenAlex API: \ // {}", // err // )), // ); // } // } // } // // self.set_status_message(StatusMessage::Info( // "Done".to_string(), // )); // } // } // } Ok(()) } GlobalAction::SubmitSeedLink => { if !self .state .seeding .input .starts_with("https://openalex.org/") { status_error!( self.action_tx, "Seed link must start with 'https://openalex.org/'" )?; return Ok(()); } status_info!( self.action_tx, "Submitting seed link: {}", &self.state.seeding.input )?; let api_link = format!( "https://api.openalex.org/{}", self.state .seeding .input .trim_start_matches("https://openalex.org/") ); tokio::spawn(async move { let publ = get_publication_by_id( &api_link, "an.tsouchlos@gmail.com", ); match publ.await { Ok(publ) => { let _ = status_info!( action_tx, "Seed paper obtained successfully: {}", publ.get_title().unwrap_or( "[title unavailable]".to_string() ) ); let _ = action_tx.send( GlobalAction::AddIncludedPublication(publ) .into(), ); } Err(e) => { let _ = status_error!(action_tx, "{}", e.to_string()); } } }); self.action_tx.send(SeedingAction::ClearInput.into()) } GlobalAction::Quit => { self.should_quit = true; Ok(()) } GlobalAction::AddIncludedPublication(publ) => { self.state .history .current_iteration .included_publications .push(publ.clone()); Ok(()) } _ => Ok(()), } } pub fn handle_action( &mut self, action: Action, ) -> Result<(), SendError> { match action { Action::Seeding(seeding_action) => self .state .seeding .handle_action(seeding_action, self.action_tx), Action::Snowballing(snowballing_action) => self .state .snowballing .handle_action(snowballing_action, self.action_tx), Action::Global(global_action) => { self.handle_global_action(global_action, self.action_tx) } } } pub fn handle_key( &mut self, key: KeyCode, ) -> Result<(), SendError> { match (self.state.current_tab, key) { (_, KeyCode::Esc) => { self.action_tx.send(GlobalAction::Quit.into())?; } (_, KeyCode::Tab) => { self.action_tx.send(GlobalAction::NextTab.into())?; } (Tab::Seeding, KeyCode::Char(c)) => { self.action_tx.send(SeedingAction::EnterChar(c).into())?; } (Tab::Seeding, KeyCode::Backspace) => { self.action_tx.send(SeedingAction::EnterBackspace.into())?; } (Tab::Seeding, KeyCode::Enter) => { self.action_tx.send(GlobalAction::SubmitSeedLink.into())?; } _ => {} } Ok(()) } }