Compare commits

...

24 Commits

Author SHA1 Message Date
59b4ad5f2c Brittling -> Brittle 2026-01-02 01:38:11 +02:00
bc0f71956b Change name; Add first version of deduplication; Fix typo 2026-01-02 01:34:07 +02:00
0276bfe515 Implement fetching citing works 2026-01-01 06:08:03 +02:00
143e177b8c Implement fetching references with future streams 2026-01-01 05:33:32 +02:00
0ba4e21ae3 Reorder imports 2025-12-31 18:24:42 +02:00
221f75e976 Modify procedural macro to allow more flexible function signatures; Refactor code 2025-12-31 18:21:38 +02:00
498cb43390 Write procedural macro to reduce boiler plate for actions 2025-12-31 18:07:13 +02:00
a168c00ee7 Clean up/fix error handling in main 2025-12-31 16:32:48 +02:00
2e942a9b31 Add App::fetch function; Fix macros 2025-12-31 03:01:19 +02:00
2e51ae8064 Implement snowballing tab controls 2025-12-31 02:46:14 +02:00
8c11630801 Code cleanup 2025-12-31 02:37:16 +02:00
dd2d19ee13 Move stuff to app/common.rs 2025-12-31 02:03:51 +02:00
b496edf404 Refactor directory structure; Fix warnings 2025-12-31 01:57:19 +02:00
3806865ae4 Implement proper multithreading 2025-12-31 01:38:23 +02:00
e0047b8fdc Rename app 2025-12-30 03:31:17 +02:00
33b3fe59db Code cleanup 2025-12-30 03:06:21 +02:00
ac89e93b7a Add done status message after backwards snowballing step 2025-12-30 02:58:11 +02:00
c1882e50df Implement status messages 2025-12-30 02:35:35 +02:00
890cb0be5e Add statusline; Log errors 2025-12-30 02:10:13 +02:00
4901a2897f Start implementing snowballing functionality; add logging; properly sanitize abstracts 2025-12-30 01:50:49 +02:00
14f503a554 Fix crash on empty savefile 2025-12-30 00:15:58 +02:00
4e46184f37 Implement seeding tab layout 2025-12-30 00:13:35 +02:00
e30b22199f Fix showing included publications in pending list 2025-12-29 23:56:06 +02:00
1dfd440524 Implement save on quit 2025-12-29 23:33:07 +02:00
14 changed files with 1615 additions and 428 deletions

239
Cargo.lock generated
View File

@@ -149,6 +149,37 @@ dependencies = [
"generic-array",
]
[[package]]
name = "brittle"
version = "0.1.0"
dependencies = [
"ammonia",
"brittle_macros",
"clap",
"crossterm",
"env_logger",
"futures",
"html-escape",
"log",
"open",
"ratatui",
"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"
@@ -229,7 +260,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -359,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]]
@@ -383,7 +414,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -394,7 +425,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -431,7 +462,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -452,7 +483,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -494,6 +525,29 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "env_filter"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -610,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"
@@ -617,6 +686,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -625,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"
@@ -643,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]]
@@ -724,6 +828,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html-escape"
version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
dependencies = [
"utf8-width",
]
[[package]]
name = "html5ever"
version = "0.35.0"
@@ -991,7 +1104,7 @@ dependencies = [
"indoc",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1002,9 +1115,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
[[package]]
name = "iri-string"
version = "0.7.9"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
dependencies = [
"memchr",
"serde",
@@ -1059,6 +1172,30 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jiff"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a87d9b8105c23642f50cbbae03d1f75d8422c5cb98ce7ee9271f7ff7505be6b8"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b787bebb543f8969132630c51fd0afab173a86c6abae56ff3b9e5e3e3f9f6e58"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.112",
]
[[package]]
name = "js-sys"
version = "0.3.83"
@@ -1190,7 +1327,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1298,7 +1435,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1365,7 +1502,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1460,7 +1597,7 @@ dependencies = [
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1513,7 +1650,7 @@ dependencies = [
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -1549,6 +1686,15 @@ version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -1786,22 +1932,6 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rust-snowballing"
version = "0.1.0"
dependencies = [
"ammonia",
"clap",
"crossterm",
"open",
"ratatui",
"reqwest",
"serde",
"serde_json",
"textwrap",
"tokio",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -1940,7 +2070,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2062,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"
@@ -2111,7 +2250,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2133,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",
@@ -2159,7 +2298,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2307,7 +2446,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2318,7 +2457,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2377,7 +2516,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
@@ -2495,6 +2634,12 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
[[package]]
name = "unicode-ident"
version = "1.0.22"
@@ -2554,6 +2699,12 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-width"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -2668,7 +2819,7 @@ dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
"wasm-bindgen-shared",
]
@@ -3019,7 +3170,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
"synstructure",
]
@@ -3040,7 +3191,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
"synstructure",
]
@@ -3080,11 +3231,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
"syn 2.0.112",
]
[[package]]
name = "zmij"
version = "1.0.1"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5858cd3a46fff31e77adea2935e357e3a2538d870741617bfb7c943e218fee6"
checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06"

View File

@@ -1,5 +1,8 @@
[workspace]
members = [".", "macros"]
[package]
name = "rust-snowballing"
name = "brittle"
version = "0.1.0"
edition = "2024"
@@ -7,10 +10,17 @@ edition = "2024"
ammonia = "4.1.2"
clap = { version = "4.5.53", features = ["derive"] }
crossterm = "0.29.0"
env_logger = "0.11.8"
html-escape = "0.2.13"
log = "0.4.29"
open = "5.3.3"
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
View 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
View 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()
}

View File

@@ -1,194 +1,347 @@
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;
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};
#[derive(Serialize, Deserialize, Default, PartialEq)]
pub enum ActivePane {
IncludedPublications,
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum StatusMessage {
Info(String),
Warning(String),
Error(String),
}
impl Default for StatusMessage {
fn default() -> Self {
StatusMessage::Info("".to_string())
}
}
#[derive(Serialize, Deserialize, Default, PartialEq, Copy, Clone)]
pub enum Tab {
#[default]
PendingPublications,
Seeding,
Snowballing,
}
#[derive(Serialize, Deserialize, Default)]
pub enum SnowballingStep {
#[default]
Forward,
Backward,
pub struct AppState {
// UI state
pub seeding: SeedingComponent,
pub snowballing: SnowballingComponent,
pub current_tab: Tab,
#[serde(skip)]
pub status_message: StatusMessage,
// Internal state
pub history: SnowballingHistory,
}
impl ToString for SnowballingStep {
fn to_string(&self) -> String {
match self {
SnowballingStep::Forward => String::from("forward"),
SnowballingStep::Backward => String::from("backward"),
}
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();
}
}
#[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 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
#[serde(skip)]
pub active_pane: ActivePane,
pub snowballing_iteration: usize,
pub snowballing_step: SnowballingStep,
#[serde(skip)]
pub state: AppState,
pub should_quit: bool,
}
// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars)
// TODO: Implement moving through steps and iterations (populating pending papers)
// TODO: Implement input of seed papers using IDs
// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars)
// TODO: Implement possibility of pushing excluded papers back into pending
// TODO: Implement export of included papers as csv for keywording with a spreadsheet
// TODO: Implement export of included papers into zotero (Use RIS format somehow)
#[component(GlobalAction)]
impl App {
pub fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') => {
self.should_quit = true;
#[action]
fn quit(&mut self) {
self.should_quit = true;
}
#[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)
}
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();
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);
}
});
}
#[action]
fn clear_stat_msg(&mut self) {
self.state.status_message = StatusMessage::Info("".to_string());
}
#[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(());
}
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());
}
Err(e) => {
let _ = status_error!(action_tx, "{}", e.to_string());
}
}
});
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());
}
}
Err(err) => {
let _ = status_error!(
action_tx,
"Error loading citing works: {}",
err
);
}
}
ActivePane::PendingPublications => {
if let Some(idx) = self.pending_list_state.selected() {
open::that(&self.pending_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());
}
}
Err(err) => {
let _ = status_error!(
action_tx,
"Error loading reference: {}",
err
);
}
}
},
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() - 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() - 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() - 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() - 1
} else {
i - 1
}
}
None => 0,
};
self.pending_list_state.select(Some(i));
}
},
KeyCode::Char('h') => {
self.active_pane = ActivePane::IncludedPublications;
if let None = self.included_list_state.selected() {
self.included_list_state.select(Some(0));
}
}
KeyCode::Char('l') => {
self.active_pane = ActivePane::PendingPublications;
let _ = status_info!(action_tx, "Done fetching references");
});
if let None = self.pending_list_state.selected() {
self.pending_list_state.select(Some(0));
}
Ok(())
}
#[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
View 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
View 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
View 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
View 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,
);
}
}
}
}

View File

@@ -1,10 +1,11 @@
use std::{error::Error, io, time::Duration};
use std::{error::Error, io};
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,
@@ -13,9 +14,9 @@ use ratatui::{
},
};
use crate::{app::App, ui};
use crate::app::{AppState, run::run_app};
pub fn run(app: App) -> Result<(), 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();
@@ -24,7 +25,7 @@ pub fn run(app: App) -> Result<(), Box<dyn Error>> {
let mut terminal = Terminal::new(backend)?;
// create app and run it
let app_result = run_app(&mut terminal, app);
let app_result = run_app(&mut terminal, app_state).await;
// restore terminal
disable_raw_mode()?;
@@ -35,51 +36,10 @@ pub fn run(app: App) -> Result<(), Box<dyn Error>> {
)?;
terminal.show_cursor()?;
if let Err(err) = app_result {
if let Err(err) = &app_result {
error!("{err:?}");
println!("{err:?}");
}
Ok(())
Ok(app_result?)
}
// TODO: Implement save on quit
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
) -> io::Result<()>
where
io::Error: From<B::Error>,
{
loop {
terminal.draw(|frame| ui::draw(frame, &mut app))?;
if event::poll(Duration::from_millis(10))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key.code);
}
}
if app.should_quit {
return Ok(());
}
}
}
// 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);
// }
// }
// }
// }

387
src/literature.rs Normal file
View 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()
}

View File

@@ -1,62 +1,76 @@
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(())
// }
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)
}
}
}
use clap::Parser;
mod crossterm;
use std::error::Error;
use log::error;
use serde_json;
use std::{env, error::Error, fs::OpenOptions};
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: &AppState,
filename: &String,
) -> 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(())
}
#[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)]
savefile: String,
#[arg(short, long, default_value = "/tmp/snowballing.log")]
logfile: String,
}
fn main() -> Result<(), Box<dyn Error>> {
#[tokio::main]
async fn main() {
let args = Args::parse();
let app = load_savefile(&args.savefile)?;
crate::crossterm::run(app)?;
if env::var("RUST_LOG").is_err() {
unsafe { env::set_var("RUST_LOG", "info") }
}
Ok(())
env_logger::Builder::from_default_env()
.format_module_path(false)
.target(env_logger::Target::Pipe(Box::new(
OpenOptions::new()
.create(true)
.append(true)
.open(&args.logfile)
.unwrap(),
)))
.init();
if let Err(e) = run(&args).await {
error!("Application error: {}", e);
print!("{e:?}");
std::process::exit(1);
}
}

View File

@@ -1,96 +0,0 @@
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>,
}
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 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()
})
}
}
#[derive(Serialize, Deserialize, Debug)]
pub struct OpenAlexResponse {
pub results: Vec<Publication>,
}
// 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)
}

156
src/ui.rs
View File

@@ -1,23 +1,94 @@
use crate::{
app::{ActivePane, App},
snowballing::Publication,
app::{AppState, StatusMessage, Tab, snowballing::ActivePane},
literature::Publication,
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
pub fn draw(f: &mut Frame, app: &mut 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 AppState) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.split(f.area());
draw_left_pane(f, app, chunks[0]);
draw_right_pane(f, app, chunks[1]);
// Included publication list
let items = create_publication_item_list(
&app.seeding.included_publications,
None,
chunks[0].width.saturating_sub(4) as usize,
false,
);
let list = List::new(items).block(
Block::default()
.title_top(Line::from("Included Publications").centered())
.title_top(
Line::from(Span::styled(
format!(
"{} entries",
app.seeding.included_publications.len()
),
Style::default().fg(Color::Yellow),
))
.right_aligned(),
)
.borders(Borders::ALL),
);
f.render_stateful_widget(
list,
chunks[0],
&mut ListState::default().with_selected(
match app.seeding.included_publications.len() {
0 => None,
_ => Some(app.seeding.included_publications.len() - 1),
},
),
);
// Text entry
let input = Paragraph::new(app.seeding.input.as_str())
.block(Block::bordered().title("Input"));
f.render_widget(input, chunks[1]);
// Status line
draw_status_line(f, app, chunks[2]);
}
fn draw_snowballing_tab(f: &mut Frame, app: &mut AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(2), Constraint::Length(1)])
.split(f.area());
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[0]);
draw_left_pane(f, app, content_chunks[0]);
draw_right_pane(f, app, content_chunks[1]);
draw_status_line(f, app, chunks[1]);
}
fn format_title<'a>(
@@ -173,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)])
@@ -182,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
},
@@ -197,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(),
@@ -205,39 +279,41 @@ 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 status
// Snowballing progress
let status = vec![
let progress = vec![
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),
),
]),
];
let status_widget =
ratatui::widgets::Paragraph::new(status).style(Style::default());
frame.render_widget(status_widget, chunks[1]);
let progress_widget =
ratatui::widgets::Paragraph::new(progress).style(Style::default());
frame.render_widget(progress_widget, chunks[1]);
}
fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
// Included Publication list
fn draw_right_pane(frame: &mut Frame, app: &mut AppState, area: Rect) {
let items = create_publication_item_list(
&app.included_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
},
@@ -250,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(),
@@ -258,5 +337,24 @@ 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: &AppState, area: Rect) {
let line = Paragraph::new(Line::from(match app.status_message.clone() {
StatusMessage::Info(s) => Span::raw(s),
StatusMessage::Warning(s) => {
Span::styled(s, Style::default().fg(Color::Yellow))
}
StatusMessage::Error(s) => {
Span::styled(s, Style::default().fg(Color::Red))
}
}))
.style(Style::default().bg(Color::Rgb(60, 56, 54)));
frame.render_widget(line, area);
}