Compare commits
37 Commits
Author | SHA1 | Date |
---|---|---|
Emma Tebibyte | 132d82680b | |
Emma Tebibyte | 5ae5aa668d | |
Emma Tebibyte | 3c87609720 | |
[ ] | 552d75cbff | |
[ ] | 9064888265 | |
[ ] | f7ba63b492 | |
Emma Tebibyte | 74de0d15b3 | |
Emma Tebibyte | 36d67a1a61 | |
Emma Tebibyte | 015ab836c0 | |
Emma Tebibyte | b2b4b388e9 | |
Emma Tebibyte | 962d11807e | |
Emma Tebibyte | 1a120e816e | |
Emma Tebibyte | 8cbc28a8c1 | |
Emma Tebibyte | 9e51aca6d3 | |
Emma Tebibyte | 4dc5cea89a | |
mars | 716579f824 | |
spookdot | ace6b25381 | |
spookdot | 6b37d007c7 | |
mars | 6ab97922cb | |
Emma Tebibyte | a5d3ff4231 | |
spookdot | f5887df96d | |
Emma Tebibyte | 3d3bf22cab | |
mars | 12cda62da0 | |
Emma Tebibyte | 48e0349848 | |
Emma Tebibyte | e2ae0ee88f | |
Emma Tebibyte | 75d3db9f50 | |
Emma Tebibyte | cf2c8df86b | |
Emma Tebibyte | 9b41e09d46 | |
Spookdot | f5b6afd790 | |
mars | 0d8aaaf2c9 | |
Spookdot | 7d3a1c4a66 | |
Spookdot | d2325fe31f | |
mars | 44a81cf148 | |
mars | ea28b0c904 | |
mars | 8ea2a7777c | |
Marceline Cramer | 6160a9a508 | |
Emma Tebibyte | be54f355c8 |
|
@ -11,15 +11,6 @@ dependencies = [
|
|||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ansi_term"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.47"
|
||||
|
@ -87,17 +78,41 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "2.33.3"
|
||||
version = "3.2.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
|
||||
checksum = "23b71c3ce99b7611011217b366d923f1d0a7e07a92bb2dbf1e84508c673ca3bd"
|
||||
dependencies = [
|
||||
"ansi_term",
|
||||
"atty",
|
||||
"bitflags",
|
||||
"clap_derive",
|
||||
"clap_lex",
|
||||
"indexmap",
|
||||
"once_cell",
|
||||
"strsim",
|
||||
"termcolor",
|
||||
"textwrap",
|
||||
"unicode-width",
|
||||
"vec_map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "3.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
|
||||
dependencies = [
|
||||
"os_str_bytes",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -324,12 +339,9 @@ checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e"
|
|||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.3.3"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
|
@ -345,6 +357,7 @@ name = "hopper"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"confy",
|
||||
"console",
|
||||
"dialoguer",
|
||||
|
@ -355,7 +368,6 @@ dependencies = [
|
|||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"structopt",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
@ -614,9 +626,9 @@ checksum = "17b02fc0ff9a9e4b35b3342880f48e896ebf69f2967921fe8646bf5b7125956a"
|
|||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.8.0"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56"
|
||||
checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
|
@ -651,6 +663,12 @@ dependencies = [
|
|||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "os_str_bytes"
|
||||
version = "6.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ff7415e9ae3fff1225851df9e0d9e4e5479f947619774677a63572e55e80eff"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.11.2"
|
||||
|
@ -989,33 +1007,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
|
||||
|
||||
[[package]]
|
||||
name = "structopt"
|
||||
version = "0.3.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40b9788f4202aa75c240ecc9c15c65185e6a39ccdeb0fd5d008b98825464c87c"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"lazy_static",
|
||||
"structopt-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "structopt-derive"
|
||||
version = "0.4.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro-error",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
|
@ -1063,12 +1057,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "textwrap"
|
||||
version = "0.11.0"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb"
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
|
@ -1196,12 +1187,6 @@ dependencies = [
|
|||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8895849a949e7845e06bd6dc1aa51731a103c42707010a5b591c0038fb73385b"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
version = "0.1.9"
|
||||
|
@ -1232,12 +1217,6 @@ version = "0.2.15"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vec_map"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.3"
|
||||
|
|
|
@ -16,5 +16,5 @@ log = "0.4.14"
|
|||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
structopt = "0.3"
|
||||
clap = { version = "3.2.20", features = ["derive"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
|
331
README.md
331
README.md
|
@ -1,118 +1,277 @@
|
|||
```
|
||||
___---___
|
||||
___--- | ---___
|
||||
--- ___---___ ---
|
||||
|---___--- ---___---|
|
||||
| ---___ ___--- |
|
||||
---___ | ___---
|
||||
|---___|___---|
|
||||
--__ - __--
|
||||
|-_-|
|
||||
-_-
|
||||
__ __
|
||||
/ / / /___ ____ ____ ___ _____
|
||||
/ /_/ / __ \/ __ \/ __ \/ _ \/ ___/
|
||||
/ __ / /_/ / /_/ / /_/ / __/ /
|
||||
/_/ /_/\____/ .___/ .___/\___/_/
|
||||
/_/ /_/
|
||||
```
|
||||
|
||||
# Hopper
|
||||
|
||||
A Minecraft mod manager for the terminal.
|
||||
A Minecraft package manager for the terminal.
|
||||
|
||||
Hopper can automatically search, download, and update Minecraft mods from 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 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 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
|
||||
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 and released under the AGPLv3.
|
||||
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).
|
||||
|
||||
We're looking for people to help contribute code, design the terminal interface, write documentation, and design a logo. Please reach out to us in [our Discord server](https://discord.gg/JWRFAbve9M) if you're interested in helping out. If you have a taste in CLI-based apps like Hopper, your input is especially appreciated.
|
||||
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 CLI apps like:
|
||||
- [paru](https://github.com/morganamilo/paru): Feature packed AUR helper
|
||||
- [topgrade](https://github.com/r-darwish/topgrade): Upgrade everything
|
||||
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
|
||||
|
||||
### Donate
|
||||
|
||||
<noscript><a href="https://liberapay.com/tebibytemedia/donate"><img alt="Donate using Liberapay" src="https://liberapay.com/assets/widgets/donate.svg"></a></noscript>
|
||||
[![Donate using
|
||||
Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/tebibytemedia/donate)
|
||||
|
||||
# High-level Goals
|
||||
|
||||
Continuous:
|
||||
- small binary size
|
||||
- minimal compile times
|
||||
## Continuous
|
||||
- Small binary size
|
||||
- Minimal compile times
|
||||
|
||||
Features:
|
||||
- modrinth mod searching
|
||||
- modrinth mod installation
|
||||
- curseforge api too?
|
||||
- per-instance mod management
|
||||
- mod updating
|
||||
- fish autocomplete
|
||||
- bash autocomplete
|
||||
- zsh autocomplete
|
||||
- nushell autocomplete
|
||||
- manpage
|
||||
- configurable mod search result display like [Starship](https://starship.rs)
|
||||
- `display` command or something that displays (cached?) mod info
|
||||
- parallel mod downloading
|
||||
## Features
|
||||
|
||||
Long-term/host-dependent:
|
||||
- conflict resolution
|
||||
- dependency resolution
|
||||
- shaderpack and resource pack management
|
||||
- integrate into multimc or theseus
|
||||
- graphical frontend (w/ notifications?)
|
||||
### High Priority:
|
||||
- Modrinth package searching
|
||||
- Modrinth package installation
|
||||
- Parallel package downloading
|
||||
- Per-instance package management
|
||||
- Package updating
|
||||
- Listing installed packages
|
||||
|
||||
[Modrinth REST API docs](https://github.com/modrinth/labrinth/wiki/API-Documentation)
|
||||
### Medium Priority
|
||||
- CurseForge package searching
|
||||
- CurseForge package installation
|
||||
- A `man(1)` entry
|
||||
|
||||
# File Architecture
|
||||
### Low Priority:
|
||||
- Shell autocomplete
|
||||
- Configurable search result display like [Starship](https://starship.rs)
|
||||
- Version-control system repository package management & compilation
|
||||
|
||||
```bash
|
||||
- .config/hopper/config.toml # Main config file
|
||||
- .local/share/multimc/instances/*/.minecraft/Hopfile.toml # Multimc
|
||||
- .minecraft/Hopfile.toml # Official launcher
|
||||
- .var/app/com.mojang.Minecraft/.minecraft/Hopfile.toml # Flatpak version
|
||||
- .cache/hopper/ # Mod cache
|
||||
| - hopper.lock # Lock file
|
||||
| - mod1.jar # Mods
|
||||
| - mod2.jar
|
||||
+------------- - ...
|
||||
```
|
||||
### External-Dependent:
|
||||
- Conflict resolution
|
||||
- Dependency resolution
|
||||
- Integration into [Prism Launcher](https://prismlauncher.org/) and/or
|
||||
[theseus](https://github.com/modrinth/theseus)
|
||||
- Integration into `topgrade(1)`
|
||||
- Graphical frontend with notifications
|
||||
|
||||
# Usage (Planned)
|
||||
[Modrinth REST API
|
||||
docs](https://docs.modrinth.com/api-spec/)
|
||||
|
||||
Create `Hopfile.toml` in your instance directory:
|
||||
```
|
||||
hopper init
|
||||
```
|
||||
|
||||
Add mods:
|
||||
```
|
||||
hopper add iris
|
||||
hopper add sodium
|
||||
hopper add phosphor
|
||||
```
|
||||
|
||||
Check for mod updates:
|
||||
```
|
||||
hopper update
|
||||
```
|
||||
|
||||
# Docs (Planned)
|
||||
|
||||
## `hopper init`
|
||||
# File Structure
|
||||
|
||||
```
|
||||
hopper init < --dir=./path/to/instance >
|
||||
├── "$XDG_CONFIG_HOME"/hopper.toml
|
||||
├── "$XDG_CACHE_HOME"/hopper/
|
||||
│ ├── 1.19.1/
|
||||
│ │ └── fabric/
|
||||
│ └── 1.18.2/
|
||||
│ ├── forge/
|
||||
│ └── plugin/
|
||||
└── "XDG_DATA_HOME"/templates/
|
||||
└── example-template.hop -> ~/.minecraft/mods/example-template.hop
|
||||
```
|
||||
|
||||
Inits in current directory if `dir` is left out, otherwise inits in given dir.
|
||||
# Hopfile Structure
|
||||
|
||||
## `hopper update`
|
||||
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
|
||||
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.
|
||||
|
||||
```
|
||||
hopper update < --mc-version=1.17 >
|
||||
template = "example-template"
|
||||
mc-version = "1.19.2"
|
||||
|
||||
[packages]
|
||||
fabric-mod = [ "sodium", "lithium" ]
|
||||
resource = "alacrity"
|
||||
```
|
||||
|
||||
Updates all installed mods of a specific version, or a version set in the config.
|
||||
# Hopper Configuration File Structure
|
||||
|
||||
## `hopper add`
|
||||
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
|
||||
custom) build file format to be defined at a later date.
|
||||
|
||||
```
|
||||
$ hopper add sodium --mc-version 1.17
|
||||
4 Indium 1.0.0+mc1.17.1 [1.17.1] (21557 downloads)
|
||||
Sodium addon providing support for the Fabric Rendering API, based on Indigo
|
||||
3 Reese's Sodium Options 1.2.1 [1.16.5] (548 downloads)
|
||||
Alternative Options Menu for Sodium
|
||||
2 Sodium Extra mc1.17.1-0.3.6 [1.17.1] (16387 downloads)
|
||||
Features that shouldn't be in Sodium.
|
||||
1 Sodium mc1.17.1-0.3.2 [1.17.1] (962361 downloads)
|
||||
Modern rendering engine and client-side optimization mod for Minecraft
|
||||
:: Select a mod
|
||||
:: ...
|
||||
hopfiles = [
|
||||
"~/.minecraft/mods/template.hop",
|
||||
"~/.minecraft/1.91.1/mods/1.19.1.hop"
|
||||
]
|
||||
|
||||
[sources]
|
||||
modrinth = "https://api.modrinth.com/"
|
||||
curseforge = "https://api.curseforge.com/"
|
||||
git = [
|
||||
"git+https://github.com/IrisShaders/Iris.git"
|
||||
"git+https://github.com/CaffeineMC/sodium-fabric.git"
|
||||
]
|
||||
```
|
||||
|
||||
## `hopper get`
|
||||
# Docs
|
||||
|
||||
Just like `hopper add` but simply downloads a mod jar to the current directory.
|
||||
## Types
|
||||
|
||||
There are multiple types of packages hopper can manage.
|
||||
|
||||
### Mods
|
||||
- `fabric-mod`
|
||||
- `forge-mod`
|
||||
- `quilt-mod`
|
||||
|
||||
### Plugins
|
||||
- `bukkit-plugin`
|
||||
- `paper-plugin`
|
||||
- `purpur-plugin`
|
||||
- `spigot-plugin`
|
||||
- `sponge-plugin`
|
||||
|
||||
### Other
|
||||
- `data-pack`
|
||||
- `fabric-pack`
|
||||
- `forge-pack`
|
||||
- `resource-pack`
|
||||
- `quilt-pack`
|
||||
|
||||
These types are specified in various hopper subcommands and in its configuration.
|
||||
|
||||
## Usage
|
||||
|
||||
`hopper [options...] [subcommand...]`
|
||||
|
||||
## OPTIONS
|
||||
|
||||
`-v`, `--verbose`
|
||||
|
||||
 Includes debug information in the output of `hopper` subcommands.
|
||||
|
||||
## SUBCOMMANDS
|
||||
|
||||
`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
|
||||
displayed in order of specification.
|
||||
|
||||
OPTIONS
|
||||
|
||||
 `-d`, `--dir [directory...]`
|
||||
|
||||
  Specifies the directory to download to (default is
|
||||
"$XDG_CACHE_HOME"/hopper/).
|
||||
|
||||
 `-m`, `--mc-version [version...]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being
|
||||
retrieved.
|
||||
|
||||
 `-n`, `--no-confirm`
|
||||
|
||||
  Does not display search results and downloads exact matches to the
|
||||
cache. Requires `--mc-version` and `--type` be specified.
|
||||
|
||||
 `-t`, `--type [types...]`
|
||||
|
||||
  Specifies what types of packages are being queried.
|
||||
|
||||
`init [options...]`
|
||||
|
||||
 Creates a hopfile in the current directory and adds it to the global known
|
||||
hopfiles list.
|
||||
|
||||
OPTIONS
|
||||
|
||||
 `-d`, `--dir [directory...]`
|
||||
|
||||
  Specifies the directory in which the hopfile is being created.
|
||||
|
||||
 `-f`, `--hopfile [hopfiles...]`
|
||||
|
||||
  Specifies templates upon which to base the new hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being managed.
|
||||
|
||||
 `-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...]`
|
||||
|
||||
 Lists all installed packages.
|
||||
|
||||
OPTIONS
|
||||
|
||||
  `-f` `--hopfile [hopfiles...]`
|
||||
|
||||
  Lists packages installed in a specified hopfile.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being managed.
|
||||
|
||||
 `-t`, `--type [types...]`
|
||||
|
||||
  List all packages of a specified type.
|
||||
|
||||
`update [options...]`
|
||||
|
||||
 Updates installed packages and adds mods if they're missing to directories
|
||||
with known hopfiles.
|
||||
|
||||
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.
|
||||
|
||||
 `-m`, `--mc-version [version]`
|
||||
|
||||
  Specifies for what version of Minecraft packages are being updated.
|
||||
|
||||
 `-t`, `--type [types...] [packages...]`
|
||||
|
||||
  Updates only packages of a specified type. Optionally takes a list
|
||||
of packages as an argument.
|
||||
|
|
106
src/api.rs
106
src/api.rs
|
@ -1,6 +1,6 @@
|
|||
use console::style;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, fmt};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct SearchResponse {
|
||||
|
@ -12,24 +12,25 @@ pub struct SearchResponse {
|
|||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModResult {
|
||||
pub mod_id: String, // TODO parse to `local-xxxxx` with regex
|
||||
pub project_type: Option<String>, // NOTE this isn't in all search results?
|
||||
pub author: String,
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub categories: Vec<String>,
|
||||
pub versions: 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 page_url: String,
|
||||
pub icon_url: String,
|
||||
pub author_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 client_side: String,
|
||||
pub server_side: String,
|
||||
pub host: String,
|
||||
pub gallery: Vec<String>,
|
||||
}
|
||||
|
||||
impl ModResult {
|
||||
|
@ -59,28 +60,43 @@ impl ModResult {
|
|||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModInfo {
|
||||
pub id: String, // TODO serialize mod id?
|
||||
pub slug: String,
|
||||
pub team: String, // TODO serialize team id?
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub body: String,
|
||||
pub published: String, // TODO serialize datetime
|
||||
pub updated: String, // TODO serialize datetime
|
||||
pub status: String,
|
||||
pub license: License,
|
||||
pub client_side: String, // TODO serialize as enum
|
||||
pub server_side: String, // TODO serialize as enum
|
||||
pub downloads: isize,
|
||||
pub followers: isize,
|
||||
pub categories: Vec<String>,
|
||||
pub versions: Vec<String>,
|
||||
pub icon_url: Option<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: Vec<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>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct GalleryEntry {
|
||||
pub url: String,
|
||||
pub featured: bool,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
|
@ -90,24 +106,30 @@ pub struct License {
|
|||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DonationLink {
|
||||
pub id: String,
|
||||
pub platform: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ModVersion {
|
||||
pub id: String, // version id
|
||||
pub mod_id: String, // mod id
|
||||
pub author_id: String, // user id
|
||||
// NOTE modrinth docs list this as a String, but is actually a bool?
|
||||
// featured: String, // user id
|
||||
pub name: String,
|
||||
pub version_number: String,
|
||||
pub changelog: Option<String>,
|
||||
pub changelog_url: 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 version_type: String, // TODO {alpha | beta | release}
|
||||
pub changelog_url: Option<String>, // NOTE deprecated
|
||||
pub files: Vec<ModVersionFile>,
|
||||
pub dependencies: Vec<String>, // TODO dependency wrangling, thank you modrinth, very cool
|
||||
pub game_versions: Vec<String>,
|
||||
pub loaders: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
|
@ -115,4 +137,20 @@ pub struct ModVersionFile {
|
|||
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,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.error, self.description)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
|
|
@ -0,0 +1,166 @@
|
|||
use crate::api::{ModInfo, ModResult, ModVersion, ModVersionFile, SearchResponse, Error as APIError};
|
||||
use crate::config::{Args, Config, PackageType, SearchArgs};
|
||||
use futures_util::StreamExt;
|
||||
use log::*;
|
||||
use std::cmp::min;
|
||||
use std::io::Write;
|
||||
|
||||
pub struct HopperClient {
|
||||
config: Config,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl HopperClient {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self {
|
||||
config: config,
|
||||
client: reqwest::ClientBuilder::new()
|
||||
.user_agent(format!("tebibytemedia/hopper/{} (tebibyte.media)", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_mods(&self, search_args: &SearchArgs) -> anyhow::Result<SearchResponse> {
|
||||
println!("Searching with query \"{}\"...", search_args.package_name);
|
||||
|
||||
let url = format!("https://{}/v2/search", self.config.upstream.server_address);
|
||||
|
||||
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 {
|
||||
let versions_facets = versions
|
||||
.iter()
|
||||
.map(|e| format!("[\"versions:{}\"]", e))
|
||||
.collect::<Vec<String>>()
|
||||
.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();
|
||||
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(())
|
||||
}
|
||||
}
|
|
@ -1,51 +1,68 @@
|
|||
use clap::{Parser, Subcommand, ValueEnum};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use structopt::StructOpt;
|
||||
|
||||
// TODO parameter to restrict target Minecraft version
|
||||
#[derive(StructOpt, Clone, Debug)]
|
||||
#[derive(clap::Args, Clone, Debug)]
|
||||
pub struct SearchArgs {
|
||||
pub package_name: String,
|
||||
|
||||
/// Type of package to use
|
||||
#[clap(short, long, value_enum)]
|
||||
pub package_type: Option<PackageType>,
|
||||
|
||||
/// Restricts the target Minecraft version
|
||||
#[structopt(short, long)]
|
||||
#[clap(short, long)]
|
||||
pub version: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
// TODO use ColoredHelp by default?
|
||||
#[derive(StructOpt, Clone, Debug)]
|
||||
#[derive(Subcommand, Clone, Debug)]
|
||||
pub enum Command {
|
||||
/// Adds a mod to the current instance
|
||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
Add(SearchArgs),
|
||||
/// Removes a mod
|
||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
Remove { package_name: String },
|
||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
Remove {
|
||||
package_name: String,
|
||||
},
|
||||
Get(SearchArgs),
|
||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
Update,
|
||||
#[structopt(setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
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(StructOpt, Clone, Debug)]
|
||||
#[structopt(name = "hopper", setting = structopt::clap::AppSettings::ColoredHelp)]
|
||||
#[derive(Parser, Clone, Debug)]
|
||||
#[clap(name = "hopper")]
|
||||
pub struct Args {
|
||||
/// Path to configuration file
|
||||
#[structopt(short, long, parse(from_os_str))]
|
||||
#[clap(short, long, value_parser)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Path to mod lockfile
|
||||
#[structopt(short, long, parse(from_os_str))]
|
||||
#[clap(short, long, value_parser)]
|
||||
pub lockfile: Option<PathBuf>,
|
||||
|
||||
/// Auto-accept confirmation dialogues
|
||||
#[structopt(short = "y", long = "yes")]
|
||||
#[clap(short = 'y', long = "yes")]
|
||||
pub auto_accept: bool,
|
||||
|
||||
#[structopt(subcommand)]
|
||||
#[clap(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
|
@ -59,7 +76,7 @@ impl Args {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct Upstream {
|
||||
/// Modrinth main server address
|
||||
pub server_address: String,
|
||||
|
@ -73,7 +90,7 @@ impl Default for Upstream {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct Options {
|
||||
/// Whether to reverse search results
|
||||
pub reverse_search: bool,
|
||||
|
@ -87,7 +104,7 @@ impl Default for Options {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug, Default)]
|
||||
#[derive(Deserialize, Serialize, Debug, Default, Clone)]
|
||||
pub struct Config {
|
||||
/// General settings
|
||||
pub options: Options,
|
||||
|
|
162
src/main.rs
162
src/main.rs
|
@ -1,35 +1,12 @@
|
|||
use futures_util::StreamExt;
|
||||
use log::*;
|
||||
use std::cmp::min;
|
||||
use std::io::Write;
|
||||
use structopt::StructOpt;
|
||||
|
||||
mod api;
|
||||
mod client;
|
||||
mod config;
|
||||
|
||||
use api::*;
|
||||
use clap::Parser;
|
||||
use client::*;
|
||||
use config::*;
|
||||
|
||||
async fn search_mods(ctx: &AppContext, search_args: &SearchArgs) -> anyhow::Result<SearchResponse> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://{}/api/v1/mod", ctx.config.upstream.server_address);
|
||||
|
||||
let mut params = vec![("query", search_args.package_name.to_owned())];
|
||||
if let Some(versions) = &search_args.version {
|
||||
params.push(("versions", versions.join(",")));
|
||||
}
|
||||
|
||||
let url = reqwest::Url::parse_with_params(url.as_str(), ¶ms)?;
|
||||
info!("GET {}", url);
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await?
|
||||
.json::<SearchResponse>()
|
||||
.await?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
||||
let iter = response.hits.iter().enumerate();
|
||||
if ctx.config.options.reverse_search {
|
||||
|
@ -44,111 +21,59 @@ fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
|||
}
|
||||
|
||||
// TODO implement enum for more graceful exiting
|
||||
async fn select_from_results<'a>(
|
||||
async fn select_from_results(
|
||||
_ctx: &AppContext,
|
||||
response: &'a SearchResponse,
|
||||
) -> anyhow::Result<Vec<&'a ModResult>> {
|
||||
response: &SearchResponse,
|
||||
) -> anyhow::Result<Vec<usize>> {
|
||||
let input: String = dialoguer::Input::new()
|
||||
.with_prompt("Mods to install (eg: 1 2 3)")
|
||||
.with_prompt("Mods to install (eg: 1 2 3-5)")
|
||||
.interact_text()?;
|
||||
|
||||
let mut selected: Vec<usize> = Vec::new();
|
||||
for token in input.split(" ") {
|
||||
// TODO range input (eg: 1-3)
|
||||
let index: usize = token.parse().expect("Token must be an integer");
|
||||
if index < 1 || index > response.hits.len() {
|
||||
// TODO return useful error instead of panicking
|
||||
panic!("Index {} is out of bounds", index);
|
||||
}
|
||||
let terms: Vec<&str> = token.split("-").collect();
|
||||
|
||||
// input is indexed from 1, but results are indexed from 0
|
||||
let index = index - 1;
|
||||
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];
|
||||
|
||||
if !selected.contains(&index) {
|
||||
selected.push(index);
|
||||
} else {
|
||||
// TODO make this a proper warning log message
|
||||
println!("warning: repeated index {}", index);
|
||||
for index in from..=to {
|
||||
selected.push(index);
|
||||
}
|
||||
}
|
||||
_ => panic!("Invalid selection token {}", token),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(selected.iter().map(|i| &response.hits[*i]).collect())
|
||||
}
|
||||
selected.dedup();
|
||||
|
||||
async fn fetch_mod_info(ctx: &AppContext, mod_result: &ModResult) -> anyhow::Result<ModInfo> {
|
||||
let client = reqwest::Client::new();
|
||||
let mod_id = &mod_result.mod_id;
|
||||
let mod_id = mod_id[6..].to_owned(); // Remove "local-" prefix
|
||||
let url = format!(
|
||||
"https://{}/api/v1/mod/{}",
|
||||
ctx.config.upstream.server_address, mod_id
|
||||
);
|
||||
let response = client.get(url).send().await?;
|
||||
let response = response.json::<ModInfo>().await?;
|
||||
Ok(response)
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
async fn fetch_mod_version(ctx: &AppContext, version_id: &String) -> anyhow::Result<ModVersion> {
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!(
|
||||
"https://{}/api/v1/version/{}",
|
||||
ctx.config.upstream.server_address, version_id
|
||||
);
|
||||
let response = client.get(url).send().await?;
|
||||
let response = response.json::<ModVersion>().await?;
|
||||
Ok(response)
|
||||
}
|
||||
// input is indexed from 1, but results are indexed from 0
|
||||
let index = index - 1;
|
||||
|
||||
async fn download_version_file(ctx: &AppContext, file: &ModVersionFile) -> anyhow::Result<()> {
|
||||
// TODO replace all uses of .unwrap() with proper error codes
|
||||
let filename = &file.filename;
|
||||
index
|
||||
})
|
||||
.collect();
|
||||
|
||||
// TODO make confirmation skippable with flag argument
|
||||
if !ctx.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 client = reqwest::Client::new();
|
||||
let url = &file.url;
|
||||
let response = client.get(url).send().await?;
|
||||
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(())
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
async fn cmd_get(ctx: &AppContext, search_args: SearchArgs) -> anyhow::Result<()> {
|
||||
let response = search_mods(ctx, &search_args).await?;
|
||||
let client = HopperClient::new(ctx.config.clone());
|
||||
let response = client.search_mods(&search_args).await?;
|
||||
|
||||
if response.hits.is_empty() {
|
||||
// TODO formatting
|
||||
|
@ -165,16 +90,17 @@ async fn cmd_get(ctx: &AppContext, search_args: SearchArgs) -> anyhow::Result<()
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
for to_get in selected.iter() {
|
||||
let mod_info = fetch_mod_info(ctx, to_get).await?;
|
||||
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 = fetch_mod_version(ctx, version_id).await?;
|
||||
let version = client.fetch_mod_version(version_id).await?;
|
||||
for file in version.files.iter() {
|
||||
download_version_file(ctx, file).await?;
|
||||
client.download_version_file(&ctx.args, file).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -185,7 +111,7 @@ async fn cmd_get(ctx: &AppContext, search_args: SearchArgs) -> anyhow::Result<()
|
|||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::init();
|
||||
let args = Args::from_args();
|
||||
let args = Args::parse();
|
||||
let config = args.load_config()?;
|
||||
let ctx = AppContext { args, config };
|
||||
match ctx.args.to_owned().command {
|
||||
|
|
Loading…
Reference in New Issue