Compare commits
14 Commits
e0047b8fdc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59b4ad5f2c | |||
| bc0f71956b | |||
| 0276bfe515 | |||
| 143e177b8c | |||
| 0ba4e21ae3 | |||
| 221f75e976 | |||
| 498cb43390 | |||
| a168c00ee7 | |||
| 2e942a9b31 | |||
| 2e51ae8064 | |||
| 8c11630801 | |||
| dd2d19ee13 | |||
| b496edf404 | |||
| 3806865ae4 |
122
Cargo.lock
generated
122
Cargo.lock
generated
@@ -150,13 +150,15 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brittling"
|
||||
name = "brittle"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"brittle_macros",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"html-escape",
|
||||
"log",
|
||||
"open",
|
||||
@@ -164,11 +166,20 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_cell",
|
||||
"textwrap",
|
||||
"tokio",
|
||||
"unicode-general-category",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brittle_macros"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
@@ -249,7 +260,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -379,7 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -403,7 +414,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -414,7 +425,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -451,7 +462,7 @@ dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -472,7 +483,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -653,6 +664,21 @@ dependencies = [
|
||||
"new_debug_unreachable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.31"
|
||||
@@ -660,6 +686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -668,6 +695,34 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
@@ -686,10 +741,16 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1043,7 +1104,7 @@ dependencies = [
|
||||
"indoc",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1132,7 +1193,7 @@ checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1266,7 +1327,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1374,7 +1435,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1441,7 +1502,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1536,7 +1597,7 @@ dependencies = [
|
||||
"pest_meta",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1589,7 +1650,7 @@ dependencies = [
|
||||
"phf_shared",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2009,7 +2070,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2131,6 +2192,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "static_cell"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "string_cache"
|
||||
version = "0.8.9"
|
||||
@@ -2180,7 +2250,7 @@ dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2202,9 +2272,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.111"
|
||||
version = "2.0.112"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
|
||||
checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -2228,7 +2298,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2376,7 +2446,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2387,7 +2457,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2446,7 +2516,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2749,7 +2819,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -3100,7 +3170,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -3121,7 +3191,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -3161,7 +3231,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.111",
|
||||
"syn 2.0.112",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
[workspace]
|
||||
members = [".", "macros"]
|
||||
|
||||
[package]
|
||||
name = "brittling"
|
||||
name = "brittle"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -15,6 +18,9 @@ ratatui = "0.30.0"
|
||||
reqwest = { version = "0.12.28", features = ["json"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.148"
|
||||
static_cell = "2.1.1"
|
||||
textwrap = "0.16.2"
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
unicode-general-category = "1.1.0"
|
||||
brittle_macros = { path = "macros" }
|
||||
futures = "0.3.31"
|
||||
|
||||
12
macros/Cargo.toml
Normal file
12
macros/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "brittle_macros"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
quote = "1.0.42"
|
||||
syn = "2.0.112"
|
||||
|
||||
140
macros/src/lib.rs
Normal file
140
macros/src/lib.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
// TODO: Clean this up
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{FnArg, ImplItem, ItemImpl, ReturnType, Type, parse_macro_input};
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn component(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let mut input = parse_macro_input!(item as ItemImpl);
|
||||
let struct_name = &input.self_ty;
|
||||
|
||||
// Use the attribute argument as the Enum name,
|
||||
// or fallback to the [Struct]Action logic if empty.
|
||||
let enum_name = if !attr.is_empty() {
|
||||
format_ident!("{}", attr.to_string().trim())
|
||||
} else {
|
||||
let struct_name_str = quote!(#struct_name).to_string().replace(" ", "");
|
||||
format_ident!("{}Action", struct_name_str)
|
||||
};
|
||||
|
||||
let mut enum_variants = Vec::new();
|
||||
let mut match_arms = Vec::new();
|
||||
|
||||
for item in &mut input.items {
|
||||
if let ImplItem::Fn(method) = item {
|
||||
let has_action = method
|
||||
.attrs
|
||||
.iter()
|
||||
.any(|attr| attr.path().is_ident("action"));
|
||||
|
||||
if has_action {
|
||||
method.attrs.retain(|attr| !attr.path().is_ident("action"));
|
||||
|
||||
let method_name = &method.sig.ident;
|
||||
let variant_name = format_ident!(
|
||||
"{}",
|
||||
snake_to_pascal(&method_name.to_string())
|
||||
);
|
||||
|
||||
let returns_result = match &method.sig.output {
|
||||
ReturnType::Default => false,
|
||||
ReturnType::Type(_, ty) => {
|
||||
quote!(#ty).to_string().contains("Result")
|
||||
}
|
||||
};
|
||||
|
||||
let mut variant_fields = Vec::new();
|
||||
let mut call_args = Vec::new();
|
||||
let mut variant_arg_names = Vec::new();
|
||||
|
||||
// Inspect arguments to decide what goes into the Enum and what is injected
|
||||
for arg in method.sig.inputs.iter().skip(1) {
|
||||
// Skip 'self'
|
||||
if let FnArg::Typed(pat_type) = arg {
|
||||
if is_sender_type(&pat_type.ty) {
|
||||
// Inject the 'tx' from handle_action, don't add to Enum
|
||||
call_args.push(quote!(tx));
|
||||
} else {
|
||||
// This is a data argument, add to Enum
|
||||
let ty = &pat_type.ty;
|
||||
variant_fields.push(quote!(#ty));
|
||||
|
||||
let arg_id = format_ident!(
|
||||
"arg_{}",
|
||||
variant_arg_names.len()
|
||||
);
|
||||
variant_arg_names.push(arg_id.clone());
|
||||
call_args.push(quote!(#arg_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Enum Variant
|
||||
if variant_fields.is_empty() {
|
||||
enum_variants.push(quote!(#variant_name));
|
||||
} else {
|
||||
enum_variants
|
||||
.push(quote!(#variant_name(#(#variant_fields),*)));
|
||||
}
|
||||
|
||||
// Generate Match Arm
|
||||
let pattern = if variant_arg_names.is_empty() {
|
||||
quote!(#enum_name::#variant_name)
|
||||
} else {
|
||||
quote!(#enum_name::#variant_name(#(#variant_arg_names),*))
|
||||
};
|
||||
|
||||
let call = if returns_result {
|
||||
quote!(self.#method_name(#(#call_args),*))
|
||||
} else {
|
||||
quote!({ self.#method_name(#(#call_args),*); ::core::result::Result::Ok(()) })
|
||||
};
|
||||
|
||||
match_arms.push(quote!(#pattern => #call));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = quote! {
|
||||
#input
|
||||
|
||||
#[derive(::core::clone::Clone, ::core::fmt::Debug)]
|
||||
pub enum #enum_name {
|
||||
#(#enum_variants),*
|
||||
}
|
||||
|
||||
impl crate::app::common::Component<#enum_name> for #struct_name {
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: #enum_name,
|
||||
tx: &'static ::tokio::sync::mpsc::UnboundedSender<crate::app::Action>,
|
||||
) -> ::core::result::Result<(), ::tokio::sync::mpsc::error::SendError<crate::app::Action>> {
|
||||
match action {
|
||||
#(#match_arms),*
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
||||
|
||||
fn is_sender_type(ty: &Type) -> bool {
|
||||
let s = quote!(#ty).to_string();
|
||||
s.contains("UnboundedSender")
|
||||
}
|
||||
|
||||
fn snake_to_pascal(s: &str) -> String {
|
||||
s.split('_')
|
||||
.map(|word| {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(f) => {
|
||||
f.to_uppercase().collect::<String>() + chars.as_str()
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
594
src/app.rs
594
src/app.rs
@@ -1,32 +1,31 @@
|
||||
use ratatui::{crossterm::event::KeyCode, widgets::ListState};
|
||||
pub mod common;
|
||||
pub mod run;
|
||||
pub mod seeding;
|
||||
pub mod snowballing;
|
||||
|
||||
use futures::StreamExt;
|
||||
use log::{error, info, warn};
|
||||
use ratatui::crossterm::event::KeyCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedSender, error::SendError},
|
||||
time::sleep,
|
||||
};
|
||||
|
||||
use crate::snowballing::{Publication, get_publication_by_id};
|
||||
use crate::{
|
||||
app::{run::Action, snowballing::ActivePane},
|
||||
literature::{
|
||||
Publication, SnowballingHistory, get_citing_works_stream,
|
||||
get_publication_by_id, get_references_stream,
|
||||
},
|
||||
status_error, status_info,
|
||||
};
|
||||
use brittle_macros::component;
|
||||
use seeding::{SeedingAction, SeedingComponent};
|
||||
use snowballing::{SnowballingAction, SnowballingComponent};
|
||||
|
||||
use log::warn;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum ActivePane {
|
||||
IncludedPublications,
|
||||
#[default]
|
||||
PendingPublications,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum ActiveTab {
|
||||
#[default]
|
||||
Seeding,
|
||||
Snowballing,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub enum SnowballingStep {
|
||||
#[default]
|
||||
Backward,
|
||||
Forward,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum StatusMessage {
|
||||
Info(String),
|
||||
Warning(String),
|
||||
@@ -39,86 +38,37 @@ impl Default for StatusMessage {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for SnowballingStep {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
SnowballingStep::Forward => String::from("forward"),
|
||||
SnowballingStep::Backward => String::from("backward"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(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>,
|
||||
|
||||
/// UI state: included publications list
|
||||
#[serde(with = "liststate_serde")]
|
||||
pub included_list_state: ListState,
|
||||
|
||||
/// UI state: pending publications list
|
||||
#[serde(with = "liststate_serde")]
|
||||
pub pending_list_state: ListState,
|
||||
|
||||
/// UI state: active pane
|
||||
pub active_pane: ActivePane,
|
||||
|
||||
/// UI state: active window
|
||||
pub active_tab: ActiveTab,
|
||||
|
||||
pub seeding_input: String,
|
||||
|
||||
pub snowballing_iteration: usize,
|
||||
|
||||
pub snowballing_step: SnowballingStep,
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
#[serde(skip)]
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -127,229 +77,271 @@ pub struct App {
|
||||
// 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)
|
||||
// TODO: Implement proper multithreading
|
||||
// TODO: Log everything relevant
|
||||
#[component(GlobalAction)]
|
||||
impl App {
|
||||
pub async fn add_seed_paper(&mut self, api_link: &String) {
|
||||
let publ =
|
||||
get_publication_by_id(api_link, "an.tsouchlos@gmail.com").await;
|
||||
#[action]
|
||||
fn quit(&mut self) {
|
||||
self.should_quit = true;
|
||||
}
|
||||
|
||||
match publ {
|
||||
Ok(publ) => self.included_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
|
||||
)));
|
||||
#[action]
|
||||
fn next_tab(&mut self) {
|
||||
self.state.current_tab = match self.state.current_tab {
|
||||
Tab::Seeding => Tab::Snowballing,
|
||||
Tab::Snowballing => Tab::Seeding,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Have status messages always last the same amount of time
|
||||
#[action]
|
||||
fn show_stat_msg(
|
||||
&mut self,
|
||||
msg: StatusMessage,
|
||||
action_tx: &'static UnboundedSender<Action>,
|
||||
) {
|
||||
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(4000)).await;
|
||||
|
||||
if let Err(err) = action_tx.send(GlobalAction::ClearStatMsg.into())
|
||||
{
|
||||
error!("{}", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn set_status_message(&mut self, s: StatusMessage) {
|
||||
self.status_message = s;
|
||||
#[action]
|
||||
fn clear_stat_msg(&mut self) {
|
||||
self.state.status_message = StatusMessage::Info("".to_string());
|
||||
}
|
||||
|
||||
pub async fn handle_key(&mut self, key: KeyCode) {
|
||||
if KeyCode::Esc == key {
|
||||
self.should_quit = true;
|
||||
return;
|
||||
#[action]
|
||||
fn add_included_pub(&mut self, publ: Publication) {
|
||||
self.state.history.add_included_publication(publ);
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn add_pending_pub(&mut self, publ: Publication) {
|
||||
self.state.history.add_pending_publication(publ);
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn fetch_and_include_seed(
|
||||
&self,
|
||||
link: String,
|
||||
action_tx: &'static UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>> {
|
||||
if !self
|
||||
.state
|
||||
.seeding
|
||||
.input
|
||||
.starts_with("https://openalex.org/")
|
||||
{
|
||||
status_error!(
|
||||
action_tx,
|
||||
"Seed link must start with 'https://openalex.org/'"
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match self.active_tab {
|
||||
ActiveTab::Seeding => match key {
|
||||
KeyCode::Tab => {
|
||||
self.active_tab = ActiveTab::Snowballing;
|
||||
status_info!(
|
||||
action_tx,
|
||||
"Submitting seed link: {}",
|
||||
&self.state.seeding.input
|
||||
)?;
|
||||
|
||||
let api_link = format!(
|
||||
"https://api.openalex.org/{}",
|
||||
link.trim_start_matches("https://openalex.org/")
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let publ = get_publication_by_id(
|
||||
api_link.into(),
|
||||
"an.tsouchlos@gmail.com".to_string().into(),
|
||||
None,
|
||||
);
|
||||
|
||||
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::AddIncludedPub(publ).into());
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.add_seed_paper(&self.seeding_input.clone()).await;
|
||||
self.seeding_input.clear();
|
||||
Err(e) => {
|
||||
let _ = status_error!(action_tx, "{}", e.to_string());
|
||||
}
|
||||
KeyCode::Char(to_insert) => self.seeding_input.push(to_insert),
|
||||
KeyCode::Backspace => {
|
||||
if self.seeding_input.len() > 0 {
|
||||
self.seeding_input
|
||||
.truncate(self.seeding_input.len() - 1);
|
||||
}
|
||||
});
|
||||
|
||||
action_tx.send(SeedingAction::ClearInput.into())
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn fetch_citing_works(
|
||||
&self,
|
||||
action_tx: &'static UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>> {
|
||||
status_info!(action_tx, "Fetching citing works...")?;
|
||||
|
||||
let included = self.state.history.get_all_included();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stream = get_citing_works_stream(
|
||||
included,
|
||||
"an.tsouchlos@gmail.com".to_string(),
|
||||
);
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(vals) => {
|
||||
for val in vals {
|
||||
let _ = action_tx
|
||||
.send(GlobalAction::AddPendingPub(val).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ActiveTab::Snowballing => match key {
|
||||
KeyCode::Char(' ') => {
|
||||
if self.pending_publications.len() > 0 {
|
||||
warn!(
|
||||
"The next snowballing step can only be initiated \
|
||||
after screening all pending publications"
|
||||
Err(err) => {
|
||||
let _ = status_error!(
|
||||
action_tx,
|
||||
"Error loading citing works: {}",
|
||||
err
|
||||
);
|
||||
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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
if let Some(idx) = self.included_list_state.selected() {
|
||||
open::that(&self.included_publications[idx].id)
|
||||
.unwrap();
|
||||
}
|
||||
let _ = status_info!(action_tx, "Done fetching citing works");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn fetch_references(
|
||||
&self,
|
||||
action_tx: &'static UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>> {
|
||||
status_info!(action_tx, "Fetching references...")?;
|
||||
|
||||
let included = self.state.history.get_all_included();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stream = get_references_stream(
|
||||
included,
|
||||
"an.tsouchlos@gmail.com".to_string(),
|
||||
);
|
||||
|
||||
tokio::pin!(stream);
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(vals) => {
|
||||
for val in vals {
|
||||
let _ = action_tx
|
||||
.send(GlobalAction::AddPendingPub(val).into());
|
||||
}
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
if let Some(idx) = self.pending_list_state.selected() {
|
||||
open::that(&self.pending_publications[idx].id)
|
||||
.unwrap();
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = status_error!(
|
||||
action_tx,
|
||||
"Error loading reference: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
},
|
||||
KeyCode::Tab => {
|
||||
self.active_tab = ActiveTab::Seeding;
|
||||
}
|
||||
KeyCode::Char('j') => match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
let i = match self.included_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= self
|
||||
.included_publications
|
||||
.len()
|
||||
.wrapping_sub(1)
|
||||
{
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.included_list_state.select(Some(i));
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
let i = match self.pending_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i >= self
|
||||
.pending_publications
|
||||
.len()
|
||||
.wrapping_sub(1)
|
||||
{
|
||||
0
|
||||
} else {
|
||||
i + 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.pending_list_state.select(Some(i));
|
||||
}
|
||||
},
|
||||
KeyCode::Char('k') => match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
let i = match self.included_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
self.included_publications
|
||||
.len()
|
||||
.wrapping_sub(1)
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.included_list_state.select(Some(i));
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
let i = match self.pending_list_state.selected() {
|
||||
Some(i) => {
|
||||
if i == 0 {
|
||||
self.pending_publications
|
||||
.len()
|
||||
.wrapping_sub(1)
|
||||
} else {
|
||||
i - 1
|
||||
}
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
self.pending_list_state.select(Some(i));
|
||||
}
|
||||
},
|
||||
KeyCode::Char('h') => {
|
||||
self.active_pane = ActivePane::IncludedPublications;
|
||||
}
|
||||
let _ = status_info!(action_tx, "Done fetching references");
|
||||
});
|
||||
|
||||
if let None = self.included_list_state.selected() {
|
||||
self.included_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
KeyCode::Char('l') => {
|
||||
self.active_pane = ActivePane::PendingPublications;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if let None = self.pending_list_state.selected() {
|
||||
self.pending_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
#[action]
|
||||
// TODO: Implement
|
||||
fn remove_selected_pending(&mut self) {
|
||||
if self.state.snowballing.active_pane != ActivePane::PendingPublications
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
#[action]
|
||||
// TODO: Implement
|
||||
fn include_selected_pending(&mut self) {
|
||||
if self.state.snowballing.active_pane != ActivePane::PendingPublications
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(
|
||||
&mut self,
|
||||
key: KeyCode,
|
||||
action_tx: &'static UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>> {
|
||||
match (self.state.current_tab, key) {
|
||||
(_, KeyCode::Esc) => action_tx.send(GlobalAction::Quit.into()),
|
||||
(_, KeyCode::Tab) => action_tx.send(GlobalAction::NextTab.into()),
|
||||
(Tab::Seeding, KeyCode::Char(c)) => {
|
||||
action_tx.send(SeedingAction::EnterChar(c).into())
|
||||
}
|
||||
(Tab::Seeding, KeyCode::Backspace) => {
|
||||
action_tx.send(SeedingAction::EnterBackspace.into())
|
||||
}
|
||||
(Tab::Seeding, KeyCode::Enter) => action_tx.send(
|
||||
GlobalAction::FetchAndIncludeSeed(
|
||||
self.state.seeding.input.clone(),
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
(Tab::Snowballing, KeyCode::Enter) => {
|
||||
action_tx.send(SnowballingAction::Search.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('h')) => {
|
||||
action_tx.send(SnowballingAction::SelectLeftPane.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('l')) => {
|
||||
action_tx.send(SnowballingAction::SelectRightPane.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('j')) => {
|
||||
action_tx.send(SnowballingAction::NextItem.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('k')) => {
|
||||
action_tx.send(SnowballingAction::PrevItem.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char(' ')) => {
|
||||
action_tx.send(GlobalAction::FetchReferences.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('c')) => {
|
||||
action_tx.send(GlobalAction::FetchCitingWorks.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('X')) => {
|
||||
action_tx.send(GlobalAction::RemoveSelectedPending.into())
|
||||
}
|
||||
(Tab::Snowballing, KeyCode::Char('<')) => {
|
||||
action_tx.send(GlobalAction::IncludeSelectedPending.into())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
src/app/common.rs
Normal file
47
src/app/common.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::app::Action;
|
||||
use tokio::sync::mpsc::{self, error::SendError};
|
||||
|
||||
// TODO: Put this somewhere closer to the procedural macro definitions
|
||||
pub trait Component<T> {
|
||||
fn handle_action(
|
||||
&mut self,
|
||||
action: T,
|
||||
tx: &'static mpsc::UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>>;
|
||||
}
|
||||
|
||||
#[allow(unused_macros)]
|
||||
#[macro_export]
|
||||
macro_rules! status_info {
|
||||
($action_tx:expr, $text:expr $(, $args:expr)*) => {
|
||||
$action_tx.send(
|
||||
crate::app::GlobalAction::ShowStatMsg(
|
||||
crate::app::StatusMessage::Info(format!($text, $($args)*)))
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(unused_macros)]
|
||||
#[macro_export]
|
||||
macro_rules! status_warn {
|
||||
($action_tx:expr, $text:expr $(, $args:expr)*) => {
|
||||
$action_tx.send(
|
||||
crate::app::GlobalAction::ShowStatMsg(
|
||||
crate::app::StatusMessage::Warn(format!($text, $($args)*)))
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(unused_macros)]
|
||||
#[macro_export]
|
||||
macro_rules! status_error {
|
||||
($action_tx:expr, $text:expr $(, $args:expr)*) => {
|
||||
$action_tx.send(
|
||||
crate::app::GlobalAction::ShowStatMsg(
|
||||
crate::app::StatusMessage::Error(format!($text $(, $args)*)))
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
}
|
||||
97
src/app/run.rs
Normal file
97
src/app/run.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use std::{error::Error, time::Duration};
|
||||
|
||||
use crossterm::event::{self, Event};
|
||||
use ratatui::{Terminal, prelude::Backend};
|
||||
use static_cell::StaticCell;
|
||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
App, AppState, GlobalAction, common::Component, seeding::SeedingAction,
|
||||
snowballing::SnowballingAction,
|
||||
},
|
||||
ui,
|
||||
};
|
||||
|
||||
static ACTION_QUEUE_TX: StaticCell<UnboundedSender<Action>> = StaticCell::new();
|
||||
static ACTION_QUEUE_RX: StaticCell<UnboundedReceiver<Action>> =
|
||||
StaticCell::new();
|
||||
|
||||
// TODO: Move this somewhere sensible
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Is there a way to completely decouple this from crossterm?
|
||||
pub async fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
app_state: AppState,
|
||||
) -> Result<AppState, Box<dyn Error>>
|
||||
where
|
||||
<B as Backend>::Error: 'static,
|
||||
{
|
||||
let (action_tx, action_rx): (
|
||||
UnboundedSender<Action>,
|
||||
UnboundedReceiver<Action>,
|
||||
) = mpsc::unbounded_channel();
|
||||
|
||||
let action_tx_ref = ACTION_QUEUE_TX.init(action_tx);
|
||||
let action_rx_ref = ACTION_QUEUE_RX.init(action_rx);
|
||||
|
||||
let mut app = App {
|
||||
state: app_state,
|
||||
should_quit: false,
|
||||
};
|
||||
|
||||
loop {
|
||||
app.state.refresh_component_states(); // TODO: Is it a problem to call this every frame?
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app.state))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key.code, action_tx_ref)?;
|
||||
}
|
||||
}
|
||||
|
||||
while let Ok(action) = action_rx_ref.try_recv() {
|
||||
match action {
|
||||
Action::Seeding(seeding_action) => app
|
||||
.state
|
||||
.seeding
|
||||
.handle_action(seeding_action, action_tx_ref),
|
||||
Action::Snowballing(snowballing_action) => app
|
||||
.state
|
||||
.snowballing
|
||||
.handle_action(snowballing_action, action_tx_ref),
|
||||
Action::Global(global_action) => {
|
||||
app.handle_action(global_action, action_tx_ref)
|
||||
}
|
||||
}?;
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
return Ok(app.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/app/seeding.rs
Normal file
44
src/app/seeding.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::mpsc::{UnboundedSender, error::SendError};
|
||||
|
||||
use crate::{
|
||||
app::{GlobalAction, run::Action},
|
||||
literature::Publication,
|
||||
};
|
||||
use brittle_macros::component;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct SeedingComponent {
|
||||
pub input: String,
|
||||
#[serde(skip)]
|
||||
pub included_publications: Vec<Publication>,
|
||||
}
|
||||
|
||||
#[component(SeedingAction)]
|
||||
impl SeedingComponent {
|
||||
#[action]
|
||||
pub fn submit(
|
||||
&mut self,
|
||||
action_tx: &UnboundedSender<Action>,
|
||||
) -> Result<(), SendError<Action>> {
|
||||
action_tx
|
||||
.send(GlobalAction::FetchAndIncludeSeed(self.input.clone()).into())
|
||||
}
|
||||
|
||||
#[action]
|
||||
pub fn clear_input(&mut self) {
|
||||
self.input.clear();
|
||||
}
|
||||
|
||||
#[action]
|
||||
pub fn enter_char(&mut self, c: char) {
|
||||
self.input.push(c)
|
||||
}
|
||||
|
||||
#[action]
|
||||
pub fn enter_backspace(&mut self) {
|
||||
if self.input.len() > 0 {
|
||||
self.input.truncate(self.input.len() - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
170
src/app/snowballing.rs
Normal file
170
src/app/snowballing.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use brittle_macros::component;
|
||||
use ratatui::widgets::ListState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::literature::Publication;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum ActivePane {
|
||||
IncludedPublications,
|
||||
#[default]
|
||||
PendingPublications,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[component(SnowballingAction)]
|
||||
impl SnowballingComponent {
|
||||
fn select_next_item_impl(
|
||||
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 select_prev_item_impl(
|
||||
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));
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn select_left_pane(&mut self) {
|
||||
self.active_pane = ActivePane::IncludedPublications;
|
||||
|
||||
if let None = self.included_list_state.selected() {
|
||||
self.included_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn select_right_pane(&mut self) {
|
||||
self.active_pane = ActivePane::PendingPublications;
|
||||
|
||||
if let None = self.pending_list_state.selected() {
|
||||
self.pending_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn search(&self) {
|
||||
match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
if let Some(idx) = self.included_list_state.selected() {
|
||||
open::that(&self.included_publications[idx].id).unwrap();
|
||||
}
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
if let Some(idx) = self.pending_list_state.selected() {
|
||||
open::that(&self.pending_publications[idx].id).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn next_item(&mut self) {
|
||||
match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
Self::select_next_item_impl(
|
||||
&mut self.included_list_state,
|
||||
&self.included_publications,
|
||||
);
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
Self::select_next_item_impl(
|
||||
&mut self.pending_list_state,
|
||||
&self.pending_publications,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[action]
|
||||
fn prev_item(&mut self) {
|
||||
match self.active_pane {
|
||||
ActivePane::IncludedPublications => {
|
||||
Self::select_prev_item_impl(
|
||||
&mut self.included_list_state,
|
||||
&self.included_publications,
|
||||
);
|
||||
}
|
||||
ActivePane::PendingPublications => {
|
||||
Self::select_prev_item_impl(
|
||||
&mut self.pending_list_state,
|
||||
&self.pending_publications,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::{error::Error, io, time::Duration};
|
||||
use std::{error::Error, io};
|
||||
|
||||
use log::{error};
|
||||
use log::error;
|
||||
use ratatui::{
|
||||
Terminal,
|
||||
backend::{Backend, CrosstermBackend},
|
||||
backend::CrosstermBackend,
|
||||
crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
|
||||
event::{DisableMouseCapture, EnableMouseCapture},
|
||||
execute,
|
||||
terminal::{
|
||||
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
|
||||
@@ -14,9 +14,9 @@ use ratatui::{
|
||||
},
|
||||
};
|
||||
|
||||
use crate::{app::App, ui};
|
||||
use crate::app::{AppState, run::run_app};
|
||||
|
||||
pub async fn run(app: App) -> Result<App, Box<dyn Error>> {
|
||||
pub async fn run(app_state: AppState) -> Result<AppState, Box<dyn Error>> {
|
||||
// setup terminal
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
@@ -25,7 +25,7 @@ pub async fn run(app: App) -> Result<App, Box<dyn Error>> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// create app and run it
|
||||
let app_result = run_app(&mut terminal, app).await;
|
||||
let app_result = run_app(&mut terminal, app_state).await;
|
||||
|
||||
// restore terminal
|
||||
disable_raw_mode()?;
|
||||
@@ -43,25 +43,3 @@ pub async fn run(app: App) -> Result<App, Box<dyn Error>> {
|
||||
|
||||
Ok(app_result?)
|
||||
}
|
||||
|
||||
async fn run_app<B: Backend>(
|
||||
terminal: &mut Terminal<B>,
|
||||
mut app: App,
|
||||
) -> io::Result<App>
|
||||
where
|
||||
io::Error: From<B::Error>,
|
||||
{
|
||||
loop {
|
||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
app.handle_key(key.code).await;
|
||||
}
|
||||
}
|
||||
|
||||
if app.should_quit {
|
||||
return Ok(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
387
src/literature.rs
Normal file
387
src/literature.rs
Normal file
@@ -0,0 +1,387 @@
|
||||
use ammonia::Builder;
|
||||
use futures::{StreamExt, future::BoxFuture, stream};
|
||||
use html_escape::decode_html_entities;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{collections::HashMap, fmt::Display, sync::Arc};
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Authorship {
|
||||
pub author_position: String,
|
||||
pub raw_author_name: String,
|
||||
}
|
||||
|
||||
// TODO: Handle duplicates by having vectors of ids
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
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, Default)]
|
||||
pub enum SnowballingStep {
|
||||
#[default]
|
||||
Backward,
|
||||
Forward,
|
||||
}
|
||||
|
||||
impl ToString for SnowballingStep {
|
||||
fn to_string(&self) -> String {
|
||||
match self {
|
||||
SnowballingStep::Forward => String::from("forward"),
|
||||
SnowballingStep::Backward => String::from("backward"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Only store IDs of excluded publications?
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct SnowballingIteration {
|
||||
pub included_publications: Vec<Publication>,
|
||||
pub excluded_publications: Vec<Publication>,
|
||||
pub step: SnowballingStep,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub struct SnowballingHistory {
|
||||
pub seed: Vec<Publication>,
|
||||
pub current_iteration: SnowballingIteration,
|
||||
pub previous_iterations: Vec<SnowballingIteration>,
|
||||
pub pending_publications: Vec<Publication>,
|
||||
}
|
||||
|
||||
impl SnowballingHistory {
|
||||
// TODO: Make this return references if possible
|
||||
pub fn get_all_included(&self) -> Vec<Publication> {
|
||||
vec![self.current_iteration.included_publications.clone()]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.previous_iterations
|
||||
.iter()
|
||||
.map(|iter| iter.included_publications.clone()),
|
||||
)
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO: Make this return references if possible
|
||||
pub fn get_all_pending(&self) -> Vec<Publication> {
|
||||
self.pending_publications.clone()
|
||||
}
|
||||
|
||||
fn publication_exists(&self, publ: &Publication) -> bool {
|
||||
self.pending_publications
|
||||
.iter()
|
||||
.chain(self.current_iteration.included_publications.iter())
|
||||
.chain(self.current_iteration.excluded_publications.iter())
|
||||
.chain(
|
||||
self.previous_iterations
|
||||
.iter()
|
||||
.flat_map(|p| p.included_publications.iter()),
|
||||
)
|
||||
.chain(
|
||||
self.previous_iterations
|
||||
.iter()
|
||||
.flat_map(|p| p.excluded_publications.iter()),
|
||||
)
|
||||
.any(|p| p.id == publ.id)
|
||||
}
|
||||
|
||||
// TODO: Implement deduplication
|
||||
pub fn add_pending_publication(&mut self, publ: Publication) {
|
||||
if !self.publication_exists(&publ) {
|
||||
self.pending_publications.push(publ);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Implement deduplication
|
||||
pub fn add_included_publication(&mut self, publ: Publication) {
|
||||
if !self.publication_exists(&publ) {
|
||||
self.current_iteration.included_publications.push(publ);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_text(raw_text: &str) -> String {
|
||||
let cleaner = Builder::empty().clean(&raw_text).to_string();
|
||||
let decoded = decode_html_entities(&cleaner);
|
||||
|
||||
let cleaned: String = decoded
|
||||
.chars()
|
||||
.filter(|&c| {
|
||||
let cat = get_general_category(c);
|
||||
!matches!(
|
||||
cat,
|
||||
GeneralCategory::Control
|
||||
| GeneralCategory::Format
|
||||
| GeneralCategory::Surrogate
|
||||
| GeneralCategory::PrivateUse
|
||||
| GeneralCategory::Unassigned
|
||||
) || c.is_whitespace()
|
||||
})
|
||||
.collect();
|
||||
|
||||
cleaned
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.replace("\\n", " ")
|
||||
}
|
||||
|
||||
impl Publication {
|
||||
pub fn get_title(&self) -> Option<String> {
|
||||
self.display_name.clone().map(|s| sanitize_text(&s))
|
||||
}
|
||||
|
||||
pub fn get_year(&self) -> Option<u32> {
|
||||
self.publication_year
|
||||
}
|
||||
|
||||
pub fn get_author_text(&self) -> String {
|
||||
let mut author_str = self
|
||||
.authorships
|
||||
.first()
|
||||
.map(|authorship| authorship.raw_author_name.clone())
|
||||
.expect("Papers are required to always have at least one author");
|
||||
|
||||
if self.authorships.len() > 1 {
|
||||
author_str.push_str(" et al.");
|
||||
}
|
||||
|
||||
author_str
|
||||
}
|
||||
|
||||
pub fn get_abstract(&self) -> Option<String> {
|
||||
self.abstract_inverted_index.clone().map(|content| {
|
||||
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
|
||||
|
||||
for (word, positions) in &content {
|
||||
for pos in positions {
|
||||
words_with_pos.push((*pos, word));
|
||||
}
|
||||
}
|
||||
|
||||
words_with_pos.sort_by_key(|k| k.0);
|
||||
|
||||
let raw_text = words_with_pos
|
||||
.into_iter()
|
||||
.map(|(_, word)| word.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
sanitize_text(&raw_text)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenAlexResponse {
|
||||
results: Vec<Publication>,
|
||||
meta: Meta,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Meta {
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
ApiError(String),
|
||||
}
|
||||
|
||||
impl Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::ApiError(s) => write!(
|
||||
f,
|
||||
"An error occurred while attempting to access the OpenAlex \
|
||||
API: url={}",
|
||||
s
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_publication_by_id(
|
||||
api_link: Arc<String>,
|
||||
email: Arc<String>,
|
||||
client: Option<reqwest::Client>,
|
||||
) -> Result<Publication, Error> {
|
||||
let url = format!("{}&mailto={}", api_link, email);
|
||||
|
||||
let client = client.or(Some(reqwest::Client::new())).unwrap();
|
||||
|
||||
client
|
||||
.get(url.clone())
|
||||
.header("User-Agent", "Rust-OpenAlex-Client/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| Error::ApiError(url.clone()))?
|
||||
.json::<Publication>()
|
||||
.await
|
||||
.map_err(|_| Error::ApiError(url))
|
||||
}
|
||||
|
||||
// TODO: Rename this
|
||||
fn get_cited_works(
|
||||
id: String,
|
||||
email: Arc<String>,
|
||||
client: reqwest::Client,
|
||||
) -> impl futures::Stream<Item = Result<Vec<Publication>, Error>> {
|
||||
let initial_state = Some("*".to_string());
|
||||
|
||||
stream::unfold(initial_state, move |cursor| {
|
||||
let email = email.clone();
|
||||
let client = client.clone();
|
||||
let id = id.clone();
|
||||
|
||||
async move {
|
||||
let current_cursor = cursor?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.openalex.org/works?filter=cited_by:{}&mailto={}&cursor={}",
|
||||
id, email, current_cursor
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Rust-OpenAlex-Client/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| Error::ApiError(url.clone()))
|
||||
.ok()?
|
||||
.json::<OpenAlexResponse>()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let next_cursor = if response.results.is_empty() {
|
||||
None
|
||||
} else {
|
||||
response.meta.next_cursor
|
||||
};
|
||||
|
||||
Some((Ok(response.results), next_cursor))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_references_stream(
|
||||
publications: Vec<Publication>,
|
||||
email: String,
|
||||
) -> impl futures::Stream<Item = Result<Vec<Publication>, Error>> {
|
||||
let email = Arc::new(email);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let mut publication_ids = Vec::<String>::new();
|
||||
let mut referenced_work_urls = Vec::<String>::new();
|
||||
|
||||
for p in &publications {
|
||||
publication_ids
|
||||
.push(p.id.trim_start_matches("https://openalex.org/").to_string());
|
||||
|
||||
referenced_work_urls.append(
|
||||
&mut p
|
||||
.referenced_works
|
||||
.iter()
|
||||
.map(|r| {
|
||||
format!(
|
||||
"https://api.openalex.org/{}",
|
||||
r.trim_start_matches("https://openalex.org/")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>(),
|
||||
);
|
||||
}
|
||||
|
||||
// Get references using the referenced_works field
|
||||
|
||||
let stream1 = stream::iter(referenced_work_urls)
|
||||
.map({
|
||||
let (email, client) = (email.clone(), client.clone());
|
||||
move |url| {
|
||||
let (email, client) = (email.clone(), client.clone());
|
||||
let fut: BoxFuture<'static, Result<Vec<Publication>, Error>> =
|
||||
Box::pin(async move {
|
||||
get_publication_by_id(url.into(), email, Some(client))
|
||||
.await
|
||||
.map(|val| vec![val])
|
||||
});
|
||||
fut
|
||||
}
|
||||
})
|
||||
.buffer_unordered(10);
|
||||
|
||||
// Search for references using API calls
|
||||
|
||||
let stream2 = stream::iter(publication_ids)
|
||||
.map(move |id| get_cited_works(id, email.clone(), client.clone()))
|
||||
.flatten();
|
||||
|
||||
// Combine the two streams
|
||||
stream::select(stream1, stream2)
|
||||
}
|
||||
|
||||
// TODO: Rename this
|
||||
fn get_citing_works(
|
||||
id: String,
|
||||
email: Arc<String>,
|
||||
client: reqwest::Client,
|
||||
) -> impl futures::Stream<Item = Result<Vec<Publication>, Error>> {
|
||||
let initial_state = Some("*".to_string());
|
||||
|
||||
stream::unfold(initial_state, move |cursor| {
|
||||
let email = email.clone();
|
||||
let client = client.clone();
|
||||
let id = id.clone();
|
||||
|
||||
async move {
|
||||
let current_cursor = cursor?;
|
||||
|
||||
let url = format!(
|
||||
"https://api.openalex.org/works?filter=cites:{}&mailto={}&cursor={}",
|
||||
id, email, current_cursor
|
||||
);
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Rust-OpenAlex-Client/1.0")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| Error::ApiError(url.clone()))
|
||||
.ok()?
|
||||
.json::<OpenAlexResponse>()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
let next_cursor = if response.results.is_empty() {
|
||||
None
|
||||
} else {
|
||||
response.meta.next_cursor
|
||||
};
|
||||
|
||||
Some((Ok(response.results), next_cursor))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_citing_works_stream(
|
||||
publications: Vec<Publication>,
|
||||
email: String,
|
||||
) -> impl futures::Stream<Item = Result<Vec<Publication>, Error>> {
|
||||
let email = Arc::new(email);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let ids: Vec<String> = publications
|
||||
.iter()
|
||||
.map(|p| p.id.trim_start_matches("https://openalex.org/").to_string())
|
||||
.collect();
|
||||
|
||||
stream::iter(ids)
|
||||
.map(move |id| get_citing_works(id, email.clone(), client.clone()))
|
||||
.flatten()
|
||||
}
|
||||
95
src/main.rs
95
src/main.rs
@@ -1,64 +1,45 @@
|
||||
use log::{error};
|
||||
use serde_json;
|
||||
|
||||
mod app;
|
||||
mod crossterm;
|
||||
mod literature;
|
||||
mod ui;
|
||||
use crate::app::App;
|
||||
mod snowballing;
|
||||
|
||||
// use crate::snowballing::get_citing_papers;
|
||||
// #[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(())
|
||||
// }
|
||||
use clap::Parser;
|
||||
use log::error;
|
||||
use serde_json;
|
||||
use std::{env, error::Error, fs::OpenOptions};
|
||||
|
||||
fn deserialize_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)
|
||||
}
|
||||
use crate::app::AppState;
|
||||
|
||||
fn deserialize_savefile(filename: &String) -> Result<AppState, Box<dyn Error>> {
|
||||
if !std::fs::exists(filename)? {
|
||||
return Ok(AppState::default());
|
||||
}
|
||||
|
||||
let app_state: AppState =
|
||||
serde_json::from_str::<AppState>(&std::fs::read_to_string(filename)?)?;
|
||||
|
||||
Ok(app_state)
|
||||
}
|
||||
|
||||
fn serialize_savefile(
|
||||
app: &App,
|
||||
app: &AppState,
|
||||
filename: &String,
|
||||
) -> Result<(), serde_json::Error> {
|
||||
if let Ok(serialized) = serde_json::to_string_pretty(&app) {
|
||||
std::fs::write(filename, serialized)
|
||||
.expect("We can't really deal with io errors ourselves");
|
||||
}
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let serialized = serde_json::to_string_pretty(&app)?;
|
||||
Ok(std::fs::write(filename, serialized)?)
|
||||
}
|
||||
|
||||
async fn run(args: &Args) -> Result<(), Box<dyn Error>> {
|
||||
let starting_app_state = deserialize_savefile(&args.savefile)?;
|
||||
let final_app_state = crate::crossterm::run(starting_app_state).await?;
|
||||
|
||||
serialize_savefile(&final_app_state, &args.savefile)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
use clap::Parser;
|
||||
mod crossterm;
|
||||
use std::{env, error::Error, fs::OpenOptions};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "Brittling")]
|
||||
#[command(name = "Brittle")]
|
||||
#[command(about = "A tool to perform snowballing for literature studies", long_about = None)]
|
||||
struct Args {
|
||||
#[arg(short, long)]
|
||||
@@ -87,21 +68,9 @@ async fn main() {
|
||||
)))
|
||||
.init();
|
||||
|
||||
match run(&args).await {
|
||||
Err(e) => {
|
||||
error!("Application error: {}", e);
|
||||
print!("{e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
_ => {}
|
||||
if let Err(e) = run(&args).await {
|
||||
error!("Application error: {}", e);
|
||||
print!("{e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(args: &Args) -> Result<(), Box<dyn Error>> {
|
||||
let starting_app_state = deserialize_savefile(&args.savefile)?;
|
||||
let final_app_state = crate::crossterm::run(starting_app_state).await?;
|
||||
|
||||
serialize_savefile(&final_app_state, &args.savefile)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
use ammonia::Builder;
|
||||
use html_escape::decode_html_entities;
|
||||
use reqwest::Error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Authorship {
|
||||
pub author_position: String,
|
||||
pub raw_author_name: String,
|
||||
}
|
||||
|
||||
// TODO: Handle duplicates by having vectors of ids
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
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>,
|
||||
}
|
||||
|
||||
impl Publication {
|
||||
pub fn get_title(&self) -> Option<String> {
|
||||
self.display_name.clone()
|
||||
}
|
||||
|
||||
pub fn get_year(&self) -> Option<u32> {
|
||||
self.publication_year
|
||||
}
|
||||
|
||||
pub fn get_author_text(&self) -> String {
|
||||
let mut author_str = self
|
||||
.authorships
|
||||
.first()
|
||||
.map(|authorship| authorship.raw_author_name.clone())
|
||||
.expect("Papers are required to always have at least one author");
|
||||
|
||||
if self.authorships.len() > 1 {
|
||||
author_str.push_str(" et al.");
|
||||
}
|
||||
|
||||
author_str
|
||||
}
|
||||
|
||||
pub fn get_abstract(&self) -> Option<String> {
|
||||
self.abstract_inverted_index.clone().map(|content| {
|
||||
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
|
||||
|
||||
for (word, positions) in &content {
|
||||
for pos in positions {
|
||||
words_with_pos.push((*pos, word));
|
||||
}
|
||||
}
|
||||
|
||||
words_with_pos.sort_by_key(|k| k.0);
|
||||
|
||||
let raw_text = words_with_pos
|
||||
.into_iter()
|
||||
.map(|(_, word)| word.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let cleaner = Builder::empty().clean(&raw_text).to_string();
|
||||
let decoded = decode_html_entities(&cleaner);
|
||||
|
||||
let cleaned: String = decoded
|
||||
.chars()
|
||||
.filter(|&c| {
|
||||
let cat = get_general_category(c);
|
||||
!matches!(
|
||||
cat,
|
||||
GeneralCategory::Control
|
||||
| GeneralCategory::Format
|
||||
| GeneralCategory::Surrogate
|
||||
| GeneralCategory::PrivateUse
|
||||
| GeneralCategory::Unassigned
|
||||
) || c.is_whitespace()
|
||||
})
|
||||
.collect();
|
||||
|
||||
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct OpenAlexResponse {
|
||||
pub results: Vec<Publication>,
|
||||
}
|
||||
|
||||
pub async fn get_publication_by_id(
|
||||
api_link: &str,
|
||||
email: &str,
|
||||
) -> Result<Publication, Error> {
|
||||
let url = format!("{}&mailto={}", api_link, email);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.get(url)
|
||||
.header("User-Agent", "Rust-OpenAlex-Client/1.0")
|
||||
.send()
|
||||
.await?
|
||||
.json::<Publication>()
|
||||
.await?;
|
||||
|
||||
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)
|
||||
}
|
||||
73
src/ui.rs
73
src/ui.rs
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
app::{ActivePane, ActiveTab, App, StatusMessage},
|
||||
snowballing::Publication,
|
||||
app::{AppState, StatusMessage, Tab, snowballing::ActivePane},
|
||||
literature::Publication,
|
||||
};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
@@ -10,14 +10,14 @@ use ratatui::{
|
||||
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
|
||||
};
|
||||
|
||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||
match app.active_tab {
|
||||
ActiveTab::Seeding => draw_seeding_tab(f, app),
|
||||
ActiveTab::Snowballing => draw_snowballing_tab(f, app),
|
||||
pub fn draw(f: &mut Frame, app: &mut AppState) {
|
||||
match app.current_tab {
|
||||
Tab::Seeding => draw_seeding_tab(f, app),
|
||||
Tab::Snowballing => draw_snowballing_tab(f, app),
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||
fn draw_seeding_tab(f: &mut Frame, app: &mut AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
@@ -30,7 +30,7 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||
// Included publication list
|
||||
|
||||
let items = create_publication_item_list(
|
||||
&app.included_publications,
|
||||
&app.seeding.included_publications,
|
||||
None,
|
||||
chunks[0].width.saturating_sub(4) as usize,
|
||||
false,
|
||||
@@ -41,7 +41,10 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||
.title_top(Line::from("Included Publications").centered())
|
||||
.title_top(
|
||||
Line::from(Span::styled(
|
||||
format!("{} entries", app.included_publications.len()),
|
||||
format!(
|
||||
"{} entries",
|
||||
app.seeding.included_publications.len()
|
||||
),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))
|
||||
.right_aligned(),
|
||||
@@ -53,16 +56,16 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||
list,
|
||||
chunks[0],
|
||||
&mut ListState::default().with_selected(
|
||||
match app.included_publications.len() {
|
||||
match app.seeding.included_publications.len() {
|
||||
0 => None,
|
||||
_ => Some(app.included_publications.len() - 1),
|
||||
_ => Some(app.seeding.included_publications.len() - 1),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Text entry
|
||||
|
||||
let input = Paragraph::new(app.seeding_input.as_str())
|
||||
let input = Paragraph::new(app.seeding.input.as_str())
|
||||
.block(Block::bordered().title("Input"));
|
||||
|
||||
f.render_widget(input, chunks[1]);
|
||||
@@ -72,7 +75,7 @@ fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||
draw_status_line(f, app, chunks[2]);
|
||||
}
|
||||
|
||||
fn draw_snowballing_tab(f: &mut Frame, app: &mut App) {
|
||||
fn draw_snowballing_tab(f: &mut Frame, app: &mut AppState) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(2), Constraint::Length(1)])
|
||||
@@ -241,7 +244,7 @@ fn create_publication_item_list(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
fn draw_left_pane(frame: &mut Frame, app: &mut AppState, area: Rect) {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(10), Constraint::Length(2)])
|
||||
@@ -250,9 +253,9 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
// Included Publication list
|
||||
|
||||
let items = create_publication_item_list(
|
||||
&app.included_publications,
|
||||
if app.active_pane == ActivePane::IncludedPublications {
|
||||
app.included_list_state.selected()
|
||||
&app.snowballing.included_publications,
|
||||
if app.snowballing.active_pane == ActivePane::IncludedPublications {
|
||||
app.snowballing.included_list_state.selected()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -265,7 +268,10 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.title_top(Line::from("Included Publications").centered())
|
||||
.title_top(
|
||||
Line::from(Span::styled(
|
||||
format!("{} entries", app.included_publications.len()),
|
||||
format!(
|
||||
"{} entries",
|
||||
app.snowballing.included_publications.len()
|
||||
),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))
|
||||
.right_aligned(),
|
||||
@@ -273,7 +279,11 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
|
||||
frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state);
|
||||
frame.render_stateful_widget(
|
||||
list,
|
||||
chunks[0],
|
||||
&mut app.snowballing.included_list_state,
|
||||
);
|
||||
|
||||
// Snowballing progress
|
||||
|
||||
@@ -281,14 +291,14 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
Line::from(vec![
|
||||
Span::raw("Iteration: "),
|
||||
Span::styled(
|
||||
app.snowballing_iteration.to_string(),
|
||||
app.history.current_iteration.step.to_string(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::raw("Step: "),
|
||||
Span::styled(
|
||||
app.snowballing_step.to_string(),
|
||||
app.history.previous_iterations.len().to_string(),
|
||||
Style::default().fg(Color::Cyan),
|
||||
),
|
||||
]),
|
||||
@@ -299,11 +309,11 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
frame.render_widget(progress_widget, chunks[1]);
|
||||
}
|
||||
|
||||
fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
fn draw_right_pane(frame: &mut Frame, app: &mut AppState, area: Rect) {
|
||||
let items = create_publication_item_list(
|
||||
&app.pending_publications,
|
||||
if app.active_pane == ActivePane::PendingPublications {
|
||||
app.pending_list_state.selected()
|
||||
&app.snowballing.pending_publications,
|
||||
if app.snowballing.active_pane == ActivePane::PendingPublications {
|
||||
app.snowballing.pending_list_state.selected()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -316,7 +326,10 @@ fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.title_top(Line::from("Publications Pending Screening").centered())
|
||||
.title_top(
|
||||
Line::from(Span::styled(
|
||||
format!("{} entries", app.pending_publications.len()),
|
||||
format!(
|
||||
"{} entries",
|
||||
app.snowballing.pending_publications.len()
|
||||
),
|
||||
Style::default().fg(Color::Yellow),
|
||||
))
|
||||
.right_aligned(),
|
||||
@@ -324,10 +337,14 @@ fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
||||
.borders(Borders::ALL),
|
||||
);
|
||||
|
||||
frame.render_stateful_widget(list, area, &mut app.pending_list_state);
|
||||
frame.render_stateful_widget(
|
||||
list,
|
||||
area,
|
||||
&mut app.snowballing.pending_list_state,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_status_line(frame: &mut Frame, app: &App, area: Rect) {
|
||||
fn draw_status_line(frame: &mut Frame, app: &AppState, area: Rect) {
|
||||
let line = Paragraph::new(Line::from(match app.status_message.clone() {
|
||||
StatusMessage::Info(s) => Span::raw(s),
|
||||
StatusMessage::Warning(s) => {
|
||||
|
||||
Reference in New Issue
Block a user