595 lines
19 KiB
Rust
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(())
|
|
}
|
|
}
|