Compare commits
10 Commits
db570abd96
...
e0047b8fdc
| Author | SHA1 | Date | |
|---|---|---|---|
| e0047b8fdc | |||
| 33b3fe59db | |||
| ac89e93b7a | |||
| c1882e50df | |||
| 890cb0be5e | |||
| 4901a2897f | |||
| 14f503a554 | |||
| 4e46184f37 | |||
| e30b22199f | |||
| 1dfd440524 |
121
Cargo.lock
generated
121
Cargo.lock
generated
@ -149,6 +149,26 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "brittling"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ammonia",
|
||||||
|
"clap",
|
||||||
|
"crossterm",
|
||||||
|
"env_logger",
|
||||||
|
"html-escape",
|
||||||
|
"log",
|
||||||
|
"open",
|
||||||
|
"ratatui",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"textwrap",
|
||||||
|
"tokio",
|
||||||
|
"unicode-general-category",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@ -494,6 +514,29 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@ -724,6 +767,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
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]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@ -1002,9 +1054,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iri-string"
|
name = "iri-string"
|
||||||
version = "0.7.9"
|
version = "0.7.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
|
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@ -1059,6 +1111,30 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
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.111",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.83"
|
version = "0.3.83"
|
||||||
@ -1549,6 +1625,15 @@ version = "1.13.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
|
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]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -1786,22 +1871,6 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc_version"
|
name = "rustc_version"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -2495,6 +2564,12 @@ version = "0.1.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-general-category"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-ident"
|
name = "unicode-ident"
|
||||||
version = "1.0.22"
|
version = "1.0.22"
|
||||||
@ -2554,6 +2629,12 @@ version = "0.7.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8-width"
|
||||||
|
version = "0.1.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@ -3085,6 +3166,6 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.1"
|
version = "1.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f5858cd3a46fff31e77adea2935e357e3a2538d870741617bfb7c943e218fee6"
|
checksum = "e9747e91771f56fd7893e1164abd78febd14a670ceec257caad15e051de35f06"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rust-snowballing"
|
name = "brittling"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
@ -7,6 +7,9 @@ edition = "2024"
|
|||||||
ammonia = "4.1.2"
|
ammonia = "4.1.2"
|
||||||
clap = { version = "4.5.53", features = ["derive"] }
|
clap = { version = "4.5.53", features = ["derive"] }
|
||||||
crossterm = "0.29.0"
|
crossterm = "0.29.0"
|
||||||
|
env_logger = "0.11.8"
|
||||||
|
html-escape = "0.2.13"
|
||||||
|
log = "0.4.29"
|
||||||
open = "5.3.3"
|
open = "5.3.3"
|
||||||
ratatui = "0.30.0"
|
ratatui = "0.30.0"
|
||||||
reqwest = { version = "0.12.28", features = ["json"] }
|
reqwest = { version = "0.12.28", features = ["json"] }
|
||||||
@ -14,3 +17,4 @@ serde = { version = "1.0.228", features = ["derive"] }
|
|||||||
serde_json = "1.0.148"
|
serde_json = "1.0.148"
|
||||||
textwrap = "0.16.2"
|
textwrap = "0.16.2"
|
||||||
tokio = { version = "1.48.0", features = ["full"] }
|
tokio = { version = "1.48.0", features = ["full"] }
|
||||||
|
unicode-general-category = "1.1.0"
|
||||||
|
|||||||
321
src/app.rs
321
src/app.rs
@ -1,7 +1,9 @@
|
|||||||
use ratatui::{crossterm::event::KeyCode, widgets::ListState};
|
use ratatui::{crossterm::event::KeyCode, widgets::ListState};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::snowballing::Publication;
|
use crate::snowballing::{Publication, get_publication_by_id};
|
||||||
|
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
||||||
pub enum ActivePane {
|
pub enum ActivePane {
|
||||||
@ -10,11 +12,31 @@ pub enum ActivePane {
|
|||||||
PendingPublications,
|
PendingPublications,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub enum ActiveTab {
|
||||||
|
#[default]
|
||||||
|
Seeding,
|
||||||
|
Snowballing,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Default)]
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
pub enum SnowballingStep {
|
pub enum SnowballingStep {
|
||||||
#[default]
|
#[default]
|
||||||
Forward,
|
|
||||||
Backward,
|
Backward,
|
||||||
|
Forward,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
pub enum StatusMessage {
|
||||||
|
Info(String),
|
||||||
|
Warning(String),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StatusMessage {
|
||||||
|
fn default() -> Self {
|
||||||
|
StatusMessage::Info("".to_string())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToString for SnowballingStep {
|
impl ToString for SnowballingStep {
|
||||||
@ -82,113 +104,252 @@ pub struct App {
|
|||||||
pub pending_list_state: ListState,
|
pub pending_list_state: ListState,
|
||||||
|
|
||||||
/// UI state: active pane
|
/// UI state: active pane
|
||||||
#[serde(skip)]
|
|
||||||
pub active_pane: ActivePane,
|
pub active_pane: ActivePane,
|
||||||
|
|
||||||
|
/// UI state: active window
|
||||||
|
pub active_tab: ActiveTab,
|
||||||
|
|
||||||
|
pub seeding_input: String,
|
||||||
|
|
||||||
pub snowballing_iteration: usize,
|
pub snowballing_iteration: usize,
|
||||||
|
|
||||||
pub snowballing_step: SnowballingStep,
|
pub snowballing_step: SnowballingStep,
|
||||||
|
|
||||||
|
#[serde(skip)]
|
||||||
|
pub status_message: StatusMessage,
|
||||||
|
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub should_quit: bool,
|
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 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 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 as csv for keywording with a spreadsheet
|
||||||
// TODO: Implement export of included papers into zotero (Use RIS format somehow)
|
// TODO: Implement export of included papers into zotero (Use RIS format somehow)
|
||||||
|
// TODO: Implement proper multithreading
|
||||||
|
// TODO: Log everything relevant
|
||||||
impl App {
|
impl App {
|
||||||
pub fn handle_key(&mut self, key: KeyCode) {
|
pub async fn add_seed_paper(&mut self, api_link: &String) {
|
||||||
match key {
|
let publ =
|
||||||
KeyCode::Char('q') => {
|
get_publication_by_id(api_link, "an.tsouchlos@gmail.com").await;
|
||||||
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
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
KeyCode::Enter => match self.active_pane {
|
}
|
||||||
ActivePane::IncludedPublications => {
|
}
|
||||||
if let Some(idx) = self.included_list_state.selected() {
|
|
||||||
open::that(&self.included_publications[idx].id)
|
pub fn set_status_message(&mut self, s: StatusMessage) {
|
||||||
.unwrap();
|
self.status_message = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_key(&mut self, key: KeyCode) {
|
||||||
|
if KeyCode::Esc == key {
|
||||||
|
self.should_quit = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.active_tab {
|
||||||
|
ActiveTab::Seeding => match key {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
self.active_tab = ActiveTab::Snowballing;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.add_seed_paper(&self.seeding_input.clone()).await;
|
||||||
|
self.seeding_input.clear();
|
||||||
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ActivePane::PendingPublications => {
|
_ => {}
|
||||||
if let Some(idx) = self.pending_list_state.selected() {
|
},
|
||||||
open::that(&self.pending_publications[idx].id).unwrap();
|
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"
|
||||||
|
);
|
||||||
|
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 {
|
||||||
KeyCode::Char('j') => match self.active_pane {
|
ActivePane::IncludedPublications => {
|
||||||
ActivePane::IncludedPublications => {
|
if let Some(idx) = self.included_list_state.selected() {
|
||||||
let i = match self.included_list_state.selected() {
|
open::that(&self.included_publications[idx].id)
|
||||||
Some(i) => {
|
.unwrap();
|
||||||
if i >= self.included_publications.len() - 1 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
i + 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => 0,
|
}
|
||||||
};
|
ActivePane::PendingPublications => {
|
||||||
self.included_list_state.select(Some(i));
|
if let Some(idx) = self.pending_list_state.selected() {
|
||||||
}
|
open::that(&self.pending_publications[idx].id)
|
||||||
ActivePane::PendingPublications => {
|
.unwrap();
|
||||||
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::Tab => {
|
||||||
|
self.active_tab = ActiveTab::Seeding;
|
||||||
}
|
}
|
||||||
},
|
KeyCode::Char('j') => match self.active_pane {
|
||||||
KeyCode::Char('k') => match self.active_pane {
|
ActivePane::IncludedPublications => {
|
||||||
ActivePane::IncludedPublications => {
|
let i = match self.included_list_state.selected() {
|
||||||
let i = match self.included_list_state.selected() {
|
Some(i) => {
|
||||||
Some(i) => {
|
if i >= self
|
||||||
if i == 0 {
|
.included_publications
|
||||||
self.included_publications.len() - 1
|
.len()
|
||||||
} else {
|
.wrapping_sub(1)
|
||||||
i - 1
|
{
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
None => 0,
|
||||||
None => 0,
|
};
|
||||||
};
|
self.included_list_state.select(Some(i));
|
||||||
self.included_list_state.select(Some(i));
|
}
|
||||||
}
|
ActivePane::PendingPublications => {
|
||||||
ActivePane::PendingPublications => {
|
let i = match self.pending_list_state.selected() {
|
||||||
let i = match self.pending_list_state.selected() {
|
Some(i) => {
|
||||||
Some(i) => {
|
if i >= self
|
||||||
if i == 0 {
|
.pending_publications
|
||||||
self.pending_publications.len() - 1
|
.len()
|
||||||
} else {
|
.wrapping_sub(1)
|
||||||
i - 1
|
{
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
None => 0,
|
||||||
None => 0,
|
};
|
||||||
};
|
self.pending_list_state.select(Some(i));
|
||||||
self.pending_list_state.select(Some(i));
|
}
|
||||||
}
|
},
|
||||||
},
|
KeyCode::Char('k') => match self.active_pane {
|
||||||
KeyCode::Char('h') => {
|
ActivePane::IncludedPublications => {
|
||||||
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;
|
||||||
|
|
||||||
if let None = self.included_list_state.selected() {
|
if let None = self.included_list_state.selected() {
|
||||||
self.included_list_state.select(Some(0));
|
self.included_list_state.select(Some(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
KeyCode::Char('l') => {
|
||||||
KeyCode::Char('l') => {
|
self.active_pane = ActivePane::PendingPublications;
|
||||||
self.active_pane = ActivePane::PendingPublications;
|
|
||||||
|
|
||||||
if let None = self.pending_list_state.selected() {
|
if let None = self.pending_list_state.selected() {
|
||||||
self.pending_list_state.select(Some(0));
|
self.pending_list_state.select(Some(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
_ => {}
|
||||||
_ => {}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
use std::{error::Error, io, time::Duration};
|
use std::{error::Error, io, time::Duration};
|
||||||
|
|
||||||
|
use log::{error};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Terminal,
|
Terminal,
|
||||||
backend::{Backend, CrosstermBackend},
|
backend::{Backend, CrosstermBackend},
|
||||||
@ -15,7 +16,7 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::{app::App, ui};
|
use crate::{app::App, ui};
|
||||||
|
|
||||||
pub fn run(app: App) -> Result<(), Box<dyn Error>> {
|
pub async fn run(app: App) -> Result<App, Box<dyn Error>> {
|
||||||
// setup terminal
|
// setup terminal
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@ -24,7 +25,7 @@ pub fn run(app: App) -> Result<(), Box<dyn Error>> {
|
|||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
// create app and run it
|
// create app and run it
|
||||||
let app_result = run_app(&mut terminal, app);
|
let app_result = run_app(&mut terminal, app).await;
|
||||||
|
|
||||||
// restore terminal
|
// restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
@ -35,51 +36,32 @@ pub fn run(app: App) -> Result<(), Box<dyn Error>> {
|
|||||||
)?;
|
)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
if let Err(err) = app_result {
|
if let Err(err) = &app_result {
|
||||||
|
error!("{err:?}");
|
||||||
println!("{err:?}");
|
println!("{err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(app_result?)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement save on quit
|
async fn run_app<B: Backend>(
|
||||||
fn run_app<B: Backend>(
|
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
mut app: App,
|
mut app: App,
|
||||||
) -> io::Result<()>
|
) -> io::Result<App>
|
||||||
where
|
where
|
||||||
io::Error: From<B::Error>,
|
io::Error: From<B::Error>,
|
||||||
{
|
{
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
terminal.draw(|frame| ui::draw(frame, &mut app))?;
|
||||||
|
|
||||||
if event::poll(Duration::from_millis(10))? {
|
if event::poll(Duration::from_millis(100))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
app.handle_key(key.code);
|
app.handle_key(key.code).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if app.should_quit {
|
if app.should_quit {
|
||||||
return Ok(());
|
return Ok(app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
55
src/main.rs
55
src/main.rs
@ -1,3 +1,4 @@
|
|||||||
|
use log::{error};
|
||||||
use serde_json;
|
use serde_json;
|
||||||
|
|
||||||
mod app;
|
mod app;
|
||||||
@ -23,7 +24,7 @@ mod snowballing;
|
|||||||
// Ok(())
|
// Ok(())
|
||||||
// }
|
// }
|
||||||
|
|
||||||
fn load_savefile(filename: &String) -> Result<App, serde_json::Error> {
|
fn deserialize_savefile(filename: &String) -> Result<App, serde_json::Error> {
|
||||||
match std::fs::read_to_string(filename) {
|
match std::fs::read_to_string(filename) {
|
||||||
Ok(content) => {
|
Ok(content) => {
|
||||||
let mut app: App = serde_json::from_str(&content)?;
|
let mut app: App = serde_json::from_str(&content)?;
|
||||||
@ -40,9 +41,21 @@ fn load_savefile(filename: &String) -> Result<App, serde_json::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_savefile(
|
||||||
|
app: &App,
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
mod crossterm;
|
mod crossterm;
|
||||||
use std::error::Error;
|
use std::{env, error::Error, fs::OpenOptions};
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "Brittling")]
|
#[command(name = "Brittling")]
|
||||||
@ -50,13 +63,45 @@ use std::error::Error;
|
|||||||
struct Args {
|
struct Args {
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
savefile: String,
|
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 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") }
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
match run(&args).await {
|
||||||
|
Err(e) => {
|
||||||
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,18 @@
|
|||||||
use ammonia::Builder;
|
use ammonia::Builder;
|
||||||
|
use html_escape::decode_html_entities;
|
||||||
use reqwest::Error;
|
use reqwest::Error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{collections::HashMap};
|
use std::collections::HashMap;
|
||||||
|
use unicode_general_category::{GeneralCategory, get_general_category};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Authorship {
|
pub struct Authorship {
|
||||||
pub author_position: String,
|
pub author_position: String,
|
||||||
pub raw_author_name: String,
|
pub raw_author_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Handle duplicates by having vectors of ids
|
// TODO: Handle duplicates by having vectors of ids
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Publication {
|
pub struct Publication {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
@ -55,15 +57,31 @@ impl Publication {
|
|||||||
|
|
||||||
words_with_pos.sort_by_key(|k| k.0);
|
words_with_pos.sort_by_key(|k| k.0);
|
||||||
|
|
||||||
let unsanitized = words_with_pos
|
let raw_text = words_with_pos
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(_, word)| word.as_str())
|
.map(|(_, word)| word.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
let cleaner = Builder::empty();
|
let cleaner = Builder::empty().clean(&raw_text).to_string();
|
||||||
let sanitized = cleaner.clean(&unsanitized).to_string();
|
let decoded = decode_html_entities(&cleaner);
|
||||||
sanitized.replace("\u{a0}", " ").trim().to_string()
|
|
||||||
|
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(" ")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -73,6 +91,24 @@ pub struct OpenAlexResponse {
|
|||||||
pub results: Vec<Publication>,
|
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
|
// TODO: Get all papers, not just the first page
|
||||||
pub async fn get_citing_papers(
|
pub async fn get_citing_papers(
|
||||||
target_id: &str,
|
target_id: &str,
|
||||||
|
|||||||
109
src/ui.rs
109
src/ui.rs
@ -1,5 +1,5 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
app::{ActivePane, App},
|
app::{ActivePane, ActiveTab, App, StatusMessage},
|
||||||
snowballing::Publication,
|
snowballing::Publication,
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@ -7,17 +7,85 @@ use ratatui::{
|
|||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style, Stylize},
|
style::{Color, Modifier, Style, Stylize},
|
||||||
text::{Line, Span},
|
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 App) {
|
||||||
|
match app.active_tab {
|
||||||
|
ActiveTab::Seeding => draw_seeding_tab(f, app),
|
||||||
|
ActiveTab::Snowballing => draw_snowballing_tab(f, app),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_seeding_tab(f: &mut Frame, app: &mut App) {
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
.constraints([
|
||||||
|
Constraint::Min(3),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Length(1),
|
||||||
|
])
|
||||||
.split(f.area());
|
.split(f.area());
|
||||||
|
|
||||||
draw_left_pane(f, app, chunks[0]);
|
// Included publication list
|
||||||
draw_right_pane(f, app, chunks[1]);
|
|
||||||
|
let items = create_publication_item_list(
|
||||||
|
&app.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.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.included_publications.len() {
|
||||||
|
0 => None,
|
||||||
|
_ => Some(app.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 App) {
|
||||||
|
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>(
|
fn format_title<'a>(
|
||||||
@ -207,9 +275,9 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
|
|
||||||
frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state);
|
frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state);
|
||||||
|
|
||||||
// Snowballing status
|
// Snowballing progress
|
||||||
|
|
||||||
let status = vec![
|
let progress = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
Span::raw("Iteration: "),
|
Span::raw("Iteration: "),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
@ -226,16 +294,14 @@ fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
let status_widget =
|
let progress_widget =
|
||||||
ratatui::widgets::Paragraph::new(status).style(Style::default());
|
ratatui::widgets::Paragraph::new(progress).style(Style::default());
|
||||||
frame.render_widget(status_widget, chunks[1]);
|
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 App, area: Rect) {
|
||||||
// Included Publication list
|
|
||||||
|
|
||||||
let items = create_publication_item_list(
|
let items = create_publication_item_list(
|
||||||
&app.included_publications,
|
&app.pending_publications,
|
||||||
if app.active_pane == ActivePane::PendingPublications {
|
if app.active_pane == ActivePane::PendingPublications {
|
||||||
app.pending_list_state.selected()
|
app.pending_list_state.selected()
|
||||||
} else {
|
} else {
|
||||||
@ -260,3 +326,18 @@ fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
|
|||||||
|
|
||||||
frame.render_stateful_widget(list, area, &mut app.pending_list_state);
|
frame.render_stateful_widget(list, area, &mut app.pending_list_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_status_line(frame: &mut Frame, app: &App, 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);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user