From b496edf404f1faccf751e4f1f642c47add5e4c31 Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Wed, 31 Dec 2025 01:57:19 +0200 Subject: [PATCH] Refactor directory structure; Fix warnings --- src/app.rs | 328 ++------------------------ src/app/common.rs | 0 src/app/seeding.rs | 47 ++++ src/app/snowballing.rs | 189 +++++++++++++++ src/crossterm.rs | 1 - src/{snowballing.rs => literature.rs} | 50 ++-- src/main.rs | 10 +- src/ui.rs | 4 +- 8 files changed, 289 insertions(+), 340 deletions(-) create mode 100644 src/app/common.rs create mode 100644 src/app/seeding.rs create mode 100644 src/app/snowballing.rs rename src/{snowballing.rs => literature.rs} (86%) diff --git a/src/app.rs b/src/app.rs index 3ce1647..05b6126 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,24 +1,22 @@ -use std::time::Duration; +pub mod common; +pub mod seeding; +pub mod snowballing; -use ratatui::{crossterm::event::KeyCode, widgets::ListState}; +use log::{error, info, warn}; +use ratatui::crossterm::event::KeyCode; use serde::{Deserialize, Serialize}; +use std::time::Duration; use tokio::{ sync::mpsc::{self, error::SendError}, time::sleep, }; -use crate::snowballing::{ - Publication, SnowballingHistory, SnowballingStep, get_publication_by_id, +use crate::literature::{ + Publication, SnowballingHistory, get_publication_by_id, }; -use log::{error, info, warn}; - -#[derive(Serialize, Deserialize, Default, PartialEq)] -pub enum ActivePane { - IncludedPublications, - #[default] - PendingPublications, -} +use seeding::{SeedingAction, SeedingComponent}; +use snowballing::{SnowballingAction, SnowballingComponent}; #[derive(Serialize, Deserialize, Clone, Debug)] pub enum StatusMessage { @@ -33,42 +31,6 @@ impl Default for StatusMessage { } } -#[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] @@ -76,27 +38,8 @@ pub enum Tab { 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), @@ -112,6 +55,14 @@ pub enum Action { Global(GlobalAction), } +pub trait Component { + fn handle_action( + &mut self, + action: T, + tx: &mpsc::UnboundedSender, + ) -> Result<(), SendError>; +} + impl From for Action { fn from(action: GlobalAction) -> Self { Action::Global(action) @@ -130,175 +81,6 @@ impl From for 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 @@ -327,6 +109,7 @@ pub struct App { pub action_tx: &'static mpsc::UnboundedSender, } +#[allow(unused_macros)] macro_rules! status_info { ($action_tx:expr, $text:expr, $($args:expr)*) => { $action_tx.send( @@ -335,6 +118,7 @@ macro_rules! status_info { ) }; } +#[allow(unused_macros)] macro_rules! status_warn { ($action_tx:expr, $text:expr, $($args:expr)*) => { $action_tx.send( @@ -343,6 +127,7 @@ macro_rules! status_warn { ) }; } +#[allow(unused_macros)] macro_rules! status_error { ($action_tx:expr, $text:expr $(, $args:expr)*) => { $action_tx.send( @@ -403,76 +188,6 @@ impl App { 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 @@ -543,7 +258,6 @@ impl App { .push(publ.clone()); Ok(()) } - _ => Ok(()), } } diff --git a/src/app/common.rs b/src/app/common.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/app/seeding.rs b/src/app/seeding.rs new file mode 100644 index 0000000..a436424 --- /dev/null +++ b/src/app/seeding.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::{self, error::SendError}; + +use crate::{ + app::{Action, Component}, + literature::Publication, +}; + +#[derive(Clone, Debug)] +pub enum SeedingAction { + EnterChar(char), + EnterBackspace, + ClearInput, +} + +#[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, + _: &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(()) + } + } + } +} diff --git a/src/app/snowballing.rs b/src/app/snowballing.rs new file mode 100644 index 0000000..38c2de6 --- /dev/null +++ b/src/app/snowballing.rs @@ -0,0 +1,189 @@ +use ratatui::widgets::ListState; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::SendError; + +use crate::app::{Action, Component}; +use crate::literature::Publication; + +#[derive(Serialize, Deserialize, Default, PartialEq)] +pub enum ActivePane { + IncludedPublications, + #[default] + PendingPublications, +} + +#[derive(Clone, Debug)] +pub enum SnowballingAction { + SelectLeftPane, + SelectRightPane, + SearchForSelected, + NextItem, + PrevItem, + ShowIncludedPublication(Publication), + ShowPendingPublication(Publication), +} + +#[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)] +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, + _: &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(()), + } + } +} diff --git a/src/crossterm.rs b/src/crossterm.rs index 6219fc5..337ef0c 100644 --- a/src/crossterm.rs +++ b/src/crossterm.rs @@ -1,6 +1,5 @@ use std::{error::Error, io, time::Duration}; -use crossterm::event::KeyCode; use log::error; use ratatui::{ Terminal, diff --git a/src/snowballing.rs b/src/literature.rs similarity index 86% rename from src/snowballing.rs rename to src/literature.rs index 9d2cfa8..f812417 100644 --- a/src/snowballing.rs +++ b/src/literature.rs @@ -136,10 +136,10 @@ impl Publication { } } -#[derive(Serialize, Deserialize, Debug)] -pub struct OpenAlexResponse { - pub results: Vec, -} +// #[derive(Serialize, Deserialize, Debug)] +// pub struct OpenAlexResponse { +// pub results: Vec, +// } pub async fn get_publication_by_id( api_link: &str, @@ -159,24 +159,24 @@ pub async fn get_publication_by_id( Ok(response) } -// TODO: Get all papers, not just the first page -pub async fn get_citing_papers( - target_id: &str, - email: &str, -) -> Result, Error> { - let url = format!( - "https://api.openalex.org/works?filter=cites:{}&mailto={}", - target_id, email - ); - - let client = reqwest::Client::new(); - let response = client - .get(url) - .header("User-Agent", "Rust-OpenAlex-Client/1.0") - .send() - .await? - .json::() - .await?; - - Ok(response.results) -} +// // TODO: Get all papers, not just the first page +// pub async fn get_citing_papers( +// target_id: &str, +// email: &str, +// ) -> Result, Error> { +// let url = format!( +// "https://api.openalex.org/works?filter=cites:{}&mailto={}", +// target_id, email +// ); +// +// let client = reqwest::Client::new(); +// let response = client +// .get(url) +// .header("User-Agent", "Rust-OpenAlex-Client/1.0") +// .send() +// .await? +// .json::() +// .await?; +// +// Ok(response.results) +// } diff --git a/src/main.rs b/src/main.rs index db475e2..5f65abf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ +mod app; +mod literature; +mod ui; + use log::error; use serde_json; -use tokio::sync::mpsc; -mod app; -mod ui; -use crate::app::{App, AppState, StatusMessage}; -mod snowballing; +use crate::app::AppState; // use crate::snowballing::get_citing_papers; // #[tokio::main] diff --git a/src/ui.rs b/src/ui.rs index a7cb9e3..348b0ec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,6 @@ use crate::{ - app::{ActivePane, App, AppState, StatusMessage, Tab}, - snowballing::Publication, + app::{AppState, StatusMessage, Tab, snowballing::ActivePane}, + literature::Publication, }; use ratatui::{ Frame,