mirror of
https://github.com/Squibid/rph.git
synced 2025-10-19 19:34:04 +00:00
feat(vendordep): add vendordep management
- Add vendordep add - Add vendordep remove - Add vendordep list - Refactor downloading
This commit is contained in:
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# RPS (Robot Pits Helper)
|
||||
Manage your FRC robot code the UNIX way.
|
||||
|
||||
## Quick Start
|
||||
Make a new project from a template
|
||||
```sh
|
||||
rph template -d MyNewRoboProject -l java -t commandbased -n 5438 -s false
|
||||
```
|
||||
If you'd like to learn more about the template subcmd just run `rph template -h`.
|
||||
Now let's go into the project and add a vendor dependency:
|
||||
```sh
|
||||
rph vendordep add photonlib-2025.3.1
|
||||
```
|
||||
And now we just need to build to make it download all the actual code:
|
||||
```sh
|
||||
gradle build
|
||||
```
|
||||
|
||||
## But Why?
|
||||
Well you probably shouldn't, but if you really want to learn more about how
|
||||
computers work this is a solid starting point. As for the creation of this
|
||||
project: it was made for two reasons:
|
||||
1. To alleviate my everlasting frustration with WPILIB's crappy uis
|
||||
2. To dust everyone in the YAMS speedrunning competition
|
||||
|
||||
## TODO
|
||||
- [x] template
|
||||
- [ ] vendordep
|
||||
- [ ] update installed vendor deps
|
||||
- [ ] get info about installed vendor deps
|
||||
- [ ] riolog listener, seems like the vscode extension does it which means
|
||||
there's no reason we can't >:)
|
||||
|
||||
- [ ] have to make sure wpilib is installed somewhere so that we can build
|
||||
projects without running the wpilib installer, this might take a bit
|
||||
of investigation
|
||||
- [ ] make a declaritive config? (really only seems useful for speedrunning or
|
||||
setting up multiple rookies machines for a new project)
|
||||
|
||||
- [ ] runner? (maybe idk though cause you can just use gradlew directly)
|
||||
no, but maybe we should document how to use gradle for the noobs who
|
||||
use the internet
|
58
cmd/root.go
58
cmd/root.go
@@ -1,12 +1,19 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rph/state"
|
||||
"rph/utils"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var projectFs fs.FS
|
||||
var projectDir string
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: state.Name,
|
||||
Short: "Manage your FRC robot code the UNIX way.",
|
||||
@@ -15,11 +22,62 @@ giving FRC teams a simple way to interact with wpilib robot code.
|
||||
|
||||
rph is cross platform, and should work everywhere wpilib is supported. To
|
||||
actually run your robot code you will still need to install wpilib.`,
|
||||
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
dir, err := cmd.Flags().GetString("project-dir")
|
||||
if err != nil {
|
||||
slog.Error("Unable to set projectFs or projectDir", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// if the user specified a directory we'll trust them
|
||||
if dir != "." {
|
||||
projectFs = os.DirFS(dir)
|
||||
projectDir = dir
|
||||
return nil
|
||||
}
|
||||
|
||||
// otherwise we should go find the .wpilib folder in the parent directories
|
||||
path, err := utils.FindEntryDirInParents(dir, ".wpilib")
|
||||
if err != nil {
|
||||
slog.Error("Unable to find project directory", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
projectFs = os.DirFS(path)
|
||||
projectDir = path
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
rootCmd.PersistentFlags().String("project-dir", ".", "Set the project directory.")
|
||||
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func superPersistentPreRun(cmd *cobra.Command, args []string) {
|
||||
if parent := cmd.Parent(); parent != nil {
|
||||
if parent.PersistentPreRunE != nil {
|
||||
if err := parent.PersistentPreRunE(parent, args); err != nil {
|
||||
return
|
||||
}
|
||||
} else if parent.PersistentPreRun != nil {
|
||||
// Fallback to PersistentPreRun if PersistentPreRunE isn't set
|
||||
parent.PersistentPreRun(parent, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inProjectDir handles the log message for you
|
||||
func inProjectDir() bool {
|
||||
_, err := os.Stat(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json"))
|
||||
if err != nil {
|
||||
slog.Error("Are you in a project directory?")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
@@ -1,76 +0,0 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const (
|
||||
padding = 2
|
||||
maxWidth = 80
|
||||
)
|
||||
|
||||
type progressMsg float64
|
||||
type progressErrMsg struct{ err error }
|
||||
|
||||
func finalPause() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type downloadModel struct {
|
||||
pw *progressWriter
|
||||
progress progress.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (m downloadModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.Width = min(msg.Width - padding * 2 - 4, maxWidth)
|
||||
return m, nil
|
||||
|
||||
case progressErrMsg:
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
|
||||
case progressMsg:
|
||||
var cmds []tea.Cmd
|
||||
|
||||
if msg >= 1.0 {
|
||||
cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit))
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.progress.SetPercent(float64(msg)))
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
// FrameMsg is sent when the progress bar wants to animate itself
|
||||
case progress.FrameMsg:
|
||||
progressModel, cmd := m.progress.Update(msg)
|
||||
m.progress = progressModel.(progress.Model)
|
||||
return m, cmd
|
||||
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m downloadModel) View() string {
|
||||
if m.err != nil {
|
||||
return "Error downloading: " + m.err.Error() + "\n"
|
||||
}
|
||||
|
||||
pad := strings.Repeat(" ", padding)
|
||||
return "\n" + pad + "Downloading template.zip to " + m.pw.file.Name() + "\n\n" + pad + m.progress.View() + "\n\n" + pad + "Press any key to quit"
|
||||
}
|
@@ -10,32 +10,9 @@ import (
|
||||
"strconv"
|
||||
|
||||
"rph/state"
|
||||
"rph/utils"
|
||||
)
|
||||
|
||||
type progressWriter struct {
|
||||
total int
|
||||
downloaded int
|
||||
file *os.File
|
||||
reader io.Reader
|
||||
onProgress func(float64)
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Start() {
|
||||
// TeeReader calls pw.Write() each time a new response is received
|
||||
_, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw))
|
||||
if err != nil {
|
||||
slog.Error("Error in progress writer", "error", progressErrMsg{err})
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
pw.downloaded += len(p)
|
||||
if pw.total > 0 && pw.onProgress != nil {
|
||||
pw.onProgress(float64(pw.downloaded) / float64(pw.total))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
Name string `json:"name"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
@@ -104,7 +81,7 @@ func getTemplateArchive(filename string, force bool, version string) {
|
||||
return
|
||||
}
|
||||
|
||||
err = downloadFile(downloadURL)
|
||||
err = utils.DownloadFile(downloadURL, filepath.Join(state.CachePath, zipFile))
|
||||
if err != nil {
|
||||
slog.Error("Error downloading archive file", "error", err)
|
||||
os.Exit(1)
|
||||
|
@@ -5,13 +5,10 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rph/state"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
|
||||
@@ -34,57 +31,6 @@ func LoadArchiveVersion() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func downloadFile(url string) error {
|
||||
var progressBar = true
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(filepath.Join(state.CachePath, zipFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
|
||||
if resp.ContentLength <= 0 {
|
||||
slog.Warn("Can't parse content length, no progress bar will be shown.")
|
||||
progressBar = false
|
||||
}
|
||||
|
||||
var p *tea.Program
|
||||
pw := &progressWriter{
|
||||
total: int(resp.ContentLength),
|
||||
file: out,
|
||||
reader: resp.Body,
|
||||
onProgress: func(ratio float64) {
|
||||
p.Send(progressMsg(ratio))
|
||||
},
|
||||
}
|
||||
|
||||
m := downloadModel{
|
||||
pw: pw,
|
||||
progress: progress.New(progress.WithDefaultGradient()),
|
||||
}
|
||||
p = tea.NewProgram(m)
|
||||
|
||||
// start the download
|
||||
go pw.Start()
|
||||
|
||||
if progressBar {
|
||||
if _, err := p.Run(); err != nil {
|
||||
slog.Error("Error starting the progress bar", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func OpenArchive(ctx context.Context) (fsys fs.FS, err error) {
|
||||
fsys, err = archives.FileSystem(ctx, filepath.Join(state.CachePath, zipFile), nil)
|
||||
if err != nil {
|
||||
|
@@ -3,12 +3,15 @@ package template
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"rph/state"
|
||||
"rph/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type TemplateOptions struct {
|
||||
@@ -19,7 +22,7 @@ type TemplateOptions struct {
|
||||
DesktopSupport *bool
|
||||
}
|
||||
|
||||
type wpilibPreferences struct {
|
||||
type WpilibPreferences struct {
|
||||
CppIntellisense bool `json:"enableCppIntellisense"`
|
||||
Lang string `json:"currentLanguage"`
|
||||
Year string `json:"projectYear"`
|
||||
@@ -85,7 +88,7 @@ func GenerateProject(opts TemplateOptions) {
|
||||
defer file.Close()
|
||||
|
||||
{ // scope it so I can jump over it
|
||||
var p wpilibPreferences
|
||||
var p WpilibPreferences
|
||||
decoder := json.NewDecoder(file)
|
||||
err = decoder.Decode(&p)
|
||||
if err != nil {
|
||||
@@ -188,14 +191,14 @@ func GenerateProject(opts TemplateOptions) {
|
||||
|
||||
if strings.HasPrefix(projType, "xrp") {
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/xrpVendordep/XRPVendordep.json", version)
|
||||
err = fetchWPIDep(url, filepath.Join(vendordepDir, "XRPVendordep.json"))
|
||||
err = utils.DownloadFile(url, filepath.Join(vendordepDir, "XRPVendordep.json"))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err)
|
||||
return
|
||||
}
|
||||
} else if strings.HasPrefix(projType, "romi") {
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/romiVendordep/RomiVendordep.json", version)
|
||||
err = fetchWPIDep(url, filepath.Join(vendordepDir, "RomiVendordep.json"))
|
||||
err = utils.DownloadFile(url, filepath.Join(vendordepDir, "RomiVendordep.json"))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err)
|
||||
return
|
||||
@@ -203,7 +206,7 @@ func GenerateProject(opts TemplateOptions) {
|
||||
} else if strings.HasPrefix(projType, "command") {
|
||||
// TODO: make sure there's no other project types this has to work with
|
||||
url := fmt.Sprintf("https://raw.githubusercontent.com/wpilibsuite/allwpilib/%s/wpilibNewCommands/WPILibNewCommands.json", version)
|
||||
err = fetchWPIDep(url, filepath.Join(vendordepDir, "WPILibNewCommands.json"))
|
||||
err = utils.DownloadFile(url, filepath.Join(vendordepDir, "WPILibNewCommands.json"))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download required vendor dep you should download it yourself", "url", url, "error", err)
|
||||
return
|
||||
@@ -211,27 +214,3 @@ func GenerateProject(opts TemplateOptions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchWPIDep(url string, outpath string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("Failed to download file", "url", url, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
outFile, err := os.Create(outpath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create file", "error", err)
|
||||
return err
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
_, err = io.Copy(outFile, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to save file", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
42
cmd/vendordep.go
Normal file
42
cmd/vendordep.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"rph/cmd/vendordep"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func vendorDepsComp(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
validVendordeps, err := vendordep.ListVendorDeps(projectFs)
|
||||
if err != nil {
|
||||
slog.Error("Unable to find vendor deps", "error", err)
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var completions []string
|
||||
for _, dep := range validVendordeps {
|
||||
if strings.HasPrefix(dep.Name, toComplete) {
|
||||
completions = append(completions, dep.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
// vendordepCmd represents the vendordep command
|
||||
var vendordepCmd = &cobra.Command{
|
||||
Use: "vendordep",
|
||||
Aliases: []string{ "vend" },
|
||||
Short: "Mange your WPILIB projects vendordeps",
|
||||
Long: `Mange your WPILIB projects vendordeps`,
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
superPersistentPreRun(cmd, args)
|
||||
vendordep.MkCacheDir()
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(vendordepCmd)
|
||||
}
|
3
cmd/vendordep/artifactory/artifactory.go
Normal file
3
cmd/vendordep/artifactory/artifactory.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package artifactory
|
||||
|
||||
const DefaultVendorDepArtifactoryUrl = "https://frcmaven.wpi.edu/artifactory"
|
202
cmd/vendordep/artifactory/artifactoryFs.go
Normal file
202
cmd/vendordep/artifactory/artifactoryFs.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package artifactory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ArtifactoryFS struct {
|
||||
BaseURL string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL string) ArtifactoryFS {
|
||||
return ArtifactoryFS{
|
||||
BaseURL: strings.TrimRight(baseURL, "/") + "/",
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (afs ArtifactoryFS) GetUrl(name string) string {
|
||||
cleanName := path.Clean(name)
|
||||
return afs.BaseURL + cleanName
|
||||
}
|
||||
|
||||
func (afs ArtifactoryFS) Open(name string) (fs.File, error) {
|
||||
cleanName := path.Clean(name)
|
||||
url := afs.BaseURL + cleanName
|
||||
|
||||
// Fetch metadata via storage API
|
||||
metaURL := afs.BaseURL + "api/storage/" + cleanName
|
||||
if cleanName == "." {
|
||||
metaURL = afs.BaseURL + "api/storage/"
|
||||
}
|
||||
|
||||
resp, err := afs.Client.Get(metaURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fs.ErrNotExist
|
||||
} else if resp.StatusCode != 200 {
|
||||
return nil, errors.New("unexpected status: " + resp.Status)
|
||||
}
|
||||
|
||||
var meta struct {
|
||||
Repo string `json:"repo"`
|
||||
Path string `json:"path"`
|
||||
Children []struct {
|
||||
URI string `json:"uri"`
|
||||
Folder bool `json:"folder"`
|
||||
} `json:"children"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// It's a directory
|
||||
if len(meta.Children) > 0 {
|
||||
return &artifactoryDir{
|
||||
entries: meta.Children,
|
||||
pos: 0,
|
||||
name: cleanName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// It's a file
|
||||
contentResp, err := afs.Client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if contentResp.StatusCode == 404 {
|
||||
return nil, fs.ErrNotExist
|
||||
} else if contentResp.StatusCode != 200 {
|
||||
return nil, errors.New("unexpected status: " + contentResp.Status)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(contentResp.Body)
|
||||
contentResp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &artifactoryFile{
|
||||
data: bytes.NewReader(data),
|
||||
name: cleanName,
|
||||
size: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type artifactoryFile struct {
|
||||
data *bytes.Reader
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (f *artifactoryFile) Stat() (fs.FileInfo, error) {
|
||||
return &fileInfo{
|
||||
name: path.Base(f.name),
|
||||
size: f.size,
|
||||
mode: 0444,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *artifactoryFile) Read(p []byte) (int, error) {
|
||||
return f.data.Read(p)
|
||||
}
|
||||
|
||||
func (f *artifactoryFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type artifactoryDir struct {
|
||||
entries []struct {
|
||||
URI string `json:"uri"`
|
||||
Folder bool `json:"folder"`
|
||||
}
|
||||
pos int
|
||||
name string
|
||||
}
|
||||
|
||||
func (d *artifactoryDir) Stat() (fs.FileInfo, error) {
|
||||
return &fileInfo{
|
||||
name: path.Base(d.name),
|
||||
mode: fs.ModeDir | 0555,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *artifactoryDir) Read([]byte) (int, error) {
|
||||
return 0, errors.New("cannot read directory")
|
||||
}
|
||||
|
||||
func (d *artifactoryDir) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *artifactoryDir) ReadDir(n int) ([]fs.DirEntry, error) {
|
||||
if d.pos >= len(d.entries) && n > 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
var entries []fs.DirEntry
|
||||
max := len(d.entries)
|
||||
if n > 0 && d.pos+n < max {
|
||||
max = d.pos + n
|
||||
}
|
||||
|
||||
for ; d.pos < max; d.pos++ {
|
||||
e := d.entries[d.pos]
|
||||
entries = append(entries, &dirEntry{
|
||||
name: strings.TrimPrefix(e.URI, "/"),
|
||||
isDir: e.Folder,
|
||||
})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode fs.FileMode
|
||||
}
|
||||
|
||||
func (fi *fileInfo) Name() string { return fi.name }
|
||||
func (fi *fileInfo) Size() int64 { return fi.size }
|
||||
func (fi *fileInfo) Mode() fs.FileMode { return fi.mode }
|
||||
func (fi *fileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (fi *fileInfo) IsDir() bool { return fi.mode.IsDir() }
|
||||
func (fi *fileInfo) Sys() any { return nil }
|
||||
|
||||
type dirEntry struct {
|
||||
name string
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (de *dirEntry) Name() string { return de.name }
|
||||
func (de *dirEntry) IsDir() bool { return de.isDir }
|
||||
func (de *dirEntry) Type() fs.FileMode {
|
||||
if de.isDir {
|
||||
return fs.ModeDir
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
func (de *dirEntry) Info() (fs.FileInfo, error) {
|
||||
return &fileInfo{
|
||||
name: de.name,
|
||||
mode: de.Type(),
|
||||
}, nil
|
||||
}
|
66
cmd/vendordep/remote.go
Normal file
66
cmd/vendordep/remote.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package vendordep
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"rph/cmd/vendordep/artifactory"
|
||||
"time"
|
||||
)
|
||||
|
||||
type OnlineVendordep struct {
|
||||
VendordepName string
|
||||
Version string
|
||||
FileName string
|
||||
LastModTime time.Time
|
||||
}
|
||||
|
||||
func ListAvailableOnlineDeps(year string) (map[string][]OnlineVendordep, error) {
|
||||
fsys := artifactory.New(artifactory.DefaultVendorDepArtifactoryUrl)
|
||||
path := "vendordeps/vendordep-marketplace/" + year
|
||||
|
||||
entries, err := fs.ReadDir(fsys, path)
|
||||
if err != nil {
|
||||
slog.Error("Failed to readdir from artifactory", "dir", path, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type fileEntry struct {
|
||||
Name string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
files := make([]fileEntry, len(entries))
|
||||
for i, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files[i] = fileEntry{ Name: e.Name(), ModTime: info.ModTime() }
|
||||
}
|
||||
|
||||
allDeps := make(map[string][]OnlineVendordep, len(entries))
|
||||
|
||||
// breaks a vendordep file name into "name-of-library" and "v2025.9.28"
|
||||
re := regexp.MustCompile(`^(.+)-v?(\d+\.\d+(?:\.\d+)?)`)
|
||||
for _, file := range files {
|
||||
matches := re.FindStringSubmatch(file.Name)
|
||||
if len(matches) > 2 {
|
||||
baseName := matches[1]
|
||||
version := matches[2]
|
||||
|
||||
allDeps[baseName] = append(allDeps[baseName], OnlineVendordep{
|
||||
VendordepName: baseName,
|
||||
Version: version,
|
||||
FileName: file.Name,
|
||||
LastModTime: file.ModTime,
|
||||
})
|
||||
} else {
|
||||
return nil, errors.New("Vendordep file name does not match format '" + file.Name + "'")
|
||||
}
|
||||
}
|
||||
|
||||
return allDeps, nil
|
||||
}
|
80
cmd/vendordep/store.go
Normal file
80
cmd/vendordep/store.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package vendordep
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rph/state"
|
||||
)
|
||||
|
||||
const vendordepDir = "vendordeps"
|
||||
|
||||
func MkCacheDir() {
|
||||
os.MkdirAll(filepath.Join(state.CachePath, vendordepDir), 0755);
|
||||
}
|
||||
|
||||
func Trash(path string) error {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
slog.Error("Can't trash file, path must be a valid file", "path", path, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
slog.Error("Failed to open vendordep file", "error", err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dep, err := Parse(file)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse vendordep file", "error", err)
|
||||
}
|
||||
|
||||
err = os.Rename(path, filepath.Join(state.CachePath, vendordepDir, dep.FileName))
|
||||
if err != nil {
|
||||
slog.Info("Failed to move vendor dep", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasVendorDepOnDisk check if a vendor dep is already on your disk, this is only
|
||||
// useful if you've got the uuid of the vendordep you would like to install or
|
||||
// are in a very percarious situation where you have no internet and any version
|
||||
// of your vendordep will do.
|
||||
func hasVendorDepOnDisk(dep Vendordep, strict bool) (bool, error) {
|
||||
path := filepath.Join(state.CachePath, vendordepDir)
|
||||
|
||||
// by default we're not matching
|
||||
matches := false
|
||||
|
||||
err := filepath.WalkDir(path, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil { return err }
|
||||
if d.IsDir() { return nil }
|
||||
|
||||
file, err := os.Open(filepath.Join(path, d.Name()))
|
||||
if err != nil {
|
||||
slog.Error("Failed to open vendordep file", "error", err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
new_dep, err := Parse(file)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse vendordep file", "error", err)
|
||||
}
|
||||
|
||||
matches = new_dep.Matches(dep, strict)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Failed to walk the vendordep directory", "error", err)
|
||||
return false, err
|
||||
}
|
||||
|
||||
return matches, nil
|
||||
}
|
129
cmd/vendordep/vendordep.go
Normal file
129
cmd/vendordep/vendordep.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package vendordep
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"rph/utils"
|
||||
)
|
||||
|
||||
type JavaDepedency struct {
|
||||
GroupId string `json:"groupId"`
|
||||
ArtifactId string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
type JniDependency struct {
|
||||
GroupId string `json:"groupId"`
|
||||
ArtifactId string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
IsJar bool `json:"isJar"`
|
||||
SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"`
|
||||
ValidPlatforms []string `json:"validPlatforms"`
|
||||
SimMode string `json:"simMode"`
|
||||
}
|
||||
|
||||
type CppDependency struct {
|
||||
GroupId string `json:"groupId"`
|
||||
ArtifactId string `json:"artifactId"`
|
||||
Version string `json:"version"`
|
||||
LibName string `json:"libName"`
|
||||
HeaderClassifier string `json:"headerClassifier"`
|
||||
SharedLibrary bool `json:"sharedLibrary"`
|
||||
SkipInvalidPlatforms bool `json:"skipInvalidPlatforms"`
|
||||
BinaryPlatforms []string `json:"binaryPlatforms"`
|
||||
SimMode string `json:"simMode"`
|
||||
}
|
||||
|
||||
type Vendordep struct {
|
||||
FileName string `json:"filename"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
FrcYear utils.StringOrNumber `json:"frcYear"`
|
||||
UUID string `json:"uuid"`
|
||||
MavenUrls []string `json:"mavenUrls"`
|
||||
JsonUrl string `json:"jsonUrl"`
|
||||
JavaDependencies []JavaDepedency `json:"javaDependencies"`
|
||||
JniDependencies []JniDependency `json:"jniDependencies"`
|
||||
CppDependencies []CppDependency `json:"cppDependencies"`
|
||||
}
|
||||
|
||||
// Matches check if one vendor dep matches another in any real useful way
|
||||
func (v *Vendordep) Matches(other Vendordep, strict bool) bool {
|
||||
if other.Name != "" && v.Name == other.Name {
|
||||
if other.Version != "" && v.Version == other.Version {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if other.UUID != "" && v.UUID == other.UUID { return true }
|
||||
if !strict {
|
||||
if other.JsonUrl != "" && v.JsonUrl == other.JsonUrl { return true }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func Parse(vendordepFile io.Reader) (*Vendordep, error) {
|
||||
var vendordep Vendordep
|
||||
|
||||
if err := json.NewDecoder(vendordepFile).Decode(&vendordep); err != nil {
|
||||
slog.Error("Invalid vendor dependency Error decoding JSON", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &vendordep, nil
|
||||
}
|
||||
|
||||
func ListVendorDeps(projectFs fs.FS) ([]Vendordep, error) {
|
||||
var out []Vendordep
|
||||
|
||||
err := fs.WalkDir(projectFs, "vendordeps", func(path string, d fs.DirEntry,
|
||||
err error) error {
|
||||
if err != nil { return err }
|
||||
if d.IsDir() { return nil }
|
||||
|
||||
file, err := projectFs.Open(path)
|
||||
if err != nil {
|
||||
slog.Error("unable to open vendor directory", "error", err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dep, err := Parse(file)
|
||||
if err != nil {
|
||||
slog.Error("Unable to parse vendordep file", "file", d.Name, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
out = append(out, *dep)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Unable to read vendordep fs", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func FindVendorDepFromName(name string, fs fs.FS) (*Vendordep, error) {
|
||||
deps, err := ListVendorDeps(fs);
|
||||
if err != nil {
|
||||
slog.Error("Unable to find vendor deps", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, dep := range deps {
|
||||
if dep.Name == name {
|
||||
return &dep, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("Vendordep not found")
|
||||
}
|
||||
|
||||
func ShowInfo(Vendordep) {
|
||||
}
|
147
cmd/vendordepadd.go
Normal file
147
cmd/vendordepadd.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rph/cmd/template"
|
||||
"rph/cmd/vendordep"
|
||||
"rph/cmd/vendordep/artifactory"
|
||||
"rph/utils"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// vendordepaddCmd represents the vendordep add command
|
||||
var vendordepaddCmd = &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "Add a new vendordep",
|
||||
Long: `Add a new vendordep. You may pass in as many urls or vendordep names
|
||||
as you wish. The vendordep names are determined by what's found at
|
||||
https://frcmaven.wpi.edu/ui/native/vendordeps/`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]cobra.Completion, cobra.ShellCompDirective) {
|
||||
year, err := cmd.Flags().GetString("year")
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
if year == "" {
|
||||
file, err := os.Open(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json"))
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var wpilibPrefs template.WpilibPreferences
|
||||
if err := json.NewDecoder(file).Decode(&wpilibPrefs); err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
year = wpilibPrefs.Year
|
||||
}
|
||||
|
||||
// TODO: refactor this into it's own func, cache it and then we can make
|
||||
// less api calls
|
||||
validVendordeps, err := vendordep.ListAvailableOnlineDeps(year)
|
||||
if err != nil {
|
||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||
}
|
||||
|
||||
var completions []string
|
||||
for k, deps := range validVendordeps {
|
||||
for _, dep := range deps {
|
||||
comp := k + "-" + dep.Version
|
||||
if strings.HasPrefix(comp, toComplete) {
|
||||
completions = append(completions, comp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !inProjectDir() { return nil }
|
||||
|
||||
year, err := cmd.Flags().GetString("year")
|
||||
if err != nil { return err }
|
||||
|
||||
if year == "" {
|
||||
file, err := os.Open(filepath.Join(projectDir, ".wpilib", "wpilib_preferences.json"))
|
||||
if err != nil {
|
||||
slog.Error("Failed to open wpilib_preferences.json", "error", err)
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var wpilibPrefs template.WpilibPreferences
|
||||
if err := json.NewDecoder(file).Decode(&wpilibPrefs); err != nil {
|
||||
slog.Error("Failed to decode wpilib_preferences.json", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
year = wpilibPrefs.Year
|
||||
}
|
||||
|
||||
fsys := artifactory.New(artifactory.DefaultVendorDepArtifactoryUrl)
|
||||
path := "vendordeps/vendordep-marketplace/" + year
|
||||
|
||||
// make sure the vendordep directory exists in the current project
|
||||
os.MkdirAll(filepath.Join(projectDir, "vendordeps"), 0755);
|
||||
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, "http") {
|
||||
resp, err := http.Get(arg)
|
||||
if err != nil {
|
||||
slog.Error("Failed to download file", "url", arg, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
dep, err := vendordep.Parse(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse vendor dep", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
err = utils.DownloadFile(arg, filepath.Join(projectDir, "vendordeps", dep.FileName))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download vendordep", "error", err)
|
||||
}
|
||||
} else {
|
||||
vendordeps, err := vendordep.ListAvailableOnlineDeps(year)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for k, deps := range vendordeps {
|
||||
for _, dep := range deps {
|
||||
d := k + "-" + dep.Version
|
||||
if d == arg {
|
||||
url := fsys.GetUrl(path + "/" + dep.FileName)
|
||||
err := utils.DownloadFile(url, filepath.Join(projectDir, "vendordeps", dep.FileName))
|
||||
if err != nil {
|
||||
slog.Error("Failed to copy the file to the filesystem", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: tell the user to gradle build
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
vendordepCmd.AddCommand(vendordepaddCmd)
|
||||
vendordepaddCmd.Flags().StringP("year", "y", "", "override the year to search for dependencies in frcmaven.")
|
||||
}
|
41
cmd/vendordeplist.go
Normal file
41
cmd/vendordeplist.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"rph/cmd/vendordep"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// vendordeplistCmd represents the vendordep list command
|
||||
var vendordeplistCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List out your vendordeps",
|
||||
Long: `A longer description that spans multiple lines and likely contains examples
|
||||
and usage of using your command. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
Aliases: []string{ "ls" },
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !inProjectDir() { return nil }
|
||||
|
||||
deps, err := vendordep.ListVendorDeps(projectFs)
|
||||
if err != nil {
|
||||
slog.Error("Unable to list vendor deps", "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, dep := range deps {
|
||||
fmt.Println(dep.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
vendordepCmd.AddCommand(vendordeplistCmd)
|
||||
}
|
58
cmd/vendordepremove.go
Normal file
58
cmd/vendordepremove.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"rph/cmd/vendordep"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// vendordepremoveCmd represents the vendordep remove command
|
||||
var vendordepremoveCmd = &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "Remove a vendordep",
|
||||
Long: `Remove a vendordep, by default this will move the vendordep file into
|
||||
a safe place just incase you wish to undo this action to make sure it's been
|
||||
removed from your disk you may use the -f flag.`,
|
||||
Aliases: []string{ "rm" },
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
ValidArgsFunction: vendorDepsComp,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !inProjectDir() { return nil }
|
||||
|
||||
force, err := cmd.Flags().GetBool("force")
|
||||
if err != nil { return err }
|
||||
|
||||
for _, n := range args {
|
||||
dep, err := vendordep.FindVendorDepFromName(args[0], projectFs)
|
||||
if err != nil {
|
||||
slog.Error("Failed to get vendor dep from name", "name", n, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if force {
|
||||
err = os.Remove(filepath.Join(projectDir, "vendordeps", dep.FileName))
|
||||
if err != nil {
|
||||
slog.Error("Failed to remove vendor dep", "error", err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
err = vendordep.Trash(filepath.Join(projectDir, "vendordeps", dep.FileName))
|
||||
if err != nil {
|
||||
slog.Error("Failed to move vendor dep to trash", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
vendordepCmd.AddCommand(vendordepremoveCmd)
|
||||
|
||||
vendordepremoveCmd.Flags().BoolP("force", "f", false, "Forcefully remove a vendor dependency.")
|
||||
}
|
156
utils/downloader.go
Normal file
156
utils/downloader.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
const (
|
||||
padding = 2
|
||||
maxWidth = 80
|
||||
)
|
||||
|
||||
func DownloadFile(url string, outpath string) error {
|
||||
var progressBar = true
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outpath)
|
||||
if err != nil { return err }
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.ContentLength <= 0 {
|
||||
slog.Warn("Can't parse content length, no progress bar will be shown.")
|
||||
progressBar = false
|
||||
}
|
||||
|
||||
var p *tea.Program
|
||||
pw := &progressWriter{
|
||||
total: int(resp.ContentLength),
|
||||
file: out,
|
||||
reader: resp.Body,
|
||||
originUrl: url,
|
||||
onProgress: func(ratio float64) {
|
||||
p.Send(progressMsg(ratio))
|
||||
},
|
||||
}
|
||||
|
||||
p = tea.NewProgram(downloadModel{
|
||||
pw: pw,
|
||||
progress: progress.New(progress.WithDefaultGradient()),
|
||||
})
|
||||
|
||||
|
||||
if progressBar {
|
||||
// start the download
|
||||
go pw.Start()
|
||||
if _, err := p.Run(); err != nil {
|
||||
slog.Error("Error starting the progress bar", "error", err)
|
||||
}
|
||||
} else {
|
||||
// we need to block the file and stream from closing
|
||||
pw.Start()
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type progressMsg float64
|
||||
type progressErrMsg struct{ err error }
|
||||
|
||||
func finalPause() tea.Cmd {
|
||||
return tea.Tick(time.Millisecond*750, func(_ time.Time) tea.Msg {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type progressWriter struct {
|
||||
total int
|
||||
downloaded int
|
||||
file *os.File
|
||||
reader io.Reader
|
||||
originUrl string
|
||||
onProgress func(float64)
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Start() {
|
||||
// TeeReader calls pw.Write() each time a new response is received
|
||||
_, err := io.Copy(pw.file, io.TeeReader(pw.reader, pw))
|
||||
if err != nil {
|
||||
slog.Error("Error in progress writer", "error", progressErrMsg{err})
|
||||
}
|
||||
}
|
||||
|
||||
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||
pw.downloaded += len(p)
|
||||
if pw.total > 0 && pw.onProgress != nil {
|
||||
pw.onProgress(float64(pw.downloaded) / float64(pw.total))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type downloadModel struct {
|
||||
pw *progressWriter
|
||||
progress progress.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (m downloadModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m downloadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
m.progress.Width = min(msg.Width - padding * 2 - 4, maxWidth)
|
||||
return m, nil
|
||||
|
||||
case progressErrMsg:
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
|
||||
case progressMsg:
|
||||
var cmds []tea.Cmd
|
||||
|
||||
if msg >= 1.0 {
|
||||
cmds = append(cmds, tea.Sequence(finalPause(), tea.Quit))
|
||||
}
|
||||
|
||||
cmds = append(cmds, m.progress.SetPercent(float64(msg)))
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
// FrameMsg is sent when the progress bar wants to animate itself
|
||||
case progress.FrameMsg:
|
||||
progressModel, cmd := m.progress.Update(msg)
|
||||
m.progress = progressModel.(progress.Model)
|
||||
return m, cmd
|
||||
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m downloadModel) View() string {
|
||||
if m.err != nil {
|
||||
return "Error downloading: " + m.err.Error() + "\n"
|
||||
}
|
||||
|
||||
pad := strings.Repeat(" ", padding)
|
||||
return "\n" + pad + "Downloading " + m.pw.originUrl + " to " + m.pw.file.Name() + "\n\n" + pad + m.progress.View() + "\n\n" + pad + "Press any key to quit"
|
||||
}
|
24
utils/stringOrNumber.go
Normal file
24
utils/stringOrNumber.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type StringOrNumber string
|
||||
|
||||
func (s *StringOrNumber) UnmarshalJSON(b []byte) error {
|
||||
var asString string
|
||||
if err := json.Unmarshal(b, &asString); err == nil {
|
||||
*s = StringOrNumber(asString)
|
||||
return nil
|
||||
}
|
||||
|
||||
var asNumber float64
|
||||
if err := json.Unmarshal(b, &asNumber); err == nil {
|
||||
*s = StringOrNumber(fmt.Sprintf("%.0f", asNumber))
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid frcYear format")
|
||||
}
|
41
utils/utils.go
Normal file
41
utils/utils.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func FindEntryDirInParents(startPath string, lookingFor string) (string, error) {
|
||||
absPath, err := filepath.Abs(startPath)
|
||||
if err != nil {
|
||||
slog.Error("Unable to get the absolute path", "path", startPath, "error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find our depth so we don't have to do more checking than necessary
|
||||
components := strings.Split(filepath.Clean(absPath), string(os.PathSeparator))
|
||||
|
||||
// Start searching from the current directory upwards
|
||||
currentPath := absPath
|
||||
for i := len(components); i > 0; i-- {
|
||||
lookingForPath := filepath.Join(currentPath, lookingFor)
|
||||
|
||||
_, err := os.Stat(lookingForPath)
|
||||
if err == nil {
|
||||
realLookingForPath, err := filepath.Abs(currentPath)
|
||||
if err != nil {
|
||||
slog.Error("Unable to get the absolute path", "path", lookingForPath, "error", err)
|
||||
return "", err
|
||||
}
|
||||
return realLookingForPath, nil
|
||||
}
|
||||
|
||||
// Go up one directory level
|
||||
currentPath = filepath.Dir(currentPath)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf(lookingFor + " not found")
|
||||
}
|
Reference in New Issue
Block a user