Refactor directory structure; Fix warnings

This commit is contained in:
Andreas Tsouchlos 2025-12-31 01:57:19 +02:00
parent 3806865ae4
commit b496edf404
8 changed files with 289 additions and 340 deletions

View File

@ -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<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]
@ -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<T> {
fn handle_action(
&mut self,
action: T,
tx: &mpsc::UnboundedSender<Action>,
) -> Result<(), SendError<Action>>;
}
impl From<GlobalAction> for Action {
fn from(action: GlobalAction) -> Self {
Action::Global(action)
@ -130,175 +81,6 @@ impl From<SeedingAction> for 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
@ -327,6 +109,7 @@ pub struct App {
pub action_tx: &'static mpsc::UnboundedSender<Action>,
}
#[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(()),
}
}

0
src/app/common.rs Normal file
View File

47
src/app/seeding.rs Normal file
View File

@ -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<Publication>,
}
impl Component<SeedingAction> for SeedingComponent {
fn handle_action(
&mut self,
action: SeedingAction,
_: &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(())
}
}
}
}

189
src/app/snowballing.rs Normal file
View File

@ -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<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)]
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,
_: &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(()),
}
}
}

View File

@ -1,6 +1,5 @@
use std::{error::Error, io, time::Duration};
use crossterm::event::KeyCode;
use log::error;
use ratatui::{
Terminal,

View File

@ -136,10 +136,10 @@ impl Publication {
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct OpenAlexResponse {
pub results: Vec<Publication>,
}
// #[derive(Serialize, Deserialize, Debug)]
// pub struct OpenAlexResponse {
// pub results: Vec<Publication>,
// }
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<Vec<Publication>, 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::<OpenAlexResponse>()
.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<Vec<Publication>, 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::<OpenAlexResponse>()
// .await?;
//
// Ok(response.results)
// }

View File

@ -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]

View File

@ -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,