1use crate::{Result, err, provider};
2use chrono::{Duration, NaiveDate, Utc};
3use quick_xml::Reader;
4use quick_xml::events::Event;
5
6pub use crate::paper::Paper;
7
8#[derive(Debug, Clone, Default)]
9pub struct PaperQuery {
10 pub query: Option<String>,
11 pub category: Option<String>,
12 pub since: Option<String>,
13 pub from: Option<NaiveDate>,
14 pub to: Option<NaiveDate>,
15}
16
17pub fn query_string(args: &PaperQuery) -> Result<String> {
18 let mut parts = Vec::new();
19 if let Some(query) = &args.query {
20 parts.push(format!("all:{}", query));
21 }
22 if let Some(category) = &args.category {
23 parts.push(format!("cat:{}", category));
24 }
25 if parts.is_empty() {
26 return Err(err("scan needs --query, --category, or both"));
27 }
28 let (from, to) = date_range(args)?;
29 if from.is_some() || to.is_some() {
30 let from = from
31 .map(|date| format!("{}0000", date.format("%Y%m%d")))
32 .unwrap_or_else(|| "*".into());
33 let to = to
34 .map(|date| format!("{}2359", date.format("%Y%m%d")))
35 .unwrap_or_else(|| "*".into());
36 parts.push(format!("submittedDate:[{from} TO {to}]"));
37 }
38 Ok(parts.join(" AND "))
39}
40
41pub fn date_range(args: &PaperQuery) -> Result<(Option<NaiveDate>, Option<NaiveDate>)> {
42 if args.since.is_some() && (args.from.is_some() || args.to.is_some()) {
43 return Err(err("--since cannot be combined with --from or --to"));
44 }
45 if let Some(since) = &args.since {
46 let days = since
47 .strip_suffix('d')
48 .ok_or_else(|| err("--since only supports Nd, e.g. 30d"))?
49 .parse::<u64>()?;
50 let days = i64::try_from(days).map_err(|_| err("--since is too large"))?;
51 let duration = Duration::try_days(days).ok_or_else(|| err("--since is too large"))?;
52 let today = Utc::now().date_naive();
53 let from = today
54 .checked_sub_signed(duration)
55 .ok_or_else(|| err("--since is too large"))?;
56 return Ok((Some(from), Some(today)));
57 }
58 if args.from.zip(args.to).is_some_and(|(from, to)| from > to) {
59 return Err(err("--from must not be after --to"));
60 }
61 Ok((args.from, args.to))
62}
63
64pub fn search_url(query: &str, limit: usize) -> String {
65 format!(
66 "https://export.arxiv.org/api/query?search_query={}&start=0&max_results={}&sortBy=submittedDate&sortOrder=descending",
67 urlencoding::encode(query),
68 limit
69 )
70}
71
72pub async fn search(query: &str, limit: usize) -> Result<Vec<Paper>> {
73 let client = reqwest::Client::builder()
74 .user_agent("paper-gap/0.1 (local on-demand CLI; contact: unknown)")
75 .timeout(std::time::Duration::from_secs(20))
76 .build()?;
77 let response = provider::send_with_retry(client.get(search_url(query, limit))).await?;
78 let body = response.error_for_status()?.text().await?;
79 parse(&body)
80}
81
82pub fn parse(xml: &str) -> Result<Vec<Paper>> {
83 let mut reader = Reader::from_str(xml);
84 reader.config_mut().trim_text(true);
85 let mut papers = Vec::new();
86 let mut current: Option<Paper> = None;
87 let mut field = String::new();
88 let mut in_entry = false;
89 let mut author_name = false;
90 loop {
91 match reader.read_event()? {
92 Event::Start(e) => {
93 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
94 if name == "entry" {
95 in_entry = true;
96 current = Some(Paper {
97 id: String::new(),
98 title: String::new(),
99 abstract_text: String::new(),
100 authors: Vec::new(),
101 published_date: None,
102 updated_date: None,
103 doi: None,
104 arxiv_id: None,
105 url: String::new(),
106 categories: Vec::new(),
107 extracted_methods: Vec::new(),
108 source_provider: "arxiv".into(),
109 });
110 } else if in_entry && name == "category" {
111 if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
112 {
113 if let Some(p) = &mut current {
114 p.categories
115 .push(String::from_utf8_lossy(&term.value).to_string());
116 }
117 }
118 } else {
119 author_name = in_entry && name == "name";
120 field = name;
121 }
122 }
123 Event::Empty(e) => {
124 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
125 if in_entry && name == "category" {
126 if let Some(term) = e.attributes().flatten().find(|a| a.key.as_ref() == b"term")
127 {
128 if let Some(p) = &mut current {
129 p.categories
130 .push(String::from_utf8_lossy(&term.value).to_string());
131 }
132 }
133 }
134 }
135 Event::Text(e) => {
136 if let Some(p) = &mut current {
137 let text = e.unescape()?.to_string();
138 match field.as_str() {
139 "id" => {
140 p.id = text.clone();
141 p.url = text.clone();
142 p.arxiv_id = text.rsplit('/').next().map(|s| s.to_string());
143 }
144 "title" => p.title.push_str(clean(&text).as_str()),
145 "summary" => p.abstract_text.push_str(clean(&text).as_str()),
146 "published" => p.published_date = Some(text),
147 "updated" => p.updated_date = Some(text),
148 "doi" | "arxiv:doi" => p.doi = Some(text),
149 "name" if author_name => p.authors.push(text),
150 _ => {}
151 }
152 }
153 }
154 Event::End(e) => {
155 let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
156 if name == "entry" {
157 if let Some(mut p) = current.take() {
158 p.extracted_methods =
159 extract_acronyms(&format!("{} {}", p.title, p.abstract_text));
160 papers.push(p);
161 }
162 in_entry = false;
163 }
164 field.clear();
165 author_name = false;
166 }
167 Event::Eof => break,
168 _ => {}
169 }
170 }
171 Ok(papers)
172}
173
174pub fn clean(s: &str) -> String {
175 s.split_whitespace().collect::<Vec<_>>().join(" ")
176}
177
178pub fn extract_acronyms(text: &str) -> Vec<String> {
179 let mut out = Vec::new();
180 for word in text.split(|c: char| !c.is_alphanumeric() && c != '-') {
181 if word.len() > 1
182 && word.len() <= 12
183 && word.chars().any(|c| c.is_ascii_uppercase())
184 && word
185 .chars()
186 .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
187 && !out.iter().any(|w| w == word)
188 {
189 out.push(word.to_string());
190 }
191 }
192 out
193}