use crate::{Result, err};
use chrono::{Duration, NaiveDate, Utc};
use quick_xml::Reader;
use quick_xml::events::Event;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Paper {
pub id: String,
pub title: String,
pub abstract_text: String,
pub authors: Vec<String>,
pub published_date: Option<String>,
pub updated_date: Option<String>,
pub doi: Option<String>,
pub arxiv_id: Option<String>,
pub url: String,
pub categories: Vec<String>,
pub extracted_methods: Vec<String>,
pub source_provider: String,
}
#[derive(Debug, Clone, Default)]
pub struct PaperQuery {
pub query: Option<String>,
pub category: Option<String>,
pub since: Option<String>,
pub from: Option<NaiveDate>,
pub to: Option<NaiveDate>,
}
pub fn query_string(args: &PaperQuery) -> Result<String> {
let mut parts = Vec::new();
if let Some(query) = &args.query {
parts.push(format!("all:{}", query));
}
if let Some(category) = &args.category {
parts.push(format!("cat:{}", category));
}
if parts.is_empty() {
return Err(err("scan needs --query, --category, or both"));
}
let (from, to) = date_range(args)?;
if let (Some(from), Some(to)) = (from, to) {
parts.push(format!(
"submittedDate:[{}0000 TO {}2359]",
from.format("%Y%m%d"),
to.format("%Y%m%d")
));
}
Ok(parts.join(" AND "))
}
pub fn date_range(args: &PaperQuery) -> Result<(Option<NaiveDate>, Option<NaiveDate>)> {
if let Some(since) = &args.since {
let days = since
.strip_suffix('d')
.ok_or_else(|| err("--since only supports Nd, e.g. 30d"))?
.parse::<i64>()?;
let today = Utc::now().date_naive();
return Ok((Some(today - Duration::days(days)), Some(today)));
}
Ok((args.from, args.to))
}
pub fn search_url(query: &str, limit: usize) -> String {
format!(
"https://export.arxiv.org/api/query?search_query={}&start=0&max_results={}&sortBy=submittedDate&sortOrder=descending",
urlencoding::encode(query),
limit
)
}
pub async fn search(query: &str, limit: usize) -> Result<Vec<Paper>> {
let client = reqwest::Client::builder()
.user_agent("paper-gap/0.1 (local on-demand CLI; contact: unknown)")
.timeout(std::time::Duration::from_secs(20))
.build()?;
let response = client.get(search_url(query, limit)).send().await?;
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
return Err(err(
"arXiv rate limit reached; wait a few minutes and retry with a smaller --limit",
));
}
let body = response.error_for_status()?.text().await?;
parse(&body)
}
pub fn parse(xml: &str) -> Result<Vec<Paper>> {
let mut reader = Reader::from_str(xml);
reader.config_mut().trim_text(true);
let mut papers = Vec::new();
let mut current: Option<Paper> = None;
let mut field = String::new();
let mut in_entry = false;
let mut author_name = false;
loop {
match reader.read_event()? {
Event::Start(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if name == "entry" {
in_entry = true;
current = Some(Paper {
id: String::new(),
title: String::new(),
abstract_text: String::new(),
authors: Vec::new(),
published_date: None,
updated_date: None,
doi: None,
arxiv_id: None,
url: String::new(),
categories: Vec::new(),
extracted_methods: Vec::new(),
source_provider: "arxiv".into(),
});
} else if in_entry && name == "category" {
if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
{
if let Some(p) = &mut current {
p.categories
.push(String::from_utf8_lossy(&term.value).to_string());
}
}
} else {
author_name = in_entry && name == "name";
field = name;
}
}
Event::Empty(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if in_entry && name == "category" {
if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
{
if let Some(p) = &mut current {
p.categories
.push(String::from_utf8_lossy(&term.value).to_string());
}
}
}
}
Event::Text(e) => {
if let Some(p) = &mut current {
let text = e.unescape()?.to_string();
match field.as_str() {
"id" => {
p.id = text.clone();
p.url = text.clone();
p.arxiv_id = text.rsplit('/').next().map(|s| s.to_string());
}
"title" => p.title.push_str(clean(&text).as_str()),
"summary" => p.abstract_text.push_str(clean(&text).as_str()),
"published" => p.published_date = Some(text),
"updated" => p.updated_date = Some(text),
"doi" | "arxiv:doi" => p.doi = Some(text),
"name" if author_name => p.authors.push(text),
_ => {}
}
}
}
Event::End(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
if name == "entry" {
if let Some(mut p) = current.take() {
p.extracted_methods =
extract_acronyms(&format!("{} {}", p.title, p.abstract_text));
papers.push(p);
}
in_entry = false;
}
field.clear();
author_name = false;
}
Event::Eof => break,
_ => {}
}
}
Ok(papers)
}
pub fn clean(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn extract_acronyms(text: &str) -> Vec<String> {
let mut out = Vec::new();
for word in text.split(|c: char| !c.is_alphanumeric() && c != '-') {
if word.len() > 1
&& word.len() <= 12
&& word.chars().any(|c| c.is_ascii_uppercase())
&& word
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
&& !out.iter().any(|w| w == word)
{
out.push(word.to_string());
}
}
out
}