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>
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
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
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
by the Free Software Foundation, either version 3 of the License, or
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
(at your option) any later version.
|
(at your option) any later version.
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
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"
|
version = "0.1.0"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
edition = "2021"
|
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]
|
[dependencies]
|
||||||
anyhow = "1.0"
|
arg = "0.4.1"
|
||||||
confy = "0.4"
|
|
||||||
console = "0.15.0"
|
console = "0.15.0"
|
||||||
|
curl = "0.4.44"
|
||||||
dialoguer = "0.9.0"
|
dialoguer = "0.9.0"
|
||||||
env_logger = "0.9.0"
|
|
||||||
futures-util = "0.3.18"
|
futures-util = "0.3.18"
|
||||||
indicatif = "0.15.0"
|
indicatif = "0.15.0"
|
||||||
log = "0.4.14"
|
|
||||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
clap = { version = "3.2.20", features = ["derive"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
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
|
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
|
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/)
|
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
|
with the hassle of swapping out different mod versions for hours while trying to
|
||||||
get Minecraft to accept them all at once.
|
get Minecraft to accept them all at once.
|
||||||
|
|
||||||
Hopper is still very early in development, but important features are coming
|
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.
|
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
|
It’s written in [Rust](https://www.rust-lang.org/) and released under the
|
||||||
[AGPLv3](LICENSE).
|
[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
|
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
|
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,
|
you’re interested in helping out. If you have a taste in CLI apps like Hopper,
|
||||||
your input is especially appreciated.
|
your input is especially appreciated.
|
||||||
|
|
||||||
Inspired by applications like [paru](https://github.com/morganamilo/paru), a
|
Inspired by applications like [paru](https://github.com/morganamilo/paru), a
|
||||||
feature-packed AUR helper and [topgrade](https://github.com/r-darwish/topgrade),
|
feature-packed AUR helper and [topgrade](https://github.com/r-darwish/topgrade),
|
||||||
a tool to upgrade everything
|
a tool to upgrade everything.
|
||||||
|
|
||||||
[![Donate using
|
[![Donate using
|
||||||
Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/tebibytemedia/donate)
|
Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/tebibytemedia/donate)
|
||||||
|
|
||||||
# High-level Goals
|
## High-level Goals
|
||||||
|
|
||||||
## Continuous
|
### Continuous
|
||||||
- Small binary size
|
- Small binary size
|
||||||
- Minimal compile times
|
- Minimal compile times
|
||||||
|
|
||||||
## Features
|
### Features
|
||||||
|
|
||||||
### High Priority:
|
High Priority:
|
||||||
- Modrinth package searching
|
- Modrinth package searching
|
||||||
- Modrinth package installation
|
- Modrinth package installation
|
||||||
- Parallel package downloading
|
- Parallel package downloading
|
||||||
|
@ -62,17 +62,17 @@ Liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.c
|
||||||
- Package updating
|
- Package updating
|
||||||
- Listing installed packages
|
- Listing installed packages
|
||||||
|
|
||||||
### Medium Priority
|
Medium Priority
|
||||||
- CurseForge package searching
|
- CurseForge package searching
|
||||||
- CurseForge package installation
|
- CurseForge package installation
|
||||||
- A `man(1)` entry
|
- A `man(1)` entry
|
||||||
|
|
||||||
### Low Priority:
|
Low Priority:
|
||||||
- Shell autocomplete
|
- Shell autocomplete
|
||||||
- Configurable search result display like [Starship](https://starship.rs)
|
- Configurable search result display like [Starship](https://starship.rs)
|
||||||
- Version-control system repository package management & compilation
|
- Version-control system repository package management & compilation
|
||||||
|
|
||||||
### External-Dependent:
|
External-Dependent:
|
||||||
- Conflict resolution
|
- Conflict resolution
|
||||||
- Dependency resolution
|
- Dependency resolution
|
||||||
- Integration into [Prism Launcher](https://prismlauncher.org/) and/or
|
- 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
|
- Graphical frontend with notifications
|
||||||
|
|
||||||
[Modrinth REST API
|
[Modrinth REST API
|
||||||
docs](https://docs.modrinth.com/api-spec/)
|
documentation](https://docs.modrinth.com/api-spec/)
|
||||||
|
|
||||||
# File Structure
|
# File Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
├── "$XDG_CONFIG_HOME"/hopper.toml
|
├── "$XDG_CONFIG_HOME"/hopper.toml
|
||||||
├── "$XDG_CACHE_HOME"/hopper/
|
├── "$XDG_DATA_HOME"/hopper/
|
||||||
│ ├── 1.19.1/
|
│ ├── 1.19.1/
|
||||||
│ │ └── fabric/
|
│ │ └── fabric/
|
||||||
│ └── 1.18.2/
|
│ └── 1.18.2/
|
||||||
│ ├── forge/
|
│ ├── forge/
|
||||||
│ └── plugin/
|
│ └── plugin/
|
||||||
└── "XDG_DATA_HOME"/templates/
|
└── "$XDG_DATA_HOME"/templates/
|
||||||
└── example-template.hop -> ~/.minecraft/mods/example-template.hop
|
└── example-template.hop -> ~/.minecraft/mods/example-template.hop
|
||||||
```
|
```
|
||||||
|
|
||||||
# Hopfile Structure
|
# Hopfile Structure
|
||||||
|
|
||||||
Hopfiles will contain a Minecraft version number, a list of packages, and any
|
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
|
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.
|
hopfile does not inherit the package or Minecraft version from a template.
|
||||||
|
|
||||||
|
@ -115,7 +115,7 @@ resource = "alacrity"
|
||||||
|
|
||||||
# Hopper Configuration File Structure
|
# 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
|
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
|
Modrinth and CurseForge and a list of (remote or local) version-control
|
||||||
repositories from which to compile mods. The latter will use a (potentially
|
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.
|
There are multiple types of packages hopper can manage.
|
||||||
|
|
||||||
### Mods
|
#### Mods
|
||||||
- `fabric-mod`
|
- `fabric-mod`
|
||||||
- `forge-mod`
|
- `forge-mod`
|
||||||
- `quilt-mod`
|
- `quilt-mod`
|
||||||
|
|
||||||
### Plugins
|
#### Modpacks
|
||||||
|
- `fabric-pack`
|
||||||
|
- `forge-pack`
|
||||||
|
- `quilt-pack`
|
||||||
|
|
||||||
|
#### Plugins
|
||||||
- `bukkit-plugin`
|
- `bukkit-plugin`
|
||||||
- `paper-plugin`
|
- `paper-plugin`
|
||||||
- `purpur-plugin`
|
- `purpur-plugin`
|
||||||
- `spigot-plugin`
|
- `spigot-plugin`
|
||||||
- `sponge-plugin`
|
- `sponge-plugin`
|
||||||
|
|
||||||
### Other
|
#### Other
|
||||||
- `data-pack`
|
- `data-pack` (planned)
|
||||||
- `fabric-pack`
|
|
||||||
- `forge-pack`
|
|
||||||
- `resource-pack`
|
- `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...]`
|
`hopper [options...] [subcommand...]`
|
||||||
|
|
||||||
## OPTIONS
|
### Options
|
||||||
|
|
||||||
`-v`, `--verbose`
|
`-v`, `--verbose`
|
||||||
|
|
||||||
 Includes debug information in the output of `hopper` subcommands.
|
 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
|
 Searches for packages, displays the results, and downloads any selected
|
||||||
packages to the local cache. If multiple targets are specified, results are
|
packages to the local cache. If multiple targets are specified, results are
|
||||||
|
@ -186,7 +204,7 @@ OPTIONS
|
||||||
 `-d`, `--dir [directory...]`
|
 `-d`, `--dir [directory...]`
|
||||||
|
|
||||||
  Specifies the directory to download to (default is
|
  Specifies the directory to download to (default is
|
||||||
"$XDG_CACHE_HOME"/hopper/).
|
`"$XDG_DATA_HOME"/hopper/`).
|
||||||
|
|
||||||
 `-m`, `--mc-version [version...]`
|
 `-m`, `--mc-version [version...]`
|
||||||
|
|
||||||
|
@ -202,7 +220,7 @@ cache. Requires `--mc-version` and `--type` be specified.
|
||||||
|
|
||||||
  Specifies what types of packages are being queried.
|
  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
|
 Creates a hopfile in the current directory and adds it to the global known
|
||||||
hopfiles list.
|
hopfiles list.
|
||||||
|
@ -217,26 +235,16 @@ OPTIONS
|
||||||
|
|
||||||
  Specifies templates upon which to base the new hopfile.
|
  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...]`
|
 `-t`, `--type [type...]`
|
||||||
|
|
||||||
  Specifies what type of packages will be listed in this hopfile.
|
  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
|
### `list [options...]`
|
||||||
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.
|
 Lists all installed packages.
|
||||||
|
|
||||||
|
@ -246,7 +254,7 @@ OPTIONS
|
||||||
|
|
||||||
  Lists packages installed in a specified hopfile.
|
  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.
|
  Specifies for what version of Minecraft packages are being managed.
|
||||||
|
|
||||||
|
@ -254,22 +262,40 @@ OPTIONS
|
||||||
|
|
||||||
  List all packages of a specified type.
|
  List all packages of a specified type.
|
||||||
|
|
||||||
`update [options...]`
|
### `remove [options...] [packages...]`
|
||||||
|
|
||||||
 Updates installed packages and adds mods if they're missing to directories
|
 Uninstalls packages.
|
||||||
with known hopfiles.
|
|
||||||
|
|
||||||
OPTIONS
|
OPTIONS
|
||||||
|
|
||||||
 `-f`, `--hopfile [hopfiles...]`
|
 `-f`, `--hopfile [hopfiles...]`
|
||||||
|
|
||||||
  Updates only packages in the specified hopfile. Note that this
|
  Removes only packages in the specified hopfile.
|
||||||
option creates a new file and symlink as it does not update the packages for
|
|
||||||
other hopfiles.
|
|
||||||
|
|
||||||
 `-m`, `--mc-version [version]`
|
 `-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...]`
|
 `-t`, `--type [types...] [packages...]`
|
||||||
|
|
||||||
|
|
40
src/api.rs
40
src/api.rs
|
@ -1,3 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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 console::style;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::{ collections::HashMap, fmt };
|
use std::{ collections::HashMap, fmt };
|
||||||
|
@ -16,13 +36,19 @@ pub struct ModResult {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub categories: Vec<String>,
|
pub categories: Vec<String>,
|
||||||
pub display_categories: Vec<String>, // NOTE this is not in the OpenAPI docs
|
|
||||||
|
// NOTE this is not in the OpenAPI docs
|
||||||
|
pub display_categories: Vec<String>,
|
||||||
pub client_side: String,
|
pub client_side: String,
|
||||||
pub server_side: String,
|
pub server_side: String,
|
||||||
pub project_type: String, // NOTE this isn't in all search results?
|
|
||||||
|
// NOTE this isn't in all search results?
|
||||||
|
pub project_type: String,
|
||||||
pub downloads: isize,
|
pub downloads: isize,
|
||||||
pub icon_url: String,
|
pub icon_url: String,
|
||||||
pub project_id: String, // TODO parse to 'local-xxxx' with reegex
|
|
||||||
|
// TODO parse to 'local-xxxx' with regex
|
||||||
|
pub project_id: String,
|
||||||
pub author: String,
|
pub author: String,
|
||||||
pub versions: Vec<String>,
|
pub versions: Vec<String>,
|
||||||
pub follows: isize,
|
pub follows: isize,
|
||||||
|
@ -82,7 +108,9 @@ pub struct ModInfo {
|
||||||
pub moderator_message: Option<String>,
|
pub moderator_message: Option<String>,
|
||||||
pub published: String, // TODO serialize as datetime
|
pub published: String, // TODO serialize as datetime
|
||||||
pub updated: 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
|
|
||||||
|
// NOTE not listed in OpenAPI docs, TODO serialize as datetime
|
||||||
|
pub approved: Option<String>,
|
||||||
pub followers: isize,
|
pub followers: isize,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub license: License,
|
pub license: License,
|
||||||
|
@ -118,7 +146,9 @@ pub struct ModVersion {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub version_number: String,
|
pub version_number: String,
|
||||||
pub changelog: Option<String>,
|
pub changelog: Option<String>,
|
||||||
// pub dependencies: Option<Vec<String>>, // TODO dependency wrangling, thank you modrinth, very cool
|
|
||||||
|
// TODO dependency wrangling, thank you modrinth, very cool
|
||||||
|
// pub dependencies: Option<Vec<String>>,
|
||||||
pub game_versions: Vec<String>,
|
pub game_versions: Vec<String>,
|
||||||
pub version_type: String, // TODO {alpha | beta | release}
|
pub version_type: String, // TODO {alpha | beta | release}
|
||||||
pub loaders: Vec<String>,
|
pub loaders: Vec<String>,
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
225
src/client.rs
225
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};
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
use futures_util::StreamExt;
|
* Copyright (c) 2022–2023 Emma Tebibyte <emma@tebibyte.media>
|
||||||
use log::*;
|
* 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::cmp::min;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
|
use curl::easy::{ Easy };
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
pub struct HopperClient {
|
pub struct HopperClient {
|
||||||
config: Config,
|
config: Config,
|
||||||
client: reqwest::Client,
|
client: Easy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HopperClient {
|
impl HopperClient {
|
||||||
pub fn new(config: Config) -> Self {
|
pub fn new(config: Config) -> Self {
|
||||||
|
curl::init();
|
||||||
Self {
|
Self {
|
||||||
config: config,
|
config: config,
|
||||||
client: reqwest::ClientBuilder::new()
|
client: Easy::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> {
|
pub async fn search_mods(
|
||||||
println!("Searching with query \"{}\"...", search_args.package_name);
|
&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 params = vec![("query", search_args.package_name.to_owned())];
|
||||||
let mut facets: Vec<String> = Vec::new();
|
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
|
let versions_facets = versions
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| format!("[\"versions:{}\"]", e))
|
.map(|e| format!("[\"versions:{}\"]", e))
|
||||||
|
@ -36,131 +81,47 @@ impl HopperClient {
|
||||||
.join(",");
|
.join(",");
|
||||||
facets.push(format!("{}", versions_facets));
|
facets.push(format!("{}", versions_facets));
|
||||||
}
|
}
|
||||||
if let Some(package_type) = &search_args.package_type {
|
if let package_type = search_args.package_type {
|
||||||
let package_type_facet = match package_type {
|
let project_type = match package_type {
|
||||||
PackageType::Fabric => "[\"categories:fabric\"],[\"project_type:mod\"]",
|
PackageType::Dummy => "",
|
||||||
PackageType::Forge => "[\"categories:forge\"],[\"project_type:mod\"]",
|
PackageType::Mod(_) => "[\"project_type:mod\"]",
|
||||||
PackageType::Quilt => "[\"categories:quilt\"],[\"project_type:mod\"]",
|
PackageType::Pack(_) => "[\"project_type:modpack\"]",
|
||||||
PackageType::Resource => "[\"project_type:resourcepack\"]",
|
PackageType::Plugin(_) => "[\"project_type:mod\"]",
|
||||||
PackageType::FabricPack => "[\"project_type:modpack\"],[\"categories:fabric\"]",
|
PackageType::ResourcePack => "[\"project_type:resourcepack\"]",
|
||||||
PackageType::ForgePack => "[\"project_type:modpack\"],[\"categories:forge\"]",
|
};
|
||||||
PackageType::QuiltPack => "[\"project_type:modpack\"],[\"categories:quilt\"]",
|
|
||||||
PackageType::BukkitPlugin => "[\"project_type:mod\"],[\"categories:bukkit\"]",
|
let project_category = match package_type {
|
||||||
PackageType::PaperPlugin => "[\"project_type:mod\"],[\"categories:paper\"]",
|
PackageType::Dummy => "",
|
||||||
PackageType::PurpurPlugin => "[\"project_type:mod\"],[\"categories:purpur\"]",
|
PackageType::Mod(kind) | PackageType::Pack(kind) => {
|
||||||
PackageType::SpigotPlugin => "[\"project_type:mod\"],[\"categories:spigot\"]",
|
match kind {
|
||||||
PackageType::SpongePlugin => "[\"project_type:mod\"],[\"categories:sponge\"]",
|
Loader::Fabric => "[\"categories:fabric\"]",
|
||||||
|
Loader::Forge => "[\"categories:forge\"]",
|
||||||
|
Loader::Quilt => "[\"categories:quilt\"]",
|
||||||
}
|
}
|
||||||
.to_string();
|
},
|
||||||
|
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);
|
facets.push(package_type_facet);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !facets.is_empty() {
|
if !facets.is_empty() {
|
||||||
params.push(("facets", format!("[{}]", facets.join(","))));
|
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};
|
* Copyright (c) 2021–2022 Marceline Cramer <mars@tebibyte.media>
|
||||||
use std::path::PathBuf;
|
* 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
|
use std::{
|
||||||
#[derive(clap::Args, Clone, Debug)]
|
fs::File,
|
||||||
pub struct SearchArgs {
|
io::{ Read, self },
|
||||||
pub package_name: String,
|
path::PathBuf,
|
||||||
|
};
|
||||||
|
|
||||||
/// Type of package to use
|
use serde::Deserialize;
|
||||||
#[clap(short, long, value_enum)]
|
use xdg::BaseDirectories;
|
||||||
pub package_type: Option<PackageType>,
|
use yacexits::{
|
||||||
|
EX_DATAERR,
|
||||||
|
EX_UNAVAILABLE,
|
||||||
|
};
|
||||||
|
|
||||||
/// Restricts the target Minecraft version
|
#[derive(Deserialize)]
|
||||||
#[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)]
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// General settings
|
pub hopfiles: Vec<String>,
|
||||||
pub options: Options,
|
pub sources: Sources,
|
||||||
|
|
||||||
/// Configuration for the upstream Modrinth server
|
|
||||||
pub upstream: Upstream,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppContext {
|
#[derive(Deserialize)]
|
||||||
pub args: Args,
|
pub struct Sources {
|
||||||
pub config: Config,
|
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)
|
||||||
|
}
|
||||||
|
}
|
172
src/main.rs
172
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 api;
|
||||||
|
mod args;
|
||||||
mod client;
|
mod client;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod hopfile;
|
||||||
|
|
||||||
use api::*;
|
use api::*;
|
||||||
use clap::Parser;
|
use args::*;
|
||||||
use client::*;
|
use client::*;
|
||||||
use config::*;
|
use config::*;
|
||||||
|
use hopfile::*;
|
||||||
|
|
||||||
fn display_search_results(ctx: &AppContext, response: &SearchResponse) {
|
use std::env::args;
|
||||||
let iter = response.hits.iter().enumerate();
|
|
||||||
if ctx.config.options.reverse_search {
|
use yacexits::{
|
||||||
for (i, result) in iter.rev() {
|
exit,
|
||||||
result.display(i + 1);
|
EX_OSERR,
|
||||||
|
EX_SOFTWARE,
|
||||||
|
EX_USAGE,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppContext {
|
||||||
|
args: Arguments,
|
||||||
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
for (i, result) in iter {
|
eprintln!("{}: {}", argv[0], message);
|
||||||
result.display(i + 1);
|
|
||||||
}
|
}
|
||||||
}
|
exit(code);
|
||||||
}
|
},
|
||||||
|
};
|
||||||
// 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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn rust_main(argv: Vec<String>) -> Result<u32, (String, u32)> {
|
||||||
env_logger::init();
|
|
||||||
let args = Args::parse();
|
let args = Arguments::from_args(argv.clone().iter().map(|s| s.as_str()))
|
||||||
let config = args.load_config()?;
|
.map_err(|err| { ArgsError::from(err) })?;
|
||||||
|
|
||||||
|
let config = Config::read_config()?;
|
||||||
let ctx = AppContext { args, config };
|
let ctx = AppContext { args, config };
|
||||||
match ctx.args.to_owned().command {
|
|
||||||
Command::Get(search_args) => cmd_get(&ctx, search_args).await,
|
match ctx.args.sub {
|
||||||
_ => unimplemented!("unimplemented subcommand"),
|
// 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