Skip to main content

rust_paper/
args.rs

1use std::str::FromStr;
2
3use crate::api::Url;
4use clap::{ArgGroup, Args, Parser, Subcommand};
5
6#[derive(Parser)]
7pub struct Cli {
8    #[clap(subcommand)]
9    pub command: Command,
10}
11
12#[derive(Debug, Subcommand)]
13//#[derive(Debug, Subcommand, Clone)]
14pub enum Command {
15    Sync,
16    Add {
17        #[arg(required = true)]
18        paths: Vec<String>,
19    },
20    Remove {
21        #[arg(required = true)]
22        ids: Vec<String>,
23    },
24    List,
25    Clean,
26    /// Get wallpaper info (supports both local and API lookup)
27    Info {
28        #[arg(required = true)]
29        id: String,
30    },
31    /// Search wallpaper by query or colors
32    Search(SearchArgs),
33    /// Get tag info
34    TagInfo(TagInfoArgs),
35    /// Show user settings
36    UserSettings(UserSettingsArgs),
37    /// Show user collections
38    UserCollections(UserCollectionsArgs),
39}
40
41#[derive(Debug, Args)]
42#[clap(group(
43            ArgGroup::new("search_method")
44                .required(true)
45                .multiple(false)
46                .args(&["query", "colors"]),
47        ))]
48pub struct SearchArgs {
49    /// Download wallpapers to save_location from config (uses Wallhaven ID as filename)
50    #[clap(short = 'd', long, verbatim_doc_comment, help_heading = "DOWNLOAD")]
51    pub download: bool,
52
53    /// Query string
54    ///
55    ///    tagname - search fuzzily for a tag/keyword
56    ///    -tagname - exclude a tag/keyword
57    ///    +tag1 +tag2 - must have tag1 and tag2
58    ///    +tag1 -tag2 - must have tag1 and NOT tag2
59    ///    @username - user uploads
60    ///    id:123 - Exact tag search (can not be combined)
61    ///    type:{png/jpg} - Search for file type (jpg = jpeg)
62    ///    like:wallpaper ID - Find wallpapers with similar tags
63    ///
64    ///    Ex. "anime +city -mountain type:png"
65    #[clap(short = 'q',
66           long,
67           verbatim_doc_comment,
68           help_heading = Some("SEARCH"),
69           //default_value = "",
70           value_parser = clap::value_parser!(SearchQuery),
71    )]
72    query: Option<SearchQuery>,
73
74    /// Categories
75    ///
76    ///    Turn categories on(1) or off(0)
77    ///    (general/anime/people).
78    #[clap(short = 'c',
79           long,
80           verbatim_doc_comment,
81           help_heading = Some("SEARCH PREFERENCES"),
82           //default_value = "111",
83           value_parser = ["100", "101", "110", "111"],
84    )]
85    categories: Option<String>,
86
87    /// Purity
88    ///
89    ///    Turn purities on(1) or off(0)
90    ///    *NSFW requires a valid API key*
91    ///    (sfw/sketchy/nsfw).
92    #[clap(short = 'p',
93           long,
94           verbatim_doc_comment,
95           help_heading = Some("SEARCH PREFERENCES"),
96           //default_value = "100",
97           value_parser = ["100", "101", "110", "111"],
98    )]
99    purity: Option<String>,
100
101    /// Sorting
102    ///
103    #[clap(short = 's',
104           long,
105           verbatim_doc_comment,
106           help_heading = Some("SORTING PREFERENCES"),
107           ignore_case = true,
108           //default_value = "DATE_ADDED",
109           value_parser = ["DATE_ADDED", "RELEVANCE", "RANDOM", "VIEWS", "FAVORITES", "TOPLIST"],
110    )]
111    sorting: Option<String>,
112
113    /// Sorting order
114    ///
115    #[clap(short = 'o',
116           long,
117           verbatim_doc_comment,
118           help_heading = Some("SORTING PREFERENCES"),
119           ignore_case = true,
120           //default_value = "DESC",
121           value_parser = ["ASC", "DESC"],
122    )]
123    order: Option<String>,
124
125    /// Range of search
126    ///
127    ///    Sorting MUST be set to 'TOPLIST'
128    #[clap(short = 't',
129           long,
130           verbatim_doc_comment,
131           help_heading = Some("SORTING PREFERENCES"),
132           ignore_case = true,
133           //default_value = "1M",
134           value_parser = ["1D", "3D", "1W", "1M", "3M", "6M", "1Y"],
135    )]
136    toprange: Option<String>,
137
138    /// Atleast
139    ///
140    ///    Set The minimum resolution allowed
141    ///    Ex. 1920x1080.
142    #[clap(short = 'a',
143           long,
144           verbatim_doc_comment,
145           help_heading = Some("WALLPAPER PREFERENCES"),
146           //default_value = "",
147    )]
148    atleast: Option<String>,
149
150    /// Resolutions
151    ///
152    ///    List of exact wallpaper resolutions
153    ///    Single resolution allowed.
154    #[clap(short = 'r',
155           long,
156           verbatim_doc_comment,
157           help_heading = Some("WALLPAPER PREFERENCES"),
158           //default_value = "1920x1080,1920x1200",
159    )]
160    resolutions: Option<String>,
161
162    /// Ratios
163    ///
164    ///    List of aspect ratios
165    ///    Single ratio allowed.
166    ///
167    ///    Ex. 16x9,16x10
168    #[clap(short = 'R',
169           long,
170           verbatim_doc_comment,
171           help_heading = Some("WALLPAPER PREFERENCES"),
172           //default_value = "16x9,16x10",
173    )]
174    ratios: Option<String>,
175
176    /// Color
177    ///
178    ///    Search by hex color
179    ///    Ex.  --colors 0066cc
180    ///         --colors #333393
181    #[clap(short = 'C',
182           long,
183           verbatim_doc_comment,
184           help_heading = Some("SEARCH"),
185           //required_unless_present_any = ["query"],
186           //conflicts_with = "query",
187           //default_value = "000000",
188           value_parser = valid_color
189    )]
190    colors: Option<String>,
191
192    /// Page
193    ///
194    ///    Select page of results
195    ///    (1..)
196    #[clap(short = 'P',
197           long,
198           verbatim_doc_comment,
199           help_heading = Some("SEARCH PREFERENCES"),
200           //default_value_t = 1,
201           value_parser = clap::value_parser!(u32).range(1..),
202    )]
203    page: Option<u32>,
204
205    /// Seed
206    ///
207    ///     Optional seed for random results
208    ///     [a-zA-Z0-9]{6}
209    #[clap(long,
210           verbatim_doc_comment,
211           help_heading = Some("SEARCH PREFERENCES"),
212           //default_value_t = 1,
213           value_parser = clap::value_parser!(Seed),
214    )]
215    seed: Option<Seed>,
216}
217
218fn valid_color(s: &str) -> Result<String, String> {
219    let s = if s.starts_with("#") { &s[1..] } else { s };
220
221    let valid_hex = s.chars().into_iter().all(|c| c.is_digit(16));
222
223    if valid_hex && s.len() == 6 {
224        return Ok(String::from(s));
225    } else {
226        return Err(String::from(format!("{s} is not a valid hex color")));
227    }
228}
229
230fn valid_wallpaper_id(s: &str) -> Result<String, String> {
231    let valid_format = s
232        .chars()
233        .into_iter()
234        .all(|c| c.is_ascii_digit() || c.is_ascii_alphabetic());
235
236    if valid_format && s.len() == 6 {
237        return Ok(String::from(s));
238    } else {
239        return Err(String::from(format!("{s} is not a valid wallpaper id")));
240    }
241}
242
243#[derive(Debug, Args)]
244pub struct TagInfoArgs {
245    /// ID of tag
246    pub id: i32,
247}
248
249#[derive(Debug, Args)]
250pub struct UserSettingsArgs;
251
252#[derive(Debug, Args)]
253pub struct UserCollectionsArgs {
254    /// Username
255    ///
256    /// Get username public collections
257    /// If no username provided, gets all api key account collections
258    username: Option<String>,
259}
260
261#[derive(Debug, Default, Clone)]
262pub struct SearchQuery {
263    tags: Option<Vec<String>>,
264    username: Option<String>,
265    id: Option<String>,       //Cant be combined
266    filetype: Option<String>, //type:{png/jpg}
267    like: Option<String>,
268}
269
270impl FromStr for SearchQuery {
271    type Err = String;
272
273    fn from_str(s: &str) -> Result<Self, Self::Err> {
274        let mut q = Self::default();
275        let mut tags = Vec::<String>::default();
276
277        for token in s.split(" ") {
278            // Get parameters key:value
279            let t: Vec<&str> = token.split(":").collect();
280            if t.len() == 2 {
281                let key = t[0];
282                let value = t[1];
283
284                match key {
285                    "id" => {
286                        // Exclusive parameter
287                        // Id is a tag number
288                        // Maybe i should force a casting, even if api is resilient to non integer id?
289                        let mut q = Self::default();
290                        q.id = Some(String::from(value));
291                        return Ok(q);
292                    }
293                    "type" => {
294                        if value == "png" || value == "jpg" {
295                            q.filetype = Some(String::from(value));
296                        } else {
297                            return Err(String::from("Invalid file type - only accept png or jpg"));
298                        }
299                    }
300                    "like" => {
301                        // Wallpaper ID
302                        // As of now ID is length 6 alphanumerical String
303                        // Maybe should enforce it by explicit check, and return err
304                        q.like = Some(String::from(value));
305                    }
306                    _ => {
307                        return Err(String::from(format!("{key}:{value} is not a valid query")));
308                    }
309                }
310
311                continue;
312            }
313
314            // Get username if any
315            if token.starts_with("@") {
316                q.username = Some(String::from(&token[1..]));
317                continue;
318            }
319
320            // Get tags - merging fuzzily with + and - tags
321            tags.push(token.to_string());
322        }
323
324        if !tags.is_empty() {
325            q.tags = Some(tags)
326        }
327
328        return Ok(q);
329    }
330}
331
332#[derive(Debug, Default, Clone)]
333pub struct Seed(String);
334
335impl FromStr for Seed {
336    type Err = String;
337
338    fn from_str(s: &str) -> Result<Self, Self::Err> {
339        if s.len() == 6
340            && s.chars()
341                .all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit())
342        {
343            return Ok(Seed(String::from(s)));
344        } else {
345            return Err(format!("{s} is an invalid seed"));
346        }
347    }
348}
349
350impl Url for SearchArgs {
351    fn to_url(&self, base_url: &str) -> String {
352        let mut params = Vec::<String>::new();
353
354        // Search
355        if let Some(colors) = &self.colors {
356            params.push(format!("colors={colors}"))
357        } else {
358            if let Some(q) = &self.query {
359                if let Some(id) = &q.id {
360                    params.push(format!("q=id:{id}"));
361                } else {
362                    let mut query = Vec::<String>::new();
363
364                    if let Some(tags) = &q.tags {
365                        query.push(tags.join(" "));
366                    }
367                    if let Some(username) = &q.username {
368                        query.push(format!("@{username}"));
369                    }
370                    if let Some(ft) = &q.filetype {
371                        query.push(format!("type:{ft}"));
372                    }
373                    if let Some(w) = &q.like {
374                        query.push(format!("like:{w}"));
375                    }
376
377                    params.push(format!("q={}", query.join(" ")));
378                }
379            }
380        }
381
382        // Search preferences
383        if let Some(categories) = &self.categories {
384            params.push(format!("categories={}", categories));
385        }
386        if let Some(purity) = &self.purity {
387            params.push(format!("purity={}", purity));
388        }
389        if let Some(page) = self.page {
390            params.push(format!("page={}", page));
391        }
392        if let Some(seed) = &self.seed {
393            params.push(format!("seed={}", seed.0));
394        }
395
396        // Sorting
397        if let Some(order) = &self.order {
398            params.push(format!("order={}", order.to_ascii_lowercase()));
399        }
400        if let Some(sorting) = &self.sorting {
401            params.push(format!("sorting={}", sorting.to_ascii_lowercase()));
402        }
403        if let Some(toprange) = &self.toprange {
404            params.push(format!("topRange={}", toprange.to_ascii_lowercase()));
405        }
406
407        // Wallpaper Preferences
408        if let Some(atleast) = &self.atleast {
409            params.push(format!("atleast={}", atleast.to_ascii_lowercase()));
410        }
411        if let Some(resolutions) = &self.resolutions {
412            params.push(format!("resolutions={}", resolutions.to_ascii_lowercase()));
413        }
414        if let Some(ratios) = &self.ratios {
415            params.push(format!("ratios={}", ratios.to_ascii_lowercase()));
416        }
417
418        return format!("{base_url}/search?{}", params.join("&"));
419    }
420}
421
422impl Url for TagInfoArgs {
423    fn to_url(&self, base_url: &str) -> String {
424        return format!("{base_url}/tag/{}", self.id);
425    }
426}
427
428impl Url for UserSettingsArgs {
429    fn to_url(&self, base_url: &str) -> String {
430        return format!("{base_url}/settings");
431    }
432}
433
434impl Url for UserCollectionsArgs {
435    fn to_url(&self, base_url: &str) -> String {
436        match &self.username {
437            Some(username) => {
438                return format!("{base_url}/collections/{username}");
439            }
440            None => {
441                return format!("{base_url}/collections");
442            }
443        }
444    }
445}