Full rewrite #24
|
@ -0,0 +1,8 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf8
|
||||
end_of_line = lf
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
|
@ -1,12 +0,0 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: tebibytemedia # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: tebibytemedia # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
@ -0,0 +1,104 @@
|
|||
# Contributing
|
||||
|
||||
When contributing a pull request to the main branch, please sign your commits
|
||||
with a PGP key and add your name and the year to the bottom of the list of
|
||||
copyright holders for the file. For example, an existing copyright header might
|
||||
read:
|
||||
|
||||
```
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
```
|
||||
|
||||
You would add your name below it like this:
|
||||
|
||||
```
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 20XX Your Name <your e-mail address or website>
|
||||
```
|
||||
|
||||
Only list years in which you worked on the source file. For example:
|
||||
|
||||
```
|
||||
* Copyright (c) 2020–2021, 2023 Your Name <yourname@example.com>
|
||||
```
|
||||
|
||||
This header shows that `Your Name` worked on this source file in 2020, 2021, and
|
||||
2023. Please use the en dash (“–”) to separate the years in the copyright
|
||||
notice.
|
||||
|
||||
If you are contributing a new file, please add the following license header text
|
||||
to it, replacing the proper text on the copyright line:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright (c) 20XX [Your Name] <yourname@example.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
```
|
||||
|
||||
When including code provided under an AGPLv3-compatible license, please modify
|
||||
the license notice. The following example contains an Expat (MIT) license
|
||||
notice:
|
||||
|
||||
```
|
||||
/*
|
||||
* Copyright (c) 20XX [Your Name] <yourname@example.com>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*
|
||||
* This file incorporates work covered by the following copyright and permission
|
||||
* notice:
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (c) <year> <copyright holders>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the
|
||||
* "Software"), to deal in the Software without restriction, including
|
||||
* without limitation the rights to use, copy, modify, merge, publish,
|
||||
* distribute, sublicense, and/or sell copies of the Software, and to permit
|
||||
* persons to whom the Software is furnished to do so, subject to the
|
||||
* following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice (including the next
|
||||
* paragraph) shall be included in all copies or substantial portions of the
|
||||
* Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
||||
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
|
||||
* NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
* USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
```
|
||||
|
||||
When writing code, make sure lines never exceed 80 characters in width when
|
||||
using four-character-wide tabs.
|
|
@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
File diff suppressed because it is too large
Load Diff
17
Cargo.toml
17
Cargo.toml
|
@ -3,18 +3,23 @@ name = "hopper"
|
|||
version = "0.1.0"
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
authors = [
|
||||
"[ ] <https://git.tebibyte.media/BlankParenthesis/>",
|
||||
"Emma Tebibyte <emma@tebibyte.media>",
|
||||
"Marceline Cramer <mars@tebibyte.media>",
|
||||
"Spookdot <https://git.tebibyte.media/spookdot/>",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
confy = "0.4"
|
||||
arg = "0.4.1"
|
||||
console = "0.15.0"
|
||||
curl = "0.4.44"
|
||||
dialoguer = "0.9.0"
|
||||
env_logger = "0.9.0"
|
||||
futures-util = "0.3.18"
|
||||
indicatif = "0.15.0"
|
||||
log = "0.4.14"
|
||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
clap = { version = "3.2.20", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.7.3"
|
||||
xdg = "2.4.1"
|
||||
yacexits = "0.1.5"
|
||||
|
|
138
README.md
138
README.md
|
@ -25,36 +25,36 @@ Hopper can automatically search, download, and update Minecraft mods, modpacks,
|
|||
resource packs, and plugins from [Modrinth](https://modrinth.com/) so that
|
||||
keeping your mods up-to-date and compatible with each other is easy. With
|
||||
Hopper, you won't have to manually visit [CurseForge](https://curseforge.com/)
|
||||
and download each mod one-by-one every time you set up a new instance, or deal
|
||||
and download each mod one by one every time you set up a new instance or deal
|
||||
with the hassle of swapping out different mod versions for hours while trying to
|
||||
get Minecraft to accept them all at once.
|
||||
|
||||
Hopper is still very early in development, but important features are coming
|
||||
along smoothly, and we'll have lots of progress to show off in the coming weeks.
|
||||
It's written in [Rust](https://www.rust-lang.org/) and released under the
|
||||
[AGPLv3](LICENSE).
|
||||
It’s written in [Rust](https://www.rust-lang.org/) and released under the
|
||||
[GNU AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html).
|
||||
|
||||
We're looking for people to help contribute code and write documentation. Please
|
||||
reach out to us in [our Discord "server"](https://discord.gg/jJutHQjsh9) if
|
||||
you're interested in helping out. If you have a taste in CLI apps like Hopper,
|
||||
We’re looking for people to help contribute code and write documentation. Please
|
||||
reach out to us in [our Discord “server”](https://discord.gg/jJutHQjsh9) if
|
||||
you’re interested in helping out. If you have a taste in CLI apps like Hopper,
|
||||
your input is especially appreciated.
|
||||
|
||||
Inspired by applications like [paru](https://github.com/morganamilo/paru), a
|
||||
feature-packed AUR helper and [topgrade](https://github.com/r-darwish/topgrade),
|
||||
a tool to upgrade everything
|
||||
a tool to upgrade everything.
|
||||
|
||||
[![Donate using
|
||||
Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/tebibytemedia/donate)
|
||||
|
||||
# High-level Goals
|
||||
## High-level Goals
|
||||
|
||||
## Continuous
|
||||
### Continuous
|
||||
- Small binary size
|
||||
- Minimal compile times
|
||||
|
||||
## Features
|
||||
### Features
|
||||
|
||||
### High Priority:
|
||||
High Priority:
|
||||
- Modrinth package searching
|
||||
- Modrinth package installation
|
||||
- Parallel package downloading
|
||||
|
@ -62,17 +62,17 @@ Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.c
|
|||
- Package updating
|
||||
- Listing installed packages
|
||||
|
||||
### Medium Priority
|
||||
Medium Priority
|
||||
- CurseForge package searching
|
||||
- CurseForge package installation
|
||||
- A `man(1)` entry
|
||||
|
||||
### Low Priority:
|
||||
Low Priority:
|
||||
- Shell autocomplete
|
||||
- Configurable search result display like [Starship](https://starship.rs)
|
||||
- Version-control system repository package management & compilation
|
||||
|
||||
### External-Dependent:
|
||||
External-Dependent:
|
||||
- Conflict resolution
|
||||
- Dependency resolution
|
||||
- Integration into [Prism Launcher](https://prismlauncher.org/) and/or
|
||||
|
@ -81,26 +81,26 @@ Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.c
|
|||
- Graphical frontend with notifications
|
||||
|
||||
[Modrinth REST API
|
||||
docs](https://docs.modrinth.com/api-spec/)
|
||||
documentation](https://docs.modrinth.com/api-spec/)
|
||||
|
||||
# File Structure
|
||||
|
||||
```
|
||||
├── "$XDG_CONFIG_HOME"/hopper.toml
|
||||
├── "$XDG_CACHE_HOME"/hopper/
|
||||
├── "$XDG_DATA_HOME"/hopper/
|
||||
│ ├── 1.19.1/
|
||||
│ │ └── fabric/
|
||||
│ └── 1.18.2/
|
||||
│ ├── forge/
|
||||
│ └── plugin/
|
||||
└── "XDG_DATA_HOME"/templates/
|
||||
└── "$XDG_DATA_HOME"/templates/
|
||||
└── example-template.hop -> ~/.minecraft/mods/example-template.hop
|
||||
```
|
||||
|
||||
# Hopfile Structure
|
||||
|
||||
Hopfiles will contain a Minecraft version number, a list of packages, and any
|
||||
references to other hopfiles on which it's based, or "templates". If a hopfile
|
||||
references to other hopfiles on which it’s based, or “templates”. If a hopfile
|
||||
is based on other template hopfiles, it inherits the packages from them. A
|
||||
hopfile does not inherit the package or Minecraft version from a template.
|
||||
|
||||
|
@ -115,7 +115,7 @@ resource = "alacrity"
|
|||
|
||||
# Hopper Configuration File Structure
|
||||
|
||||
Hopper's configuration will be maintained with a list of all hopfiles known to
|
||||
Hopper’s configuration will be maintained with a list of all hopfiles known to
|
||||
hopper. Its config file will also contain a list of mod hosting sites like
|
||||
Modrinth and CurseForge and a list of (remote or local) version-control
|
||||
repositories from which to compile mods. The latter will use a (potentially
|
||||
|
@ -136,46 +136,64 @@ git = [
|
|||
]
|
||||
```
|
||||
|
||||
# Docs
|
||||
## Documentation
|
||||
|
||||
## Types
|
||||
### Types
|
||||
|
||||
There are multiple types of packages hopper can manage.
|
||||
|
||||
### Mods
|
||||
#### Mods
|
||||
- `fabric-mod`
|
||||
- `forge-mod`
|
||||
- `quilt-mod`
|
||||
|
||||
### Plugins
|
||||
#### Modpacks
|
||||
- `fabric-pack`
|
||||
- `forge-pack`
|
||||
- `quilt-pack`
|
||||
|
||||
#### Plugins
|
||||
- `bukkit-plugin`
|
||||
- `paper-plugin`
|
||||
- `purpur-plugin`
|
||||
- `spigot-plugin`
|
||||
- `sponge-plugin`
|
||||
|
||||
### Other
|
||||
- `data-pack`
|
||||
- `fabric-pack`
|
||||
- `forge-pack`
|
||||
#### Other
|
||||
- `data-pack` (planned)
|
||||
- `resource-pack`
|
||||
- `quilt-pack`
|
||||
|
||||
These types are specified in various hopper subcommands and in its configuration.
|
||||
These types are specified in various hopper subcommands and in its
|
||||
configuration.
|
||||
|
||||
## Usage
|
||||
### Usage
|
||||
|
||||
`hopper [options...] [subcommand...]`
|
||||
|
||||
## OPTIONS
|
||||
### Options
|
||||
|
||||
`-v`, `--verbose`
|
||||
|
||||
 Includes debug information in the output of `hopper` subcommands.
|
||||
|
||||
## SUBCOMMANDS
|
||||
## Subcommands
|
||||
|
||||
`get [options...] [targets...]`
|
||||
### `add [options...] [packages...]`
|
||||
|
||||
 Adds packages to the current hopfile, symlinking them to its directory. If
|
||||
the package cannot be found in the package cache, `hopper get` is run first.
|
||||
|
||||
OPTIONS
|
||||
|
||||
  `-f`, `--hopfile [hopfiles...]`
|
||||
|
||||
  Specifies hopfiles to which mods will be added.
|
||||
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
  Overrides the version of Minecraft added packages will be for.
|
||||
|
||||
### `get [options...] [targets...]`
|
||||
|
||||
 Searches for packages, displays the results, and downloads any selected
|
||||
packages to the local cache. If multiple targets are specified, results are
|
||||
|
@ -186,7 +204,7 @@ OPTIONS
|
|||
 `-d`, `--dir [directory...]`
|
||||
|
||||
  Specifies the directory to download to (default is
|
||||
"$XDG_CACHE_HOME"/hopper/).
|
||||
`"$XDG_DATA_HOME"/hopper/`).
|
||||
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
|
@ -202,7 +220,7 @@ cache. Requires `--mc-version` and `--type` be specified.
|
|||
|
||||
  Specifies what types of packages are being queried.
|
||||
|
||||
`init [options...]`
|
||||
### `init [options...]`
|
||||
|
||||
 Creates a hopfile in the current directory and adds it to the global known
|
||||
hopfiles list.
|
||||
|
@ -217,26 +235,16 @@ OPTIONS
|
|||
|
||||
  Specifies templates upon which to base the new hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being managed.
|
||||
  Specifies the version of Minecraft packages are being managed for.
|
||||
|
||||
 `-t`, `--type [type...]`
|
||||
|
||||
  Specifies what type of packages will be listed in this hopfile.
|
||||
|
||||
`install [options...] [packages...]`
|
||||
|
||||
 Adds packages to the current hopfile, symlinking them to its directory. If
|
||||
the package cannot be found in the package cache, `hopper get` is run first.
|
||||
|
||||
OPTIONS
|
||||
|
||||
  `-f`, `--hopfile [hopfiles...]`
|
||||
|
||||
  Specifies hopfiles to which mods will be added.
|
||||
|
||||
`list [options...]`
|
||||
### `list [options...]`
|
||||
|
||||
 Lists all installed packages.
|
||||
|
||||
|
@ -246,7 +254,7 @@ OPTIONS
|
|||
|
||||
  Lists packages installed in a specified hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being managed.
|
||||
|
||||
|
@ -254,22 +262,40 @@ OPTIONS
|
|||
|
||||
  List all packages of a specified type.
|
||||
|
||||
`update [options...]`
|
||||
### `remove [options...] [packages...]`
|
||||
|
||||
 Updates installed packages and adds mods if they're missing to directories
|
||||
with known hopfiles.
|
||||
 Uninstalls packages.
|
||||
|
||||
OPTIONS
|
||||
|
||||
 `-f`, `--hopfile [hopfiles...]`
|
||||
|
||||
  Updates only packages in the specified hopfile. Note that this
|
||||
option creates a new file and symlink as it does not update the packages for
|
||||
other hopfiles.
|
||||
  Removes only packages in the specified hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being updated.
|
||||
  Specifies the version of Minecraft the packages are being
|
||||
uninstalled for.
|
||||
|
||||
 `-t`, `--type [types...] [packages...]`
|
||||
|
||||
  Removes only packages of a specified type. Optionally takes a list
|
||||
of packages as an argument.
|
||||
|
||||
### `update [options...]`
|
||||
|
||||
 Updates installed packages. This command also adds mods to directories
|
||||
with known hopfiles if the hopfile lists a mod which is not present.
|
||||
|
||||
OPTIONS
|
||||
|
||||
 `-f`, `--hopfile [hopfiles...]`
|
||||
|
||||
  Updates only packages in the specified hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
  Specifies the version of Minecraft packages are being updated for.
|
||||
|
||||
 `-t`, `--type [types...] [packages...]`
|
||||
|
||||
|
|
246
src/api.rs
246
src/api.rs
|
@ -1,156 +1,186 @@
|
|||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use console::style;
|
||||
use serde::Deserialize;
|
||||
use std::{collections::HashMap, fmt};
|
||||
use std::{ collections::HashMap, fmt };
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SearchResponse {
|
||||
pub hits: Vec<ModResult>,
|
||||
pub offset: isize,
|
||||
pub limit: isize,
|
||||
pub total_hits: isize,
|
||||
pub hits: Vec<ModResult>,
|
||||
pub offset: isize,
|
||||
pub limit: isize,
|
||||
pub total_hits: isize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModResult {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
pub display_categories: Vec<String>, // NOTE this is not in the OpenAPI docs
|
||||
pub client_side: String,
|
||||
pub server_side: String,
|
||||
pub project_type: String, // NOTE this isn't in all search results?
|
||||
pub downloads: isize,
|
||||
pub icon_url: String,
|
||||
pub project_id: String, // TODO parse to 'local-xxxx' with reegex
|
||||
pub author: String,
|
||||
pub versions: Vec<String>,
|
||||
pub follows: isize,
|
||||
pub date_created: String,
|
||||
pub date_modified: String,
|
||||
pub latest_version: String,
|
||||
pub license: String,
|
||||
pub gallery: Vec<String>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
|
||||
// NOTE this is not in the OpenAPI docs
|
||||
pub display_categories: Vec<String>,
|
||||
pub client_side: String,
|
||||
pub server_side: String,
|
||||
|
||||
// NOTE this isn't in all search results?
|
||||
pub project_type: String,
|
||||
pub downloads: isize,
|
||||
pub icon_url: String,
|
||||
|
||||
// TODO parse to 'local-xxxx' with regex
|
||||
pub project_id: String,
|
||||
pub author: String,
|
||||
pub versions: Vec<String>,
|
||||
pub follows: isize,
|
||||
pub date_created: String,
|
||||
pub date_modified: String,
|
||||
pub latest_version: String,
|
||||
pub license: String,
|
||||
pub gallery: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModResult {
|
||||
pub fn format_info(&self) -> String {
|
||||
let title = style(self.title.clone()).bold();
|
||||
let downloads = style(self.downloads.clone()).bold().green();
|
||||
if let Some(latest_release) = self.versions.last() {
|
||||
// TODO fetch version numbers to display
|
||||
let latest_release = style(latest_release).bold().blue();
|
||||
format!("{} [{}] ({} downloads)", title, latest_release, downloads)
|
||||
} else {
|
||||
format!("{} [no releases]", title)
|
||||
}
|
||||
}
|
||||
pub fn format_info(&self) -> String {
|
||||
let title = style(self.title.clone()).bold();
|
||||
let downloads = style(self.downloads.clone()).bold().green();
|
||||
if let Some(latest_release) = self.versions.last() {
|
||||
// TODO fetch version numbers to display
|
||||
let latest_release = style(latest_release).bold().blue();
|
||||
format!("{} [{}] ({} downloads)", title, latest_release, downloads)
|
||||
} else {
|
||||
format!("{} [no releases]", title)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_description(&self) -> String {
|
||||
self.description.to_owned()
|
||||
}
|
||||
pub fn format_description(&self) -> String {
|
||||
self.description.to_owned()
|
||||
}
|
||||
|
||||
pub fn display(&self, index: usize) {
|
||||
let index = style(index).magenta();
|
||||
let info = self.format_info();
|
||||
let description = self.format_description();
|
||||
println!("{:>2} {}\n {}", index, info, description);
|
||||
}
|
||||
pub fn display(&self, index: usize) {
|
||||
let index = style(index).magenta();
|
||||
let info = self.format_info();
|
||||
let description = self.format_description();
|
||||
println!("{:>2} {}\n {}", index, info, description);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModInfo {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
pub additional_categories: Vec<String>, // NOTE not listed in OpenAPI docs
|
||||
pub client_side: String, // TODO serialize as enum
|
||||
pub server_side: String, // TODO serialize as enum
|
||||
pub body: String,
|
||||
pub issues_url: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub wiki_url: Option<String>,
|
||||
pub discord_url: Option<String>,
|
||||
pub donation_urls: Option<Vec<DonationLink>>,
|
||||
pub project_type: String,
|
||||
pub downloads: isize,
|
||||
pub icon_url: Option<String>,
|
||||
pub id: String, // TODO serialize mod id?
|
||||
pub team: String, // TODO serialize team id?
|
||||
pub body_url: Option<String>, // NOTE deprecated
|
||||
pub moderator_message: Option<String>,
|
||||
pub published: String, // TODO serialize as datetime
|
||||
pub updated: String, // TODO serialize as datetime
|
||||
pub approved: Option<String>, // NOTE not listed in OpenAPI docs, TODO serialize as datetime
|
||||
pub followers: isize,
|
||||
pub status: String,
|
||||
pub license: License,
|
||||
pub versions: Vec<String>,
|
||||
pub gallery: Option<Vec<GalleryEntry>>,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
pub additional_categories: Vec<String>, // NOTE not listed in OpenAPI docs
|
||||
pub client_side: String, // TODO serialize as enum
|
||||
pub server_side: String, // TODO serialize as enum
|
||||
pub body: String,
|
||||
pub issues_url: Option<String>,
|
||||
pub source_url: Option<String>,
|
||||
pub wiki_url: Option<String>,
|
||||
pub discord_url: Option<String>,
|
||||
pub donation_urls: Option<Vec<DonationLink>>,
|
||||
pub project_type: String,
|
||||
pub downloads: isize,
|
||||
pub icon_url: Option<String>,
|
||||
pub id: String, // TODO serialize mod id?
|
||||
pub team: String, // TODO serialize team id?
|
||||
pub body_url: Option<String>, // NOTE deprecated
|
||||
pub moderator_message: Option<String>,
|
||||
pub published: String, // TODO serialize as datetime
|
||||
pub updated: String, // TODO serialize as datetime
|
||||
|
||||
// NOTE not listed in OpenAPI docs, TODO serialize as datetime
|
||||
pub approved: Option<String>,
|
||||
pub followers: isize,
|
||||
pub status: String,
|
||||
pub license: License,
|
||||
pub versions: Vec<String>,
|
||||
pub gallery: Option<Vec<GalleryEntry>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GalleryEntry {
|
||||
pub url: String,
|
||||
pub featured: bool,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub created: String,
|
||||
pub url: String,
|
||||
pub featured: bool,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct License {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DonationLink {
|
||||
pub id: String,
|
||||
pub platform: String,
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
pub platform: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModVersion {
|
||||
pub name: String,
|
||||
pub version_number: String,
|
||||
pub changelog: Option<String>,
|
||||
// pub dependencies: Option<Vec<String>>, // TODO dependency wrangling, thank you modrinth, very cool
|
||||
pub game_versions: Vec<String>,
|
||||
pub version_type: String, // TODO {alpha | beta | release}
|
||||
pub loaders: Vec<String>,
|
||||
pub featured: bool,
|
||||
pub id: String, // version id
|
||||
pub project_id: String, // mod id
|
||||
pub author_id: String, // user id
|
||||
pub date_published: String, // TODO serialize datetime
|
||||
pub downloads: isize,
|
||||
pub changelog_url: Option<String>, // NOTE deprecated
|
||||
pub files: Vec<ModVersionFile>,
|
||||
pub name: String,
|
||||
pub version_number: String,
|
||||
pub changelog: Option<String>,
|
||||
|
||||
// TODO dependency wrangling, thank you modrinth, very cool
|
||||
// pub dependencies: Option<Vec<String>>,
|
||||
pub game_versions: Vec<String>,
|
||||
pub version_type: String, // TODO {alpha | beta | release}
|
||||
pub loaders: Vec<String>,
|
||||
pub featured: bool,
|
||||
pub id: String, // version id
|
||||
pub project_id: String, // mod id
|
||||
pub author_id: String, // user id
|
||||
pub date_published: String, // TODO serialize datetime
|
||||
pub downloads: isize,
|
||||
pub changelog_url: Option<String>, // NOTE deprecated
|
||||
pub files: Vec<ModVersionFile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModVersionFile {
|
||||
pub hashes: HashMap<String, String>,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub primary: bool,
|
||||
pub size: isize,
|
||||
pub hashes: HashMap<String, String>,
|
||||
pub url: String,
|
||||
pub filename: String,
|
||||
pub primary: bool,
|
||||
pub size: isize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Error {
|
||||
pub error: String,
|
||||
pub description: String,
|
||||
pub error: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.error, self.description)
|
||||
}
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.error, self.description)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use core::{
|
||||
fmt,
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
pub use arg::Args;
|
||||
use yacexits::{ EX_DATAERR, EX_USAGE };
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct Arguments {
|
||||
pub argv0: String,
|
||||
|
||||
#[arg(short = "v")]
|
||||
pub v: Option<bool>,
|
||||
|
||||
#[arg(sub)]
|
||||
pub sub: Command,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub enum Command {
|
||||
Add(AddArgs),
|
||||
Get(SearchArgs),
|
||||
Init(InitArgs),
|
||||
List(HopArgs),
|
||||
Remove(RmArgs),
|
||||
Update(HopArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct AddArgs {
|
||||
#[arg(short = "m")]
|
||||
pub mc_version: String,
|
||||
|
||||
#[arg(short = "f")]
|
||||
pub hopfiles: Vec<String>,
|
||||
|
||||
pub package_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct HopArgs {
|
||||
#[arg(short = "f")]
|
||||
pub hopfile: Vec<String>,
|
||||
|
||||
#[arg(short = "m")]
|
||||
pub mc_version: Vec<String>,
|
||||
|
||||
#[arg(short = "t")]
|
||||
pub package_type: Option<PackageType>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct InitArgs {
|
||||
#[arg(short = "f")]
|
||||
pub template: Option<String>,
|
||||
|
||||
pub mc_version: String,
|
||||
|
||||
pub package_type: PackageType,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct RmArgs {
|
||||
#[arg(short = "f")]
|
||||
pub hopfile: Option<String>,
|
||||
|
||||
pub package_type: PackageType,
|
||||
|
||||
pub mc_version: String,
|
||||
|
||||
pub package_names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct SearchArgs {
|
||||
#[arg(short = "n")]
|
||||
pub no_confirm: bool,
|
||||
|
||||
/// Overrides the download directory
|
||||
#[arg(short = "d")]
|
||||
pub dir: Option<String>,
|
||||
|
||||
/// Restricts the target Minecraft version
|
||||
#[arg(short = "m")]
|
||||
pub mc_version: Vec<String>,
|
||||
|
||||
/// Type of package to use
|
||||
#[arg(short = "t")]
|
||||
pub package_type: PackageType,
|
||||
|
||||
pub package_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum PackageType {
|
||||
Dummy,
|
||||
Mod(Loader),
|
||||
Pack(Loader),
|
||||
Plugin(Server),
|
||||
ResourcePack,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Loader {
|
||||
Fabric,
|
||||
Forge,
|
||||
Quilt,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Server {
|
||||
Bukkit,
|
||||
Paper,
|
||||
Purpur,
|
||||
Spigot,
|
||||
Sponge,
|
||||
}
|
||||
|
||||
impl fmt::Display for Command {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self {
|
||||
Command::Add(_) => write!(f, "add"),
|
||||
Command::Get(_) => write!(f, "get"),
|
||||
Command::Init(_) => write!(f, "init"),
|
||||
Command::List(_) => write!(f, "list"),
|
||||
Command::Remove(_) => write!(f, "remove"),
|
||||
Command::Update(_) => write!(f, "update"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PackageParseError {
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl std::default::Default for PackageType { // TODO: Actually implement Default
|
||||
fn default() -> Self { // for PackageType
|
||||
PackageType::Dummy
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PackageParseError> for (String, u32) {
|
||||
fn from(error: PackageParseError) -> Self {
|
||||
match error {
|
||||
PackageParseError::Invalid(err) => (err, EX_DATAERR),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PackageType {
|
||||
type Err = PackageParseError;
|
||||
fn from_str(s: &str) -> Result<PackageType, PackageParseError> {
|
||||
let pieces: Vec<&str> = s.split("-").collect();
|
||||
|
||||
if pieces.len() > 2 || pieces.len() == 1 {
|
||||
return Err(PackageParseError::Invalid(
|
||||
format!("{}: Invalid package type.", s)
|
||||
));
|
||||
}
|
||||
|
||||
let (prefix, postfix) = (pieces[0], pieces[1]);
|
||||
|
||||
let loader = match prefix {
|
||||
"bukkit" => return Ok(PackageType::Plugin(Server::Bukkit)),
|
||||
"fabric" => Loader::Fabric,
|
||||
"forge" => Loader::Forge,
|
||||
"paper" => return Ok(PackageType::Plugin(Server::Paper)),
|
||||
"purpur" => return Ok(PackageType::Plugin(Server::Purpur)),
|
||||
"quilt" => Loader::Quilt,
|
||||
"resource" => return Ok(PackageType::ResourcePack),
|
||||
"spigot" => return Ok(PackageType::Plugin(Server::Spigot)),
|
||||
"sponge" => return Ok(PackageType::Plugin(Server::Sponge)),
|
||||
_ => {
|
||||
return Err(PackageParseError::Invalid(
|
||||
format!("{}: Invalid package type.", prefix)
|
||||
))
|
||||
},
|
||||
};
|
||||
|
||||
match postfix {
|
||||
"mod" => Ok(PackageType::Mod(loader)),
|
||||
"pack" => Ok(PackageType::Pack(loader)),
|
||||
_ => {
|
||||
Err(PackageParseError::Invalid(
|
||||
format!("{}: Invalid package type.", postfix)
|
||||
))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Make this an enum for this for matching specific error kinds
|
||||
pub struct ArgsError {
|
||||
message: String,
|
||||
code: u32,
|
||||
}
|
||||
|
||||
// TODO: More granular matching here with an enum
|
||||
impl From<arg::ParseKind<'_>> for ArgsError {
|
||||
fn from(_: arg::ParseKind) -> Self {
|
||||
let message = format!(
|
||||
"{}",
|
||||
"[-v] add | get | init | list | remove | update\n\n".to_owned() +
|
||||
"add [-m version] [-f hopfiles...] packages...\n" +
|
||||
"get [-n] [-d directory] [-m versions...] [-t types...] packages\n" +
|
||||
"init [-f hopfiles...] version type\n" +
|
||||
"list [[-f hopfiles...] | [-m versions...] [-t types...]]\n" +
|
||||
"remove [[-f hopfiles...] | type version]] packages...\n" +
|
||||
"update [[-f hopfiles... | [-m versions...] [-t types...]]",
|
||||
);
|
||||
ArgsError { message, code: EX_USAGE }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ArgsError> for (String, u32) {
|
||||
fn from(err: ArgsError) -> Self {
|
||||
(err.message, err.code)
|
||||
}
|
||||
}
|
227
src/client.rs
227
src/client.rs
|
@ -1,34 +1,79 @@
|
|||
use crate::api::{ModInfo, ModResult, ModVersion, ModVersionFile, SearchResponse, Error as APIError};
|
||||
use crate::config::{Args, Config, PackageType, SearchArgs};
|
||||
use futures_util::StreamExt;
|
||||
use log::*;
|
||||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
api::{
|
||||
ModInfo,
|
||||
ModResult,
|
||||
ModVersion,
|
||||
ModVersionFile,
|
||||
SearchResponse,
|
||||
Error as APIError,
|
||||
},
|
||||
config::{
|
||||
Config,
|
||||
},
|
||||
args::{
|
||||
Arguments,
|
||||
Loader,
|
||||
PackageType,
|
||||
Server,
|
||||
SearchArgs,
|
||||
},
|
||||
};
|
||||
|
||||
use std::cmp::min;
|
||||
use std::io::Write;
|
||||
|
||||
use curl::easy::{ Easy };
|
||||
use futures_util::StreamExt;
|
||||
|
||||
pub struct HopperClient {
|
||||
config: Config,
|
||||
client: reqwest::Client,
|
||||
client: Easy,
|
||||
}
|
||||
|
||||
impl HopperClient {
|
||||
pub fn new(config: Config) -> Self {
|
||||
curl::init();
|
||||
Self {
|
||||
config: config,
|
||||
client: reqwest::ClientBuilder::new()
|
||||
.user_agent(format!("tebibytemedia/hopper/{} (tebibyte.media)", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
client: Easy::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_mods(&self, search_args: &SearchArgs) -> anyhow::Result<SearchResponse> {
|
||||
println!("Searching with query \"{}\"...", search_args.package_name);
|
||||
pub async fn search_mods(
|
||||
&mut self,
|
||||
search_args: &SearchArgs,
|
||||
) /*-> Result<SearchResponse, (String, u32)>*/ {
|
||||
println!("Searching with query “{}”...", search_args.package_name);
|
||||
|
||||
let url = format!("https://{}/v2/search", self.config.upstream.server_address);
|
||||
let mut urls = Vec::new();
|
||||
|
||||
for entry in self.config.sources.modrinth.iter() {
|
||||
urls.push(format!("{}/v2/search", entry));
|
||||
}
|
||||
|
||||
let mut params = vec![("query", search_args.package_name.to_owned())];
|
||||
let mut facets: Vec<String> = Vec::new();
|
||||
if let Some(versions) = &search_args.version {
|
||||
if let versions = &search_args.mc_version {
|
||||
let versions_facets = versions
|
||||
.iter()
|
||||
.map(|e| format!("[\"versions:{}\"]", e))
|
||||
|
@ -36,131 +81,47 @@ impl HopperClient {
|
|||
.join(",");
|
||||
facets.push(format!("{}", versions_facets));
|
||||
}
|
||||
if let Some(package_type) = &search_args.package_type {
|
||||
let package_type_facet = match package_type {
|
||||
PackageType::Fabric => "[\"categories:fabric\"],[\"project_type:mod\"]",
|
||||
PackageType::Forge => "[\"categories:forge\"],[\"project_type:mod\"]",
|
||||
PackageType::Quilt => "[\"categories:quilt\"],[\"project_type:mod\"]",
|
||||
PackageType::Resource => "[\"project_type:resourcepack\"]",
|
||||
PackageType::FabricPack => "[\"project_type:modpack\"],[\"categories:fabric\"]",
|
||||
PackageType::ForgePack => "[\"project_type:modpack\"],[\"categories:forge\"]",
|
||||
PackageType::QuiltPack => "[\"project_type:modpack\"],[\"categories:quilt\"]",
|
||||
PackageType::BukkitPlugin => "[\"project_type:mod\"],[\"categories:bukkit\"]",
|
||||
PackageType::PaperPlugin => "[\"project_type:mod\"],[\"categories:paper\"]",
|
||||
PackageType::PurpurPlugin => "[\"project_type:mod\"],[\"categories:purpur\"]",
|
||||
PackageType::SpigotPlugin => "[\"project_type:mod\"],[\"categories:spigot\"]",
|
||||
PackageType::SpongePlugin => "[\"project_type:mod\"],[\"categories:sponge\"]",
|
||||
}
|
||||
.to_string();
|
||||
if let package_type = search_args.package_type {
|
||||
let project_type = match package_type {
|
||||
PackageType::Dummy => "",
|
||||
PackageType::Mod(_) => "[\"project_type:mod\"]",
|
||||
PackageType::Pack(_) => "[\"project_type:modpack\"]",
|
||||
PackageType::Plugin(_) => "[\"project_type:mod\"]",
|
||||
PackageType::ResourcePack => "[\"project_type:resourcepack\"]",
|
||||
};
|
||||
|
||||
let project_category = match package_type {
|
||||
PackageType::Dummy => "",
|
||||
PackageType::Mod(kind) | PackageType::Pack(kind) => {
|
||||
match kind {
|
||||
Loader::Fabric => "[\"categories:fabric\"]",
|
||||
Loader::Forge => "[\"categories:forge\"]",
|
||||
Loader::Quilt => "[\"categories:quilt\"]",
|
||||
}
|
||||
},
|
||||
PackageType::Plugin(kind) => {
|
||||
match kind {
|
||||
Server::Bukkit => "[\"categories:bukkit\"]",
|
||||
Server::Paper => "[\"categories:paper\"]",
|
||||
Server::Purpur => "[\"categories:purpur\"]",
|
||||
Server::Spigot => "[\"categories:spigot\"]",
|
||||
Server::Sponge => "[\"categories:sponge\"]",
|
||||
}
|
||||
},
|
||||
PackageType::ResourcePack => "",
|
||||
};
|
||||
|
||||
let package_type_facet = format!(
|
||||
"{},{}",
|
||||
project_type,
|
||||
project_category,
|
||||
);
|
||||
|
||||
facets.push(package_type_facet);
|
||||
}
|
||||
|
||||
if !facets.is_empty() {
|
||||
params.push(("facets", format!("[{}]", facets.join(","))));
|
||||
}
|
||||
|
||||
let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?;
|
||||
info!("GET {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json::<SearchResponse>().await?)
|
||||
} else {
|
||||
Err(response.json::<APIError>().await?.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_mod_info(&self, mod_result: &ModResult) -> anyhow::Result<ModInfo> {
|
||||
let mod_id = &mod_result.project_id;
|
||||
println!(
|
||||
"Fetching mod info for {} (ID: {})...",
|
||||
mod_result.title, mod_id
|
||||
);
|
||||
|
||||
let url = format!(
|
||||
"https://{}/v2/project/{}",
|
||||
self.config.upstream.server_address, mod_id
|
||||
);
|
||||
info!("GET {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json::<ModInfo>().await?)
|
||||
} else {
|
||||
Err(response.json::<APIError>().await?.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_mod_version(&self, version_id: &String) -> anyhow::Result<ModVersion> {
|
||||
println!("Fetching mod version {}...", version_id);
|
||||
|
||||
let url = format!(
|
||||
"https://{}/v2/version/{}",
|
||||
self.config.upstream.server_address, version_id
|
||||
);
|
||||
info!("GET {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(response.json::<ModVersion>().await?)
|
||||
} else {
|
||||
Err(response.json::<APIError>().await?.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_version_file(
|
||||
&self,
|
||||
args: &Args,
|
||||
file: &ModVersionFile,
|
||||
) -> anyhow::Result<()> {
|
||||
// TODO replace all uses of .unwrap() with proper error codes
|
||||
let filename = &file.filename;
|
||||
|
||||
// TODO make confirmation skippable with flag argument
|
||||
if !args.auto_accept {
|
||||
use dialoguer::Confirm;
|
||||
let prompt = format!("Download to {}?", filename);
|
||||
let confirm = Confirm::new()
|
||||
.with_prompt(prompt)
|
||||
.default(true)
|
||||
.interact()?;
|
||||
if !confirm {
|
||||
println!("Skipping downloading {}...", filename);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let url = &file.url;
|
||||
info!("GET {}", url);
|
||||
let response = self.client.get(url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(response.json::<APIError>().await?.into())
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap();
|
||||
|
||||
// TODO better colors and styling!
|
||||
// TODO square colored creeper face progress indicator (from top-left clockwise spiral in)
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
let pb = ProgressBar::new(total_size);
|
||||
pb.set_style(ProgressStyle::default_bar().template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({bytes_per_sec}, {eta})").progress_chars("#>-"));
|
||||
pb.set_message(&format!("Downloading {}", url));
|
||||
|
||||
let filename = &file.filename;
|
||||
let mut file = std::fs::File::create(filename)?;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
// TODO check hashes while streaming
|
||||
while let Some(item) = stream.next().await {
|
||||
let chunk = &item.unwrap();
|
||||
file.write(&chunk)?;
|
||||
let new = min(downloaded + (chunk.len() as u64), total_size);
|
||||
downloaded = new;
|
||||
pb.set_position(new);
|
||||
}
|
||||
|
||||
pb.finish_with_message(&format!("Downloaded {} to {}", url, filename));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022 [ ] <https://git.tebibyte.media/BlankParenthesis/>
|
||||
* Copyright (c) 2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
pub async fn cmd_get(
|
||||
ctx: &AppContext,
|
||||
search_args: SearchArgs
|
||||
) -> anyhow::Result<()> {
|
||||
let client = HopperClient::new(ctx.config.clone());
|
||||
let response = client.search_mods(&search_args).await?;
|
||||
|
||||
if response.hits.is_empty() {
|
||||
// TODO formatting
|
||||
println!("No results; nothing to do...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
display_search_results(ctx, &response);
|
||||
let selected = select_from_results(ctx, &response).await?;
|
||||
|
||||
if selected.is_empty() {
|
||||
// TODO formatting
|
||||
println!("No packages selected; nothing to do...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for selection in selected.iter() {
|
||||
let to_get = &response.hits[*selection];
|
||||
let mod_info = client.fetch_mod_info(to_get).await?;
|
||||
|
||||
// TODO allow the user to select multiple versions
|
||||
if let Some(version_id) = mod_info.versions.first() {
|
||||
println!("fetching version {}", version_id);
|
||||
|
||||
let version = client.fetch_mod_version(version_id).await?;
|
||||
for file in version.files.iter() {
|
||||
client.download_version_file(&ctx.args, file).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn cmd_init(args: HopfileArgs) -> anyhow::Result<()> {
|
||||
let mut path = args.dir.unwrap_or_default();
|
||||
path.push("info.hop");
|
||||
|
||||
if path.try_exists().expect("Invalid dir") {
|
||||
let message = format!(
|
||||
"hopfile already exists: {}",
|
||||
path.to_str().unwrap()
|
||||
);
|
||||
Err(anyhow::Error::msg(message))
|
||||
} else {
|
||||
let mut file = File::create(&path).await?;
|
||||
let doc = Hopfile::new(args.template, args.version);
|
||||
let output = toml_edit::easy::to_string_pretty(&doc).unwrap();
|
||||
|
||||
file.write_all(output.as_bytes()).await?;
|
||||
|
||||
println!("Saved {}", path.to_str().unwrap());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
||||
let iter = response.hits.iter().enumerate();
|
||||
if ctx.config.options.reverse_search {
|
||||
for (i, result) in iter.rev() {
|
||||
result.display(i + 1);
|
||||
}
|
||||
} else {
|
||||
for (i, result) in iter {
|
||||
result.display(i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO implement enum for more graceful exiting
|
||||
async fn select_from_results(
|
||||
_ctx: &AppContext,
|
||||
response: &SearchResponse,
|
||||
) -> anyhow::Result<Vec<usize>> {
|
||||
let input: String = dialoguer::Input::new()
|
||||
.with_prompt("Mods to install (eg: 1 2 3-5)")
|
||||
.interact_text()?;
|
||||
|
||||
let mut selected: Vec<usize> = Vec::new();
|
||||
for token in input.split(" ") {
|
||||
let terms: Vec<&str> = token.split("-").collect();
|
||||
|
||||
match terms.len() {
|
||||
1 => selected.push(terms[0].parse().expect("Token must be an integer")),
|
||||
2 => {
|
||||
let terms: Vec<usize> = terms
|
||||
.iter()
|
||||
.map(|term| term.parse().expect("Term must be an integer"))
|
||||
.collect();
|
||||
let from = terms[0];
|
||||
let to = terms[1];
|
||||
|
||||
for index in from..=to {
|
||||
selected.push(index);
|
||||
}
|
||||
}
|
||||
_ => panic!("Invalid selection token {}", token),
|
||||
}
|
||||
}
|
||||
|
||||
selected.dedup();
|
||||
|
||||
let selected = selected
|
||||
.iter()
|
||||
.map(|index| {
|
||||
if *index < 1 || *index > response.hits.len() {
|
||||
// TODO return useful error instead of panicking
|
||||
panic!("Index {} is out of bounds", index);
|
||||
}
|
||||
|
||||
// input is indexed from 1, but results are indexed from 0
|
||||
let index = index - 1;
|
||||
|
||||
index
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(selected)
|
||||
}
|
209
src/config.rs
209
src/config.rs
|
@ -1,119 +1,104 @@
|
|||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||
* Copyright (c) 2023 [ ] <https://git.tebibyte.media/BlankParenthesis/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// TODO parameter to restrict target Minecraft version
|
||||
#[derive(clap::Args, Clone, Debug)]
|
||||
pub struct SearchArgs {
|
||||
pub package_name: String,
|
||||
use std::{
|
||||
fs::File,
|
||||
io::{ Read, self },
|
||||
path::PathBuf,
|
||||
};
|
||||
|
||||
/// Type of package to use
|
||||
#[clap(short, long, value_enum)]
|
||||
pub package_type: Option<PackageType>,
|
||||
use serde::Deserialize;
|
||||
use xdg::BaseDirectories;
|
||||
use yacexits::{
|
||||
EX_DATAERR,
|
||||
EX_UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Restricts the target Minecraft version
|
||||
#[clap(short, long)]
|
||||
pub version: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// TODO use ColoredHelp by default?
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum Command {
|
||||
/// Adds a mod to the current instance
|
||||
Add(SearchArgs),
|
||||
/// Removes a mod
|
||||
Remove {
|
||||
package_name: String,
|
||||
},
|
||||
Get(SearchArgs),
|
||||
Update,
|
||||
Clean,
|
||||
}
|
||||
|
||||
#[derive(ValueEnum, Clone, Debug)]
|
||||
pub enum PackageType {
|
||||
Fabric,
|
||||
Forge,
|
||||
Quilt,
|
||||
Resource,
|
||||
FabricPack,
|
||||
ForgePack,
|
||||
QuiltPack,
|
||||
BukkitPlugin,
|
||||
PaperPlugin,
|
||||
PurpurPlugin,
|
||||
SpigotPlugin,
|
||||
SpongePlugin,
|
||||
}
|
||||
|
||||
// TODO move main body argument fields to substruct for ease of moving?
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
#[clap(name = "hopper")]
|
||||
pub struct Args {
|
||||
/// Path to configuration file
|
||||
#[clap(short, long, value_parser)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Path to mod lockfile
|
||||
#[clap(short, long, value_parser)]
|
||||
pub lockfile: Option<PathBuf>,
|
||||
|
||||
/// Auto-accept confirmation dialogues
|
||||
#[clap(short = 'y', long = "yes")]
|
||||
pub auto_accept: bool,
|
||||
|
||||
#[clap(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub fn load_config(&self) -> Result<Config, confy::ConfyError> {
|
||||
if let Some(config_path) = &self.config {
|
||||
confy::load_path(config_path)
|
||||
} else {
|
||||
confy::load("hopper")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct Upstream {
|
||||
/// Modrinth main server address
|
||||
pub server_address: String,
|
||||
}
|
||||
|
||||
impl Default for Upstream {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server_address: "api.modrinth.com".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// Whether to reverse search results
|
||||
pub reverse_search: bool,
|
||||
}
|
||||
|
||||
impl Default for Options {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
reverse_search: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
#[derive(Deserialize)]
|
||||
pub struct Config {
|
||||
/// General settings
|
||||
pub options: Options,
|
||||
|
||||
/// Configuration for the upstream Modrinth server
|
||||
pub upstream: Upstream,
|
||||
pub hopfiles: Vec<String>,
|
||||
pub sources: Sources,
|
||||
}
|
||||
|
||||
pub struct AppContext {
|
||||
pub args: Args,
|
||||
pub config: Config,
|
||||
#[derive(Deserialize)]
|
||||
pub struct Sources {
|
||||
pub modrinth: Vec<String>,
|
||||
}
|
||||
|
||||
pub enum ConfigError {
|
||||
CreateError(io::Error),
|
||||
OpenError(io::Error),
|
||||
ReadError(io::Error),
|
||||
FormatError(std::string::FromUtf8Error),
|
||||
ParseError(toml::de::Error),
|
||||
}
|
||||
|
||||
impl From<ConfigError> for (String, u32) {
|
||||
fn from(error: ConfigError) -> Self {
|
||||
let (message, code) = match error {
|
||||
// TODO: More precise matching inside these arms
|
||||
ConfigError::CreateError(_) => {
|
||||
("Unable to create configuration file.", EX_UNAVAILABLE)
|
||||
},
|
||||
ConfigError::OpenError(_) => {
|
||||
("Unable to open configuration file.", EX_UNAVAILABLE)
|
||||
},
|
||||
ConfigError::ReadError(_) => {
|
||||
("Error while reading configuration file.", EX_DATAERR)
|
||||
},
|
||||
ConfigError::FormatError(_) => {
|
||||
("Configuration file is not valid UTF-8.", EX_DATAERR)
|
||||
},
|
||||
ConfigError::ParseError(_) => {
|
||||
("Unable to parse configuration file.", EX_DATAERR)
|
||||
},
|
||||
};
|
||||
|
||||
(message.to_string(), code)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
impl From<xdg::BaseDirectoriesError> for ConfigError {
|
||||
fn from(err: xdg::BaseDirectoriesError) -> Self {
|
||||
ConfigError::CreateError(io::Error::from(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn read_config() -> Result<Self, ConfigError> {
|
||||
let config_path = BaseDirectories::with_prefix("hopper")?
|
||||
.place_config_file("config.toml")
|
||||
.map_err(ConfigError::CreateError)?;
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
|
||||
let mut config_file = File::open(&config_path)
|
||||
.map_err(ConfigError::OpenError)?;
|
||||
|
||||
config_file.read_to_end(&mut buf)
|
||||
.map_err(ConfigError::ReadError)?;
|
||||
|
||||
let toml = String::from_utf8(buf)
|
||||
.map_err(ConfigError::FormatError)?;
|
||||
|
||||
toml::from_str(&toml).map_err(ConfigError::ParseError)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright (c) 2022 [ ] <https://git.tebibyte.media/BlankParenthesis/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Serialize, de::Visitor};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct Hopfile {
|
||||
pub template: Option<String>,
|
||||
// TODO: possibly parse this into a more specific format (enum maybe?)
|
||||
pub mc_version: String,
|
||||
pub packages: Packages,
|
||||
}
|
||||
|
||||
impl Hopfile {
|
||||
pub fn new(template: Option<String>, version: Option<String>) -> Self {
|
||||
Self {
|
||||
template,
|
||||
mc_version: version.unwrap_or_else(|| String::from("1.19.1")),
|
||||
packages: Packages::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Packages {
|
||||
pub mods: Vec<Resource>,
|
||||
pub resources: Vec<Resource>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
enum Provider {
|
||||
#[default]
|
||||
Modrinth,
|
||||
}
|
||||
|
||||
impl FromStr for Provider {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"modrinth" => Ok(Self::Modrinth),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for Provider {
|
||||
fn to_string(&self) -> String {
|
||||
String::from(match self {
|
||||
Self::Modrinth => "modrinth",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Resource {
|
||||
provider: Provider,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl FromStr for Resource {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Some((provider, name)) = s.split_once(':') {
|
||||
Ok(Resource {
|
||||
provider: Provider::from_str(provider)?,
|
||||
name: name.to_string(),
|
||||
})
|
||||
} else if !s.is_empty() {
|
||||
Ok(Resource {
|
||||
provider: Provider::default(),
|
||||
name: s.to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToString for Resource {
|
||||
fn to_string(&self) -> String {
|
||||
[self.provider.to_string().as_str(), self.name.as_str()].join(":")
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Resource {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: serde::Serializer {
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
struct V;
|
||||
|
||||
impl<'de> Visitor<'de> for V {
|
||||
type Value = Resource;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
write!(formatter, "a string")
|
||||
}
|
||||
|
||||
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
|
||||
where E: serde::de::Error, {
|
||||
Resource::from_str(v).map_err(|_| serde::de::Error::custom(
|
||||
format!("Failed to parse mod/resource: '{}'", v)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Resource {
|
||||
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where D: serde::Deserializer<'de> {
|
||||
deserializer.deserialize_str(V)
|
||||
}
|
||||
}
|
174
src/main.rs
174
src/main.rs
|
@ -1,121 +1,83 @@
|
|||
/*
|
||||
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||
* Copyright (c) 2022 Spookdot <https://git.tebibyte.media/spookdot/>
|
||||
* Copyright (c) 2022–2023 [ ] <https://git.tebibyte.media/BlankParenthesis/>
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* This file is part of Hopper.
|
||||
*
|
||||
* Hopper is free software: you can redistribute it and/or modify it under the
|
||||
* terms of the GNU General Public License as published by the Free Software
|
||||
* Foundation, either version 3 of the License, or (at your option) any later
|
||||
* version.
|
||||
*
|
||||
* Hopper is distributed in the hope that it will be useful, but WITHOUT ANY
|
||||
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
|
||||
* A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* Hopper. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
mod api;
|
||||
mod args;
|
||||
mod client;
|
||||
mod config;
|
||||
mod hopfile;
|
||||
|
||||
use api::*;
|
||||
use clap::Parser;
|
||||
use args::*;
|
||||
use client::*;
|
||||
use config::*;
|
||||
use hopfile::*;
|
||||
|
||||
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
||||
let iter = response.hits.iter().enumerate();
|
||||
if ctx.config.options.reverse_search {
|
||||
for (i, result) in iter.rev() {
|
||||
result.display(i + 1);
|
||||
}
|
||||
} else {
|
||||
for (i, result) in iter {
|
||||
result.display(i + 1);
|
||||
}
|
||||
}
|
||||
use std::env::args;
|
||||
|
||||
use yacexits::{
|
||||
exit,
|
||||
EX_OSERR,
|
||||
EX_SOFTWARE,
|
||||
EX_USAGE,
|
||||
};
|
||||
|
||||
struct AppContext {
|
||||
args: Arguments,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
// TODO implement enum for more graceful exiting
|
||||
async fn select_from_results(
|
||||
_ctx: &AppContext,
|
||||
response: &SearchResponse,
|
||||
) -> anyhow::Result<Vec<usize>> {
|
||||
let input: String = dialoguer::Input::new()
|
||||
.with_prompt("Mods to install (eg: 1 2 3-5)")
|
||||
.interact_text()?;
|
||||
|
||||
let mut selected: Vec<usize> = Vec::new();
|
||||
for token in input.split(" ") {
|
||||
let terms: Vec<&str> = token.split("-").collect();
|
||||
|
||||
match terms.len() {
|
||||
1 => selected.push(terms[0].parse().expect("Token must be an integer")),
|
||||
2 => {
|
||||
let terms: Vec<usize> = terms
|
||||
.iter()
|
||||
.map(|term| term.parse().expect("Term must be an integer"))
|
||||
.collect();
|
||||
let from = terms[0];
|
||||
let to = terms[1];
|
||||
|
||||
for index in from..=to {
|
||||
selected.push(index);
|
||||
}
|
||||
}
|
||||
_ => panic!("Invalid selection token {}", token),
|
||||
}
|
||||
}
|
||||
|
||||
selected.dedup();
|
||||
|
||||
let selected = selected
|
||||
.iter()
|
||||
.map(|index| {
|
||||
if *index < 1 || *index > response.hits.len() {
|
||||
// TODO return useful error instead of panicking
|
||||
panic!("Index {} is out of bounds", index);
|
||||
}
|
||||
|
||||
// input is indexed from 1, but results are indexed from 0
|
||||
let index = index - 1;
|
||||
|
||||
index
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
async fn cmd_get(ctx: &AppContext, search_args: SearchArgs) -> anyhow::Result<()> {
|
||||
let client = HopperClient::new(ctx.config.clone());
|
||||
let response = client.search_mods(&search_args).await?;
|
||||
|
||||
if response.hits.is_empty() {
|
||||
// TODO formatting
|
||||
println!("No results; nothing to do...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
display_search_results(ctx, &response);
|
||||
let selected = select_from_results(ctx, &response).await?;
|
||||
|
||||
if selected.is_empty() {
|
||||
// TODO formatting
|
||||
println!("No packages selected; nothing to do...");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for selection in selected.iter() {
|
||||
let to_get = &response.hits[*selection];
|
||||
let mod_info = client.fetch_mod_info(to_get).await?;
|
||||
|
||||
// TODO allow the user to select multiple versions
|
||||
if let Some(version_id) = mod_info.versions.first() {
|
||||
println!("fetching version {}", version_id);
|
||||
|
||||
let version = client.fetch_mod_version(version_id).await?;
|
||||
for file in version.files.iter() {
|
||||
client.download_version_file(&ctx.args, file).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
fn main() {
|
||||
let argv = args().collect::<Vec<String>>();
|
||||
match rust_main(argv.clone()) {
|
||||
Ok(code) => exit(code),
|
||||
Err((message, code)) => {
|
||||
if code == EX_USAGE {
|
||||
eprintln!("Usage: {} {}", argv[0], message);
|
||||
} else {
|
||||
eprintln!("{}: {}", argv[0], message);
|
||||
}
|
||||
exit(code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
let args = Args::parse();
|
||||
let config = args.load_config()?;
|
||||
let ctx = AppContext { args, config };
|
||||
match ctx.args.to_owned().command {
|
||||
Command::Get(search_args) => cmd_get(&ctx, search_args).await,
|
||||
_ => unimplemented!("unimplemented subcommand"),
|
||||
}
|
||||
async fn rust_main(argv: Vec<String>) -> Result<u32, (String, u32)> {
|
||||
|
||||
let args = Arguments::from_args(argv.clone().iter().map(|s| s.as_str()))
|
||||
.map_err(|err| { ArgsError::from(err) })?;
|
||||
|
||||
let config = Config::read_config()?;
|
||||
let ctx = AppContext { args, config };
|
||||
|
||||
match ctx.args.sub {
|
||||
// Command::Get(search_args) => cmd_get(&ctx, search_args).await,
|
||||
// Command::Init(hopfile_args) => cmd_init(hopfile_args).await,
|
||||
_ => {
|
||||
let message = format!(
|
||||
"{}: Unimplemented subcommand.", ctx.args.sub
|
||||
);
|
||||
Err((message, EX_SOFTWARE))
|
||||
},
|
||||
}?
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue