brittling/src/app.rs

595 lines
19 KiB
Rust

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<usize>,
}
pub mod liststate_serde {
use serde::{Deserializer, Serializer};
use super::*;
pub fn serialize<S>(
state: &ListState,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let surrogate = SerializableListState {
offset: state.offset(),
selected: state.selected(),
};
surrogate.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<ListState, D::Error>
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<GlobalAction> for Action {
fn from(action: GlobalAction) -> Self {
Action::Global(action)
}
}
impl From<SnowballingAction> for Action {
fn from(action: SnowballingAction) -> Self {
Action::Snowballing(action)
}
}
impl From<SeedingAction> for Action {
fn from(action: SeedingAction) -> Self {
Action::Seeding(action)
}
}
trait Component<T> {
fn handle_action(
&mut self,
action: T,
tx: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>>;
}
#[derive(Serialize, Deserialize, Default)]
pub struct SeedingComponent {
pub input: String,
#[serde(skip)]
pub included_publications: Vec<Publication>,
}
impl Component<SeedingAction> for SeedingComponent {
fn handle_action(
&mut self,
action: SeedingAction,
action_tx: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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<Publication>,
/// Local component copy of the pending publications list
#[serde(skip)]
pub pending_publications: Vec<Publication>,
}
impl SnowballingComponent {
fn next_list_item(
list_state: &mut ListState,
publications: &Vec<Publication>,
) {
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<Publication>,
) {
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<SnowballingAction> for SnowballingComponent {
fn handle_action(
&mut self,
action: SnowballingAction,
action_tx: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>> {
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<Action>,
}
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<Action>,
) -> Result<(), SendError<Action>> {
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<Action>> {
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<Action>> {
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(())
}
}