From e4b02a792d3849547e4c0746adc90d0266be887a Mon Sep 17 00:00:00 2001 From: mircearoata Date: Wed, 6 Dec 2023 05:47:41 +0100 Subject: [PATCH] feat: offline mode (#14) * chore: move mod downloading to cli/cache * feat: data providers, ficsit and local * feat: keep cache in memory, load on init * feat: log invalid cache files instead of returning error * chore: make linter happy * feat: fill cached mod Authors field from CreatedBy * chore: make linter happy again * feat: add icon and size to cached mods * feat: cache the cached file hashes * fix: change to new provider access style --------- Co-authored-by: Vilsol --- cli/cache/cache.go | 164 +++++++++++++++ cli/cache/download.go | 130 ++++++++++++ cli/cache/integrity.go | 107 ++++++++++ cli/cache/uplugin.go | 16 ++ cli/context.go | 23 +- cli/dependency_resolver.go | 18 +- cli/dependency_resolver_error.go | 16 +- cli/installations.go | 5 +- cli/provider/ficsit.go | 47 +++++ cli/provider/local.go | 289 ++++++++++++++++++++++++++ cli/provider/mixed.go | 72 +++++++ cli/provider/provider.go | 18 ++ cli/resolving_test.go | 2 +- cmd/root.go | 4 + go.mod | 1 + go.sum | 2 + tea/components/header.go | 5 + tea/components/types.go | 2 + tea/root.go | 7 +- tea/scenes/mods/installed_mods.go | 2 +- tea/scenes/mods/mod_info.go | 2 +- tea/scenes/mods/mods.go | 2 +- tea/scenes/mods/select_mod_version.go | 2 +- utils/io.go | 114 ---------- 24 files changed, 909 insertions(+), 141 deletions(-) create mode 100644 cli/cache/cache.go create mode 100644 cli/cache/download.go create mode 100644 cli/cache/integrity.go create mode 100644 cli/cache/uplugin.go create mode 100644 cli/provider/ficsit.go create mode 100644 cli/provider/local.go create mode 100644 cli/provider/mixed.go create mode 100644 cli/provider/provider.go diff --git a/cli/cache/cache.go b/cli/cache/cache.go new file mode 100644 index 0000000..8dee8bb --- /dev/null +++ b/cli/cache/cache.go @@ -0,0 +1,164 @@ +package cache + +import ( + "archive/zip" + "encoding/base64" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" +) + +const IconFilename = "Resources/Icon128.png" // This is the path UE expects for the icon + +type File struct { + Icon *string + ModReference string + Hash string + Plugin UPlugin + Size int64 +} + +var loadedCache map[string][]File + +func GetCache() (map[string][]File, error) { + if loadedCache != nil { + return loadedCache, nil + } + return LoadCache() +} + +func GetCacheMod(mod string) ([]File, error) { + cache, err := GetCache() + if err != nil { + return nil, err + } + return cache[mod], nil +} + +func LoadCache() (map[string][]File, error) { + loadedCache = map[string][]File{} + downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") + if _, err := os.Stat(downloadCache); os.IsNotExist(err) { + return map[string][]File{}, nil + } + + items, err := os.ReadDir(downloadCache) + if err != nil { + return nil, errors.Wrap(err, "failed reading download cache") + } + + for _, item := range items { + if item.IsDir() { + continue + } + if item.Name() == integrityFilename { + continue + } + + _, err = addFileToCache(item.Name()) + if err != nil { + log.Err(err).Str("file", item.Name()).Msg("failed to add file to cache") + } + } + return loadedCache, nil +} + +func addFileToCache(filename string) (*File, error) { + cacheFile, err := readCacheFile(filename) + if err != nil { + return nil, errors.Wrap(err, "failed to read cache file") + } + + loadedCache[cacheFile.ModReference] = append(loadedCache[cacheFile.ModReference], *cacheFile) + return cacheFile, nil +} + +func readCacheFile(filename string) (*File, error) { + downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") + path := filepath.Join(downloadCache, filename) + stat, err := os.Stat(path) + if err != nil { + return nil, errors.Wrap(err, "failed to stat file") + } + + zipFile, err := os.Open(path) + if err != nil { + return nil, errors.Wrap(err, "failed to open file") + } + defer zipFile.Close() + + size := stat.Size() + reader, err := zip.NewReader(zipFile, size) + if err != nil { + return nil, errors.Wrap(err, "failed to read zip") + } + + var upluginFile *zip.File + for _, file := range reader.File { + if strings.HasSuffix(file.Name, ".uplugin") { + upluginFile = file + break + } + } + if upluginFile == nil { + return nil, errors.New("no uplugin file found in zip") + } + + upluginReader, err := upluginFile.Open() + if err != nil { + return nil, errors.Wrap(err, "failed to open uplugin file") + } + + var uplugin UPlugin + data, err := io.ReadAll(upluginReader) + if err != nil { + return nil, errors.Wrap(err, "failed to read uplugin file") + } + if err := json.Unmarshal(data, &uplugin); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal uplugin file") + } + + modReference := strings.TrimSuffix(upluginFile.Name, ".uplugin") + + hash, err := getFileHash(filename) + if err != nil { + return nil, errors.Wrap(err, "failed to get file hash") + } + + var iconFile *zip.File + for _, file := range reader.File { + if file.Name == IconFilename { + iconFile = file + break + } + } + var icon *string + if iconFile != nil { + iconReader, err := iconFile.Open() + if err != nil { + return nil, errors.Wrap(err, "failed to open icon file") + } + defer iconReader.Close() + + data, err := io.ReadAll(iconReader) + if err != nil { + return nil, errors.Wrap(err, "failed to read icon file") + } + iconData := base64.StdEncoding.EncodeToString(data) + icon = &iconData + } + + return &File{ + ModReference: modReference, + Hash: hash, + Size: size, + Icon: icon, + Plugin: uplugin, + }, nil +} diff --git a/cli/cache/download.go b/cli/cache/download.go new file mode 100644 index 0000000..22376a6 --- /dev/null +++ b/cli/cache/download.go @@ -0,0 +1,130 @@ +package cache + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/spf13/viper" + + "github.com/satisfactorymodding/ficsit-cli/utils" +) + +type Progresser struct { + io.Reader + updates chan utils.GenericUpdate + total int64 + running int64 +} + +func (pt *Progresser) Read(p []byte) (int, error) { + n, err := pt.Reader.Read(p) + pt.running += int64(n) + + if err == nil { + if pt.updates != nil { + select { + case pt.updates <- utils.GenericUpdate{Progress: float64(pt.running) / float64(pt.total)}: + default: + } + } + } + + if err == io.EOF { + return n, io.EOF + } + + return n, errors.Wrap(err, "failed to read") +} + +func DownloadOrCache(cacheKey string, hash string, url string, updates chan utils.GenericUpdate) (io.ReaderAt, int64, error) { + downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") + if err := os.MkdirAll(downloadCache, 0o777); err != nil { + if !os.IsExist(err) { + return nil, 0, errors.Wrap(err, "failed creating download cache") + } + } + + location := filepath.Join(downloadCache, cacheKey) + + stat, err := os.Stat(location) + if err == nil { + existingHash := "" + + if hash != "" { + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + existingHash, err = utils.SHA256Data(f) + if err != nil { + return nil, 0, errors.Wrap(err, "could not compute hash for file: "+location) + } + } + + if hash == existingHash { + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + return f, stat.Size(), nil + } + + if err := os.Remove(location); err != nil { + return nil, 0, errors.Wrap(err, "failed to delete file: "+location) + } + } else if !os.IsNotExist(err) { + return nil, 0, errors.Wrap(err, "failed to stat file: "+location) + } + + out, err := os.Create(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed creating file at: "+location) + } + defer out.Close() + + resp, err := http.Get(url) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to fetch: "+url) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url) + } + + progresser := &Progresser{ + Reader: resp.Body, + total: resp.ContentLength, + updates: updates, + } + + _, err = io.Copy(out, progresser) + if err != nil { + return nil, 0, errors.Wrap(err, "failed writing file to disk") + } + + f, err := os.Open(location) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to open file: "+location) + } + + if updates != nil { + select { + case updates <- utils.GenericUpdate{Progress: 1}: + default: + } + } + + _, err = addFileToCache(cacheKey) + if err != nil { + return nil, 0, errors.Wrap(err, "failed to add file to cache") + } + + return f, resp.ContentLength, nil +} diff --git a/cli/cache/integrity.go b/cli/cache/integrity.go new file mode 100644 index 0000000..4c35976 --- /dev/null +++ b/cli/cache/integrity.go @@ -0,0 +1,107 @@ +package cache + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + "github.com/spf13/viper" + + "github.com/satisfactorymodding/ficsit-cli/utils" +) + +type hashInfo struct { + Modified time.Time + Hash string + Size int64 +} + +var hashCache map[string]hashInfo + +var integrityFilename = ".integrity" + +func getFileHash(file string) (string, error) { + if hashCache == nil { + loadHashCache() + } + cachedHash, ok := hashCache[file] + if !ok { + return cacheFileHash(file) + } + downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") + stat, err := os.Stat(filepath.Join(downloadCache, file)) + if err != nil { + return "", errors.Wrap(err, "failed to stat file") + } + if stat.Size() != cachedHash.Size || stat.ModTime() != cachedHash.Modified { + return cacheFileHash(file) + } + return cachedHash.Hash, nil +} + +func cacheFileHash(file string) (string, error) { + downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") + stat, err := os.Stat(filepath.Join(downloadCache, file)) + if err != nil { + return "", errors.Wrap(err, "failed to stat file") + } + f, err := os.Open(filepath.Join(downloadCache, file)) + if err != nil { + return "", errors.Wrap(err, "failed to open file") + } + defer f.Close() + hash, err := utils.SHA256Data(f) + if err != nil { + return "", errors.Wrap(err, "failed to hash file") + } + hashCache[file] = hashInfo{ + Hash: hash, + Size: stat.Size(), + Modified: stat.ModTime(), + } + saveHashCache() + return hash, nil +} + +func loadHashCache() { + hashCache = map[string]hashInfo{} + cacheFile := filepath.Join(viper.GetString("cache-dir"), "downloadCache", integrityFilename) + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + return + } + f, err := os.Open(cacheFile) + if err != nil { + log.Warn().Err(err).Msg("failed to open hash cache, recreating") + return + } + defer f.Close() + + hashCacheJSON, err := io.ReadAll(f) + if err != nil { + log.Warn().Err(err).Msg("failed to read hash cache, recreating") + return + } + + if err := json.Unmarshal(hashCacheJSON, &hashCache); err != nil { + log.Warn().Err(err).Msg("failed to unmarshal hash cache, recreating") + return + } +} + +func saveHashCache() { + cacheFile := filepath.Join(viper.GetString("cache-dir"), "downloadCache", integrityFilename) + hashCacheJSON, err := json.Marshal(hashCache) + if err != nil { + log.Warn().Err(err).Msg("failed to marshal hash cache") + return + } + + if err := os.WriteFile(cacheFile, hashCacheJSON, 0o755); err != nil { + log.Warn().Err(err).Msg("failed to write hash cache") + return + } +} diff --git a/cli/cache/uplugin.go b/cli/cache/uplugin.go new file mode 100644 index 0000000..471866a --- /dev/null +++ b/cli/cache/uplugin.go @@ -0,0 +1,16 @@ +package cache + +type UPlugin struct { + SemVersion string `json:"SemVersion"` + FriendlyName string `json:"FriendlyName"` + Description string `json:"Description"` + CreatedBy string `json:"CreatedBy"` + Plugins []Plugins `json:"Plugins"` +} +type Plugins struct { + Name string `json:"Name"` + SemVersion string `json:"SemVersion"` + Enabled bool `json:"Enabled"` + BasePlugin bool `json:"BasePlugin"` + Optional bool `json:"Optional"` +} diff --git a/cli/context.go b/cli/context.go index c1e0d68..7352f8c 100644 --- a/cli/context.go +++ b/cli/context.go @@ -3,7 +3,10 @@ package cli import ( "github.com/Khan/genqlient/graphql" "github.com/pkg/errors" + "github.com/spf13/viper" + "github.com/satisfactorymodding/ficsit-cli/cli/cache" + "github.com/satisfactorymodding/ficsit-cli/cli/provider" "github.com/satisfactorymodding/ficsit-cli/ficsit" ) @@ -11,6 +14,7 @@ type GlobalContext struct { Installations *Installations Profiles *Profiles APIClient graphql.Client + Provider *provider.MixedProvider } var globalContext *GlobalContext @@ -20,6 +24,14 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) { return globalContext, nil } + apiClient := ficsit.InitAPI() + + mixedProvider := provider.InitMixedProvider(apiClient) + + if viper.GetBool("offline") { + mixedProvider.Offline = true + } + if !apiOnly { profiles, err := InitProfiles() if err != nil { @@ -31,14 +43,21 @@ func InitCLI(apiOnly bool) (*GlobalContext, error) { return nil, errors.Wrap(err, "failed to initialize installations") } + _, err = cache.LoadCache() + if err != nil { + return nil, errors.Wrap(err, "failed to load cache") + } + globalContext = &GlobalContext{ Installations: installations, Profiles: profiles, - APIClient: ficsit.InitAPI(), + APIClient: apiClient, + Provider: mixedProvider, } } else { globalContext = &GlobalContext{ - APIClient: ficsit.InitAPI(), + APIClient: apiClient, + Provider: mixedProvider, } } diff --git a/cli/dependency_resolver.go b/cli/dependency_resolver.go index 596c777..2d15698 100644 --- a/cli/dependency_resolver.go +++ b/cli/dependency_resolver.go @@ -5,24 +5,24 @@ import ( "fmt" "slices" - "github.com/Khan/genqlient/graphql" "github.com/mircearoata/pubgrub-go/pubgrub" "github.com/mircearoata/pubgrub-go/pubgrub/helpers" "github.com/mircearoata/pubgrub-go/pubgrub/semver" "github.com/pkg/errors" "github.com/spf13/viper" + "github.com/satisfactorymodding/ficsit-cli/cli/provider" "github.com/satisfactorymodding/ficsit-cli/ficsit" ) const smlDownloadTemplate = `https://github.com/satisfactorymodding/SatisfactoryModLoader/releases/download/v%s/SML.zip` type DependencyResolver struct { - apiClient graphql.Client + provider provider.Provider } -func NewDependencyResolver(apiClient graphql.Client) DependencyResolver { - return DependencyResolver{apiClient: apiClient} +func NewDependencyResolver(provider provider.Provider) DependencyResolver { + return DependencyResolver{provider} } var ( @@ -32,7 +32,7 @@ var ( ) type ficsitAPISource struct { - apiClient graphql.Client + provider provider.Provider lockfile *LockFile toInstall map[string]semver.Constraint modVersionInfo map[string]ficsit.ModVersionsWithDependenciesResponse @@ -67,7 +67,7 @@ func (f *ficsitAPISource) GetPackageVersions(pkg string) ([]pubgrub.PackageVersi } return versions, nil } - response, err := ficsit.ModVersionsWithDependencies(context.TODO(), f.apiClient, pkg) + response, err := f.provider.ModVersionsWithDependencies(context.TODO(), pkg) if err != nil { return nil, errors.Wrapf(err, "failed to fetch mod %s", pkg) } @@ -120,7 +120,7 @@ func (f *ficsitAPISource) PickVersion(pkg string, versions []semver.Version) sem } func (d DependencyResolver) ResolveModDependencies(constraints map[string]string, lockFile *LockFile, gameVersion int) (LockFile, error) { - smlVersionsDB, err := ficsit.SMLVersions(context.TODO(), d.apiClient) + smlVersionsDB, err := d.provider.SMLVersions(context.TODO()) if err != nil { return nil, errors.Wrap(err, "failed fetching SML versions") } @@ -140,7 +140,7 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string } ficsitSource := &ficsitAPISource{ - apiClient: d.apiClient, + provider: d.provider, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersionSemver, lockfile: lockFile, @@ -153,7 +153,7 @@ func (d DependencyResolver) ResolveModDependencies(constraints map[string]string finalError := err var solverErr pubgrub.SolvingError if errors.As(err, &solverErr) { - finalError = DependencyResolverError{SolvingError: solverErr, apiClient: d.apiClient, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersion} + finalError = DependencyResolverError{SolvingError: solverErr, provider: d.provider, smlVersions: smlVersionsDB.SmlVersions.Sml_versions, gameVersion: gameVersion} } return nil, errors.Wrap(finalError, "failed to solve dependencies") } diff --git a/cli/dependency_resolver_error.go b/cli/dependency_resolver_error.go index aede472..9ae02c2 100644 --- a/cli/dependency_resolver_error.go +++ b/cli/dependency_resolver_error.go @@ -5,16 +5,16 @@ import ( "fmt" "strings" - "github.com/Khan/genqlient/graphql" "github.com/mircearoata/pubgrub-go/pubgrub" "github.com/mircearoata/pubgrub-go/pubgrub/semver" + "github.com/satisfactorymodding/ficsit-cli/cli/provider" "github.com/satisfactorymodding/ficsit-cli/ficsit" ) type DependencyResolverError struct { pubgrub.SolvingError - apiClient graphql.Client + provider provider.Provider smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion gameVersion int } @@ -23,7 +23,7 @@ func (e DependencyResolverError) Error() string { rootPkg := e.Cause().Terms()[0].Dependency() writer := pubgrub.NewStandardErrorWriter(rootPkg). WithIncompatibilityStringer( - MakeDependencyResolverErrorStringer(e.apiClient, e.smlVersions, e.gameVersion), + MakeDependencyResolverErrorStringer(e.provider, e.smlVersions, e.gameVersion), ) e.WriteTo(writer) return writer.String() @@ -31,15 +31,15 @@ func (e DependencyResolverError) Error() string { type DependencyResolverErrorStringer struct { pubgrub.StandardIncompatibilityStringer - apiClient graphql.Client + provider provider.Provider packageNames map[string]string smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion gameVersion int } -func MakeDependencyResolverErrorStringer(apiClient graphql.Client, smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, gameVersion int) *DependencyResolverErrorStringer { +func MakeDependencyResolverErrorStringer(provider provider.Provider, smlVersions []ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, gameVersion int) *DependencyResolverErrorStringer { s := &DependencyResolverErrorStringer{ - apiClient: apiClient, + provider: provider, smlVersions: smlVersions, gameVersion: gameVersion, packageNames: map[string]string{}, @@ -58,7 +58,7 @@ func (w *DependencyResolverErrorStringer) getPackageName(pkg string) string { if name, ok := w.packageNames[pkg]; ok { return name } - result, err := ficsit.GetModName(context.Background(), w.apiClient, pkg) + result, err := w.provider.GetModName(context.TODO(), pkg) if err != nil { return pkg } @@ -98,7 +98,7 @@ func (w *DependencyResolverErrorStringer) Term(t pubgrub.Term, includeVersion bo } return fmt.Sprintf("%s \"%s\"", fullName, t.Constraint()) default: - res, err := ficsit.ModVersions(context.Background(), w.apiClient, t.Dependency(), ficsit.VersionFilter{ + res, err := w.provider.ModVersions(context.TODO(), t.Dependency(), ficsit.VersionFilter{ Limit: 100, }) if err != nil { diff --git a/cli/installations.go b/cli/installations.go index a70e840..235167e 100644 --- a/cli/installations.go +++ b/cli/installations.go @@ -14,6 +14,7 @@ import ( "github.com/rs/zerolog/log" "github.com/spf13/viper" + "github.com/satisfactorymodding/ficsit-cli/cli/cache" "github.com/satisfactorymodding/ficsit-cli/cli/disk" "github.com/satisfactorymodding/ficsit-cli/utils" ) @@ -317,7 +318,7 @@ func (i *Installation) ResolveProfile(ctx *GlobalContext) (LockFile, error) { return nil, err } - resolver := NewDependencyResolver(ctx.APIClient) + resolver := NewDependencyResolver(ctx.Provider) gameVersion, err := i.GetGameVersion(ctx) if err != nil { @@ -446,7 +447,7 @@ func (i *Installation) Install(ctx *GlobalContext, updates chan InstallUpdate) e } log.Info().Str("mod_reference", modReference).Str("version", version.Version).Str("link", version.Link).Msg("downloading mod") - reader, size, err := utils.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link, genericUpdates) + reader, size, err := cache.DownloadOrCache(modReference+"_"+version.Version+".zip", version.Hash, version.Link, genericUpdates) if err != nil { return errors.Wrap(err, "failed to download "+modReference+" from: "+version.Link) } diff --git a/cli/provider/ficsit.go b/cli/provider/ficsit.go new file mode 100644 index 0000000..7c28520 --- /dev/null +++ b/cli/provider/ficsit.go @@ -0,0 +1,47 @@ +package provider + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + + "github.com/satisfactorymodding/ficsit-cli/ficsit" +) + +type ficsitProvider struct { + client graphql.Client +} + +func initFicsitProvider(client graphql.Client) ficsitProvider { + return ficsitProvider{ + client, + } +} + +func (p ficsitProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { + return ficsit.Mods(context, p.client, filter) +} + +func (p ficsitProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) { + return ficsit.GetMod(context, p.client, modReference) +} + +func (p ficsitProvider) ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) { + return ficsit.ModVersions(context, p.client, modReference, filter) +} + +func (p ficsitProvider) SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error) { + return ficsit.SMLVersions(context, p.client) +} + +func (p ficsitProvider) ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) { + return ficsit.ResolveModDependencies(context, p.client, filter) +} + +func (p ficsitProvider) ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) { + return ficsit.ModVersionsWithDependencies(context, p.client, modID) +} + +func (p ficsitProvider) GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error) { + return ficsit.GetModName(context, p.client, modReference) +} diff --git a/cli/provider/local.go b/cli/provider/local.go new file mode 100644 index 0000000..8a1b83b --- /dev/null +++ b/cli/provider/local.go @@ -0,0 +1,289 @@ +package provider + +import ( + "context" + "strings" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/pkg/errors" + + "github.com/satisfactorymodding/ficsit-cli/cli/cache" + "github.com/satisfactorymodding/ficsit-cli/ficsit" +) + +type localProvider struct{} + +func initLocalProvider() localProvider { + return localProvider{} +} + +func (p localProvider) Mods(_ context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { + cachedMods, err := cache.GetCache() + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + mods := make([]ficsit.ModsModsGetModsModsMod, 0) + + for modReference, files := range cachedMods { + if modReference == "SML" { + continue + } + + if len(filter.References) > 0 { + skip := true + + for _, a := range filter.References { + if a == modReference { + skip = false + break + } + } + + if skip { + continue + } + } + + mods = append(mods, ficsit.ModsModsGetModsModsMod{ + Id: modReference, + Name: files[0].Plugin.FriendlyName, + Mod_reference: modReference, + Last_version_date: time.Now(), + Created_at: time.Now(), + Downloads: 0, + Popularity: 0, + Hotness: 0, + }) + } + + if filter.Limit == 0 { + filter.Limit = 25 + } + + low := filter.Offset + high := filter.Offset + filter.Limit + + if low > len(mods) { + return &ficsit.ModsResponse{ + Mods: ficsit.ModsModsGetMods{ + Count: 0, + Mods: []ficsit.ModsModsGetModsModsMod{}, + }, + }, nil + } + + if high > len(mods) { + high = len(mods) + } + + mods = mods[low:high] + + return &ficsit.ModsResponse{ + Mods: ficsit.ModsModsGetMods{ + Count: len(mods), + Mods: mods, + }, + }, nil +} + +func (p localProvider) GetMod(_ context.Context, modReference string) (*ficsit.GetModResponse, error) { + cachedModFiles, err := cache.GetCacheMod(modReference) + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + if len(cachedModFiles) == 0 { + return nil, errors.New("mod not found") + } + + authors := make([]ficsit.GetModModAuthorsUserMod, 0) + + for _, author := range strings.Split(cachedModFiles[0].Plugin.CreatedBy, ",") { + authors = append(authors, ficsit.GetModModAuthorsUserMod{ + Role: "Unknown", + User: ficsit.GetModModAuthorsUserModUser{ + Username: author, + }, + }) + } + + return &ficsit.GetModResponse{ + Mod: ficsit.GetModMod{ + Id: modReference, + Name: cachedModFiles[0].Plugin.FriendlyName, + Mod_reference: modReference, + Created_at: time.Now(), + Views: 0, + Downloads: 0, + Authors: authors, + Full_description: "", + Source_url: "", + }, + }, nil +} + +func (p localProvider) ModVersions(_ context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) { + cachedModFiles, err := cache.GetCacheMod(modReference) + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + if filter.Limit == 0 { + filter.Limit = 25 + } + + versions := make([]ficsit.ModVersionsModVersionsVersion, 0) + + for _, modFile := range cachedModFiles[filter.Offset : filter.Offset+filter.Limit] { + versions = append(versions, ficsit.ModVersionsModVersionsVersion{ + Id: modReference + ":" + modFile.Plugin.SemVersion, + Version: modFile.Plugin.SemVersion, + }) + } + + return &ficsit.ModVersionsResponse{ + Mod: ficsit.ModVersionsMod{ + Id: modReference, + Versions: versions, + }, + }, nil +} + +func (p localProvider) SMLVersions(_ context.Context) (*ficsit.SMLVersionsResponse, error) { + cachedSMLFiles, err := cache.GetCacheMod("SML") + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + smlVersions := make([]ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion, 0) + + for _, smlFile := range cachedSMLFiles { + smlVersions = append(smlVersions, ficsit.SMLVersionsSmlVersionsGetSMLVersionsSml_versionsSMLVersion{ + Id: "SML:" + smlFile.Plugin.SemVersion, + Version: smlFile.Plugin.SemVersion, + Satisfactory_version: 0, // TODO: where can this be obtained from? + }) + } + + return &ficsit.SMLVersionsResponse{ + SmlVersions: ficsit.SMLVersionsSmlVersionsGetSMLVersions{ + Count: len(smlVersions), + Sml_versions: smlVersions, + }, + }, nil +} + +func (p localProvider) ResolveModDependencies(_ context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) { + cachedMods, err := cache.GetCache() + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + mods := make([]ficsit.ResolveModDependenciesModsModVersion, 0) + + constraintMap := make(map[string]string) + + for _, constraint := range filter { + constraintMap[constraint.ModIdOrReference] = constraint.Version + } + + for modReference, modFiles := range cachedMods { + constraint, ok := constraintMap[modReference] + if !ok { + continue + } + + semverConstraint, err := semver.NewConstraint(constraint) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse constraint for %s", modReference) + } + + versions := make([]ficsit.ResolveModDependenciesModsModVersionVersionsVersion, 0) + + for _, modFile := range modFiles { + semverVersion, err := semver.NewVersion(modFile.Plugin.SemVersion) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse version for %s", modReference) + } + + if !semverConstraint.Check(semverVersion) { + continue + } + + dependencies := make([]ficsit.ResolveModDependenciesModsModVersionVersionsVersionDependenciesVersionDependency, 0) + + for _, dependency := range modFile.Plugin.Plugins { + if dependency.BasePlugin { + continue + } + dependencies = append(dependencies, ficsit.ResolveModDependenciesModsModVersionVersionsVersionDependenciesVersionDependency{ + Mod_id: dependency.Name, + Condition: dependency.SemVersion, + Optional: dependency.Optional, + }) + } + + versions = append(versions, ficsit.ResolveModDependenciesModsModVersionVersionsVersion{ + Id: modReference + ":" + modFile.Plugin.SemVersion, + Version: modFile.Plugin.SemVersion, + Link: "", + Hash: modFile.Hash, + Dependencies: dependencies, + }) + } + + mods = append(mods, ficsit.ResolveModDependenciesModsModVersion{ + Id: modReference, + Mod_reference: modReference, + Versions: versions, + }) + } + + return &ficsit.ResolveModDependenciesResponse{ + Mods: mods, + }, nil +} + +func (p localProvider) ModVersionsWithDependencies(_ context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) { + cachedModFiles, err := cache.GetCacheMod(modID) + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + versions := make([]ficsit.ModVersionsWithDependenciesModVersionsVersion, 0) + + for _, modFile := range cachedModFiles { + versions = append(versions, ficsit.ModVersionsWithDependenciesModVersionsVersion{ + Id: modID + ":" + modFile.Plugin.SemVersion, + Version: modFile.Plugin.SemVersion, + }) + } + + return &ficsit.ModVersionsWithDependenciesResponse{ + Mod: ficsit.ModVersionsWithDependenciesMod{ + Id: modID, + Versions: versions, + }, + }, nil +} + +func (p localProvider) GetModName(_ context.Context, modReference string) (*ficsit.GetModNameResponse, error) { + cachedModFiles, err := cache.GetCacheMod(modReference) + if err != nil { + return nil, errors.Wrap(err, "failed to get cache") + } + + if len(cachedModFiles) == 0 { + return nil, errors.New("mod not found") + } + + return &ficsit.GetModNameResponse{ + Mod: ficsit.GetModNameMod{ + Id: modReference, + Name: cachedModFiles[0].Plugin.FriendlyName, + Mod_reference: modReference, + }, + }, nil +} diff --git a/cli/provider/mixed.go b/cli/provider/mixed.go new file mode 100644 index 0000000..c488ac4 --- /dev/null +++ b/cli/provider/mixed.go @@ -0,0 +1,72 @@ +package provider + +import ( + "context" + + "github.com/Khan/genqlient/graphql" + + "github.com/satisfactorymodding/ficsit-cli/ficsit" +) + +type MixedProvider struct { + ficsitProvider ficsitProvider + localProvider localProvider + Offline bool +} + +func InitMixedProvider(client graphql.Client) *MixedProvider { + return &MixedProvider{ + ficsitProvider: initFicsitProvider(client), + localProvider: initLocalProvider(), + Offline: false, + } +} + +func (p MixedProvider) Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) { + if p.Offline { + return p.localProvider.Mods(context, filter) + } + return p.ficsitProvider.Mods(context, filter) +} + +func (p MixedProvider) GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) { + if p.Offline { + return p.localProvider.GetMod(context, modReference) + } + return p.ficsitProvider.GetMod(context, modReference) +} + +func (p MixedProvider) ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) { + if p.Offline { + return p.localProvider.ModVersions(context, modReference, filter) + } + return p.ficsitProvider.ModVersions(context, modReference, filter) +} + +func (p MixedProvider) SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error) { + if p.Offline { + return p.localProvider.SMLVersions(context) + } + return p.ficsitProvider.SMLVersions(context) +} + +func (p MixedProvider) ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) { + if p.Offline { + return p.localProvider.ResolveModDependencies(context, filter) + } + return p.ficsitProvider.ResolveModDependencies(context, filter) +} + +func (p MixedProvider) ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) { + if p.Offline { + return p.localProvider.ModVersionsWithDependencies(context, modID) + } + return p.ficsitProvider.ModVersionsWithDependencies(context, modID) +} + +func (p MixedProvider) GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error) { + if p.Offline { + return p.localProvider.GetModName(context, modReference) + } + return p.ficsitProvider.GetModName(context, modReference) +} diff --git a/cli/provider/provider.go b/cli/provider/provider.go new file mode 100644 index 0000000..f6aaae6 --- /dev/null +++ b/cli/provider/provider.go @@ -0,0 +1,18 @@ +package provider + +import ( + "context" + + "github.com/satisfactorymodding/ficsit-cli/ficsit" +) + +type Provider interface { + Mods(context context.Context, filter ficsit.ModFilter) (*ficsit.ModsResponse, error) + GetMod(context context.Context, modReference string) (*ficsit.GetModResponse, error) + ModVersions(context context.Context, modReference string, filter ficsit.VersionFilter) (*ficsit.ModVersionsResponse, error) + SMLVersions(context context.Context) (*ficsit.SMLVersionsResponse, error) + ResolveModDependencies(context context.Context, filter []ficsit.ModVersionConstraint) (*ficsit.ResolveModDependenciesResponse, error) + + ModVersionsWithDependencies(context context.Context, modID string) (*ficsit.ModVersionsWithDependenciesResponse, error) + GetModName(context context.Context, modReference string) (*ficsit.GetModNameResponse, error) +} diff --git a/cli/resolving_test.go b/cli/resolving_test.go index 0c12b64..8060621 100644 --- a/cli/resolving_test.go +++ b/cli/resolving_test.go @@ -13,7 +13,7 @@ func profilesGetResolver() DependencyResolver { panic(err) } - return NewDependencyResolver(ctx.APIClient) + return NewDependencyResolver(ctx.Provider) } func TestProfileResolution(t *testing.T) { diff --git a/cmd/root.go b/cmd/root.go index 11849e3..e6c5f1f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -138,6 +138,8 @@ func init() { RootCmd.PersistentFlags().String("graphql-api", "/v2/query", "Path for GraphQL API") RootCmd.PersistentFlags().String("api-key", "", "API key to use when sending requests") + RootCmd.PersistentFlags().Bool("offline", false, "Whether to only use local data") + _ = viper.BindPFlag("log", RootCmd.PersistentFlags().Lookup("log")) _ = viper.BindPFlag("log-file", RootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet")) @@ -153,4 +155,6 @@ func init() { _ = viper.BindPFlag("api-base", RootCmd.PersistentFlags().Lookup("api-base")) _ = viper.BindPFlag("graphql-api", RootCmd.PersistentFlags().Lookup("graphql-api")) _ = viper.BindPFlag("api-key", RootCmd.PersistentFlags().Lookup("api-key")) + + _ = viper.BindPFlag("offline", RootCmd.PersistentFlags().Lookup("offline")) } diff --git a/go.mod b/go.mod index f59c896..338d767 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.3.6 github.com/Khan/genqlient v0.5.0 github.com/MarvinJWendt/testza v0.5.2 + github.com/Masterminds/semver/v3 v3.2.1 github.com/PuerkitoBio/goquery v1.8.0 github.com/charmbracelet/bubbles v0.14.0 github.com/charmbracelet/bubbletea v0.22.1 diff --git a/go.sum b/go.sum index ac56436..a2b5a64 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= diff --git a/tea/components/header.go b/tea/components/header.go index 59b6efc..3c65d4f 100644 --- a/tea/components/header.go +++ b/tea/components/header.go @@ -57,5 +57,10 @@ func (h headerComponent) View() string { out += "N/A" } + if h.root.GetProvider().Offline { + out += "\n" + out += h.labelStyle.Render("Offline") + } + return lipgloss.NewStyle().Margin(1, 0).Render(out) } diff --git a/tea/components/types.go b/tea/components/types.go index 1020a9f..f0e120f 100644 --- a/tea/components/types.go +++ b/tea/components/types.go @@ -5,6 +5,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/satisfactorymodding/ficsit-cli/cli" + "github.com/satisfactorymodding/ficsit-cli/cli/provider" ) type RootModel interface { @@ -17,6 +18,7 @@ type RootModel interface { SetCurrentInstallation(installation *cli.Installation) error GetAPIClient() graphql.Client + GetProvider() *provider.MixedProvider Size() tea.WindowSizeMsg SetSize(size tea.WindowSizeMsg) diff --git a/tea/root.go b/tea/root.go index 3b46ff6..4a98079 100644 --- a/tea/root.go +++ b/tea/root.go @@ -7,6 +7,7 @@ import ( "github.com/pkg/errors" "github.com/satisfactorymodding/ficsit-cli/cli" + "github.com/satisfactorymodding/ficsit-cli/cli/provider" "github.com/satisfactorymodding/ficsit-cli/tea/components" "github.com/satisfactorymodding/ficsit-cli/tea/scenes" ) @@ -25,7 +26,7 @@ func newModel(global *cli.GlobalContext) *rootModel { Width: 20, Height: 14, }, - dependencyResolver: cli.NewDependencyResolver(global.APIClient), + dependencyResolver: cli.NewDependencyResolver(global.Provider), } m.headerComponent = components.NewHeaderComponent(m) @@ -63,6 +64,10 @@ func (m *rootModel) GetAPIClient() graphql.Client { return m.global.APIClient } +func (m *rootModel) GetProvider() *provider.MixedProvider { + return m.global.Provider +} + func (m *rootModel) Size() tea.WindowSizeMsg { return m.currentSize } diff --git a/tea/scenes/mods/installed_mods.go b/tea/scenes/mods/installed_mods.go index e5cee13..af7ba41 100644 --- a/tea/scenes/mods/installed_mods.go +++ b/tea/scenes/mods/installed_mods.go @@ -111,7 +111,7 @@ func (m installedModsList) LoadModData() { i++ } - mods, err := ficsit.Mods(context.TODO(), m.root.GetAPIClient(), ficsit.ModFilter{ + mods, err := m.root.GetProvider().Mods(context.TODO(), ficsit.ModFilter{ References: references, }) if err != nil { diff --git a/tea/scenes/mods/mod_info.go b/tea/scenes/mods/mod_info.go index d55a70d..836fba0 100644 --- a/tea/scenes/mods/mod_info.go +++ b/tea/scenes/mods/mod_info.go @@ -87,7 +87,7 @@ func NewModInfo(root components.RootModel, parent tea.Model, mod utils.Mod) tea. model.help.Width = root.Size().Width go func() { - fullMod, err := ficsit.GetMod(context.TODO(), root.GetAPIClient(), mod.Reference) + fullMod, err := root.GetProvider().GetMod(context.TODO(), mod.Reference) if err != nil { model.modError <- err.Error() return diff --git a/tea/scenes/mods/mods.go b/tea/scenes/mods/mods.go index 0fa12ce..c2f2d10 100644 --- a/tea/scenes/mods/mods.go +++ b/tea/scenes/mods/mods.go @@ -221,7 +221,7 @@ func NewMods(root components.RootModel, parent tea.Model) tea.Model { allMods := make([]ficsit.ModsModsGetModsModsMod, 0) offset := 0 for { - mods, err := ficsit.Mods(context.TODO(), root.GetAPIClient(), ficsit.ModFilter{ + mods, err := root.GetProvider().Mods(context.TODO(), ficsit.ModFilter{ Limit: 100, Offset: offset, Order_by: ficsit.ModFieldsLastVersionDate, diff --git a/tea/scenes/mods/select_mod_version.go b/tea/scenes/mods/select_mod_version.go index c3becb9..c492c7d 100644 --- a/tea/scenes/mods/select_mod_version.go +++ b/tea/scenes/mods/select_mod_version.go @@ -56,7 +56,7 @@ func NewModVersionList(root components.RootModel, parent tea.Model, mod utils.Mo allVersions := make([]ficsit.ModVersionsModVersionsVersion, 0) offset := 0 for { - versions, err := ficsit.ModVersions(context.TODO(), root.GetAPIClient(), mod.Reference, ficsit.VersionFilter{ + versions, err := root.GetProvider().ModVersions(context.TODO(), mod.Reference, ficsit.VersionFilter{ Limit: 100, Offset: offset, Order: ficsit.OrderDesc, diff --git a/utils/io.go b/utils/io.go index d71035f..7c07983 100644 --- a/utils/io.go +++ b/utils/io.go @@ -4,134 +4,20 @@ import ( "archive/zip" "crypto/sha256" "encoding/hex" - "fmt" "io" - "net/http" "os" "path/filepath" "github.com/pkg/errors" - "github.com/spf13/viper" "github.com/satisfactorymodding/ficsit-cli/cli/disk" ) -type Progresser struct { - io.Reader - updates chan GenericUpdate - total int64 - running int64 -} - -func (pt *Progresser) Read(p []byte) (int, error) { - n, err := pt.Reader.Read(p) - pt.running += int64(n) - - if err == nil { - if pt.updates != nil { - select { - case pt.updates <- GenericUpdate{Progress: float64(pt.running) / float64(pt.total)}: - default: - } - } - } - - if err == io.EOF { - return n, io.EOF - } - - return n, errors.Wrap(err, "failed to read") -} - type GenericUpdate struct { ModReference *string Progress float64 } -func DownloadOrCache(cacheKey string, hash string, url string, updates chan GenericUpdate) (io.ReaderAt, int64, error) { - downloadCache := filepath.Join(viper.GetString("cache-dir"), "downloadCache") - if err := os.MkdirAll(downloadCache, 0o777); err != nil { - if !os.IsExist(err) { - return nil, 0, errors.Wrap(err, "failed creating download cache") - } - } - - location := filepath.Join(downloadCache, cacheKey) - - stat, err := os.Stat(location) - if err == nil { - existingHash := "" - - if hash != "" { - f, err := os.Open(location) - if err != nil { - return nil, 0, errors.Wrap(err, "failed to open file: "+location) - } - - existingHash, err = SHA256Data(f) - if err != nil { - return nil, 0, errors.Wrap(err, "could not compute hash for file: "+location) - } - } - - if hash == existingHash { - f, err := os.Open(location) - if err != nil { - return nil, 0, errors.Wrap(err, "failed to open file: "+location) - } - - return f, stat.Size(), nil - } - - if err := os.Remove(location); err != nil { - return nil, 0, errors.Wrap(err, "failed to delete file: "+location) - } - } else if !os.IsNotExist(err) { - return nil, 0, errors.Wrap(err, "failed to stat file: "+location) - } - - out, err := os.Create(location) - if err != nil { - return nil, 0, errors.Wrap(err, "failed creating file at: "+location) - } - defer out.Close() - - resp, err := http.Get(url) - if err != nil { - return nil, 0, errors.Wrap(err, "failed to fetch: "+url) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, 0, fmt.Errorf("bad status: %s on url: %s", resp.Status, url) - } - - progresser := &Progresser{ - Reader: resp.Body, - total: resp.ContentLength, - updates: updates, - } - - _, err = io.Copy(out, progresser) - if err != nil { - return nil, 0, errors.Wrap(err, "failed writing file to disk") - } - - f, err := os.Open(location) - if err != nil { - return nil, 0, errors.Wrap(err, "failed to open file: "+location) - } - - if updates != nil { - select { - case updates <- GenericUpdate{Progress: 1}: - default: - } - } - - return f, resp.ContentLength, nil -} - func SHA256Data(f io.Reader) (string, error) { h := sha256.New() if _, err := io.Copy(h, f); err != nil {