Finish basic ui layout
This commit is contained in:
commit
65936ea106
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/target
|
||||
3053
Cargo.lock
generated
Normal file
3053
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "rust-snowballing"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ammonia = "4.1.2"
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
crossterm = "0.29.0"
|
||||
ratatui = "0.30.0"
|
||||
reqwest = { version = "0.12.28", features = ["json"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
textwrap = "0.16.2"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
1
rustfmt.toml
Normal file
1
rustfmt.toml
Normal file
@ -0,0 +1 @@
|
||||
max_width = 80
|
||||
304
src/gui.rs
Normal file
304
src/gui.rs
Normal file
@ -0,0 +1,304 @@
|
||||
use ratatui::{
|
||||
crossterm::event::{self, Event, KeyCode},
|
||||
prelude::*,
|
||||
widgets::{Block, Borders, List, ListItem},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
|
||||
use crate::snowballing::{Publication, reconstruct_abstract};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
enum ActivePane {
|
||||
#[default]
|
||||
IncludedPublications,
|
||||
PendingPublications,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
enum SnowballingStep {
|
||||
#[default]
|
||||
Forward,
|
||||
Backward,
|
||||
}
|
||||
|
||||
impl ToString for SnowballingStep {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
SnowballingStep::Forward => String::from("forward"),
|
||||
SnowballingStep::Backward => String::from("backward"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct App {
|
||||
/// List of Publications that have been conclusively included
|
||||
pub included_publications: Vec<Publication>,
|
||||
/// List of Publications pending screening
|
||||
pub pending_publications: Vec<Publication>,
|
||||
/// List of Publications that have been conclusively excluded
|
||||
pub excluded_publications: Vec<Publication>,
|
||||
/// Index of the selected item in the included publications list (UI)
|
||||
pub included_selected_idx: usize,
|
||||
/// Index of the selected item in the pending publications list (UI)
|
||||
pub pending_selected_idx: usize,
|
||||
pub active_pane: ActivePane,
|
||||
pub snowballing_iteration: usize,
|
||||
pub snowballing_step: SnowballingStep,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn handle_key(&mut self, key: KeyCode) {
|
||||
match key {
|
||||
KeyCode::Char('q') => {
|
||||
self.should_quit = true;
|
||||
}
|
||||
KeyCode::Char('h') => {
|
||||
self.active_pane = ActivePane::IncludedPublications;
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
self.active_pane = ActivePane::PendingPublications;
|
||||
}
|
||||
KeyCode::Char('k') => match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
if self.included_selected_idx > 0 {
|
||||
self.included_selected_idx -= 1;
|
||||
}
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
if self.pending_selected_idx > 0 {
|
||||
self.pending_selected_idx -= 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
KeyCode::Char('j') => match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
if self.included_selected_idx
|
||||
< self.included_publications.len().saturating_sub(1)
|
||||
{
|
||||
self.included_selected_idx += 1;
|
||||
}
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
if self.pending_selected_idx
|
||||
< self.pending_publications.len().saturating_sub(1)
|
||||
{
|
||||
self.pending_selected_idx += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut app: App,
|
||||
) -> io::Result<App>
|
||||
where
|
||||
io::Error: From<B::Error>,
|
||||
{
|
||||
loop {
|
||||
terminal.draw(|f| ui(f, &app))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key.code);
|
||||
if app.should_quit {
|
||||
return Ok(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ui(f: &mut Frame, app: &App) {
|
||||
// Root layout
|
||||
|
||||
let panes = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
||||
.split(f.area());
|
||||
|
||||
// Left pane
|
||||
|
||||
let left_pane = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(10), Constraint::Length(2)])
|
||||
.split(panes[0]);
|
||||
|
||||
let included_pubs: Vec<ListItem> = app
|
||||
.included_publications
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| {
|
||||
let style = if idx == app.included_selected_idx
|
||||
&& matches!(app.active_pane, ActivePane::IncludedPublications)
|
||||
{
|
||||
Style::default().bg(Color::Rgb(50, 50, 50))
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut author_str = item
|
||||
.authorships
|
||||
.first()
|
||||
.map(|authorship| authorship.raw_author_name.clone())
|
||||
.expect(
|
||||
"Papers are required to always have at least one author",
|
||||
);
|
||||
|
||||
if item.authorships.len() > 1 {
|
||||
author_str.push_str(" et al.");
|
||||
}
|
||||
|
||||
ListItem::new(vec![
|
||||
Line::from(vec![
|
||||
Span::raw(
|
||||
item.display_name.clone().unwrap_or("".to_string()),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" ({})",
|
||||
item.publication_year
|
||||
.map_or("".to_string(), |val| val.to_string())
|
||||
),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
author_str,
|
||||
Style::default().fg(Color::Blue),
|
||||
)),
|
||||
])
|
||||
.style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let num_included_pubs = included_pubs.len();
|
||||
let included_pub_list = List::new(included_pubs)
|
||||
.block(
|
||||
Block::default()
|
||||
.title_top(Line::from("Included Publications").centered())
|
||||
.title_top(
|
||||
Line::from(Span::styled(
|
||||
format!("{} entries", num_included_pubs),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))
|
||||
.right_aligned(),
|
||||
)
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.style(Style::default());
|
||||
|
||||
f.render_widget(included_pub_list, left_pane[0]);
|
||||
|
||||
let status = vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Iteration: "),
|
||||
Span::styled(
|
||||
app.snowballing_iteration.to_string(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw("Step: "),
|
||||
Span::styled(
|
||||
app.snowballing_step.to_string(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]),
|
||||
];
|
||||
|
||||
let status_widget =
|
||||
ratatui::widgets::Paragraph::new(status).style(Style::default());
|
||||
f.render_widget(status_widget, left_pane[1]);
|
||||
|
||||
// Right pane
|
||||
|
||||
let pending_pubs: Vec<ListItem> = app
|
||||
.pending_publications
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, item)| {
|
||||
let style = if idx == app.pending_selected_idx
|
||||
&& matches!(app.active_pane, ActivePane::PendingPublications)
|
||||
{
|
||||
Style::default().bg(Color::Rgb(50, 50, 50))
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
|
||||
let mut author_str = item
|
||||
.authorships
|
||||
.first()
|
||||
.map(|authorship| authorship.raw_author_name.clone())
|
||||
.expect(
|
||||
"Papers are required to always have at least one author",
|
||||
);
|
||||
|
||||
if item.authorships.len() > 1 {
|
||||
author_str.push_str(" et al.");
|
||||
}
|
||||
|
||||
let mut lines = vec![
|
||||
Line::from(vec![
|
||||
Span::raw(
|
||||
item.display_name.clone().unwrap_or("".to_string()),
|
||||
),
|
||||
Span::styled(
|
||||
format!(
|
||||
" ({})",
|
||||
item.publication_year
|
||||
.map_or("".to_string(), |val| val.to_string())
|
||||
),
|
||||
Style::default().fg(Color::Yellow),
|
||||
),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
author_str,
|
||||
Style::default().fg(Color::Blue),
|
||||
)),
|
||||
];
|
||||
|
||||
let available_width = panes[1].width.saturating_sub(4) as usize; // subtract for borders/padding
|
||||
|
||||
let abstract_text = item
|
||||
.abstract_inverted_index
|
||||
.clone()
|
||||
.map_or("[No abstact available]".to_string(), |content| {
|
||||
reconstruct_abstract(&content)
|
||||
});
|
||||
let wrapped = textwrap::fill(
|
||||
&abstract_text,
|
||||
available_width.saturating_sub(2),
|
||||
);
|
||||
for wrapped_line in wrapped.lines() {
|
||||
lines.push(Line::from(format!(" {}", wrapped_line)));
|
||||
}
|
||||
|
||||
ListItem::new(lines).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let num_pending_pubs = pending_pubs.len();
|
||||
let pengind_pub_list = List::new(pending_pubs)
|
||||
.block(
|
||||
Block::default()
|
||||
.title_top(
|
||||
Line::from("Publications Pending Screening").centered(),
|
||||
)
|
||||
.title_top(
|
||||
Line::from(Span::styled(
|
||||
format!("{} entries", num_pending_pubs),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))
|
||||
.right_aligned(),
|
||||
)
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.style(Style::default());
|
||||
|
||||
f.render_widget(pengind_pub_list, panes[1]);
|
||||
}
|
||||
123
src/main.rs
Normal file
123
src/main.rs
Normal file
@ -0,0 +1,123 @@
|
||||
use std::io;
|
||||
|
||||
use ratatui::Terminal;
|
||||
use ratatui::crossterm::{
|
||||
execute,
|
||||
terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
|
||||
enable_raw_mode,
|
||||
},
|
||||
};
|
||||
use ratatui::prelude::CrosstermBackend;
|
||||
use serde_json;
|
||||
|
||||
mod gui;
|
||||
use crate::gui::{App, run_app};
|
||||
use crate::snowballing::get_citing_papers;
|
||||
mod snowballing;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "Brittling")]
|
||||
#[command(about = "A tool to perform snowballing for literature studies", long_about = None)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
savefile: String,
|
||||
}
|
||||
|
||||
fn load_savefile(filename: &String) -> Result<App, serde_json::Error> {
|
||||
match std::fs::read_to_string(filename) {
|
||||
Ok(content) => {
|
||||
let mut app: App = serde_json::from_str(&content)?;
|
||||
app.should_quit = false;
|
||||
Ok(app)
|
||||
}
|
||||
Err(_) => {
|
||||
let app = App::default();
|
||||
if let Ok(serialized) = serde_json::to_string_pretty(&app) {
|
||||
let _ = std::fs::write(filename, serialized);
|
||||
}
|
||||
Ok(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Error {
|
||||
IOError(std::io::Error),
|
||||
JsonError(serde_json::Error),
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Error::IOError(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Error::JsonError(value)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> io::Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
// <Init terminal>
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
// </Init terminal>
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
let mut err: Option<Error> = None;
|
||||
|
||||
let app = load_savefile(&args.savefile);
|
||||
if let Ok(app) = app {
|
||||
let result = run_app(&mut terminal, app);
|
||||
|
||||
match result {
|
||||
Ok(app) => match serde_json::to_string_pretty(&app) {
|
||||
Ok(serialized) => std::fs::write(args.savefile, serialized)?,
|
||||
Err(ser_err) => err = Some(ser_err.into()),
|
||||
},
|
||||
Err(app_err) => {
|
||||
err = Some(app_err.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
err = Some(app.err().unwrap().into());
|
||||
}
|
||||
|
||||
// <Deinit terminal>
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
terminal.show_cursor()?;
|
||||
// </Deinit terminal>
|
||||
|
||||
if let Some(err) = err {
|
||||
eprintln!("{:?}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// #[tokio::main]
|
||||
// async fn main() -> Result<(), reqwest::Error> {
|
||||
// let publications = get_citing_papers("w2963127785", "an.tsouchlos@gmail.com").await?;
|
||||
//
|
||||
// let app = App {
|
||||
// pending_publications: publications,
|
||||
// ..Default::default()
|
||||
// };
|
||||
//
|
||||
// if let Ok(serialized) = serde_json::to_string_pretty(&app) {
|
||||
// std::fs::write("temp.json", serialized)
|
||||
// .expect("We can't really deal with io errors ourselves");
|
||||
// }
|
||||
//
|
||||
// Ok(())
|
||||
// }
|
||||
70
src/snowballing.rs
Normal file
70
src/snowballing.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use ammonia::Builder;
|
||||
use reqwest::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Authorship {
|
||||
pub author_position: String,
|
||||
pub raw_author_name: String,
|
||||
}
|
||||
|
||||
// TODO: Handle duplicates by having vectors of ids
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Publication {
|
||||
pub id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub authorships: Vec<Authorship>,
|
||||
pub publication_year: Option<u32>,
|
||||
pub abstract_inverted_index: Option<HashMap<String, Vec<u32>>>,
|
||||
pub referenced_works: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct OpenAlexResponse {
|
||||
pub results: Vec<Publication>,
|
||||
}
|
||||
|
||||
pub fn reconstruct_abstract(inverted_index: &HashMap<String, Vec<u32>>) -> String {
|
||||
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
|
||||
|
||||
for (word, positions) in inverted_index {
|
||||
for &pos in positions {
|
||||
words_with_pos.push((pos, word));
|
||||
}
|
||||
}
|
||||
|
||||
words_with_pos.sort_by_key(|k| k.0);
|
||||
|
||||
let unsanitized = words_with_pos
|
||||
.into_iter()
|
||||
.map(|(_, word)| word.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let cleaner = Builder::empty();
|
||||
let sanitized = cleaner.clean(&unsanitized).to_string();
|
||||
sanitized.replace("\u{a0}", " ").trim().to_string()
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user