diff --git a/cmd/cli/commands/add/add.go b/cmd/cli/commands/add/add.go index e4d9974..ba466cd 100644 --- a/cmd/cli/commands/add/add.go +++ b/cmd/cli/commands/add/add.go @@ -1,22 +1,21 @@ package add import ( - "cloudsave/pkg/remote" - "cloudsave/pkg/repository" + "cloudsave/pkg/data" "context" "flag" "fmt" "os" "path/filepath" - "strings" "github.com/google/subcommands" ) type ( AddCmd struct { - name string - remote string + Service *data.Service + name string + remote string } ) @@ -51,17 +50,16 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s p.name = filepath.Base(path) } - m, err := repository.Add(p.name, path) + gameID, err := p.Service.Add(p.name, path, p.remote) if err != nil { - fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err) + fmt.Fprintln(os.Stderr, "error: failed to add this gamesave to the datastore:", err) return subcommands.ExitFailure } - if len(strings.TrimSpace(p.remote)) > 0 { - remote.Set(m.ID, p.remote) + if err := p.Service.Scan(gameID); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to scan:", err) + return subcommands.ExitFailure } - fmt.Println(m.ID) - return subcommands.ExitSuccess } diff --git a/cmd/cli/commands/apply/apply.go b/cmd/cli/commands/apply/apply.go index b6610d0..041b805 100644 --- a/cmd/cli/commands/apply/apply.go +++ b/cmd/cli/commands/apply/apply.go @@ -1,13 +1,10 @@ package apply import ( - "cloudsave/pkg/repository" - "cloudsave/pkg/tools/archive" "context" "flag" "fmt" "os" - "path/filepath" "github.com/google/subcommands" ) @@ -35,36 +32,10 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitUsageError } - gameID := f.Arg(0) - uuid := f.Arg(1) + //gameID := f.Arg(0) + //uuid := f.Arg(1) - g, err := repository.One(gameID) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to open game metadata: %s\n", err) - return subcommands.ExitFailure - } - - if err := repository.RestoreArchive(gameID, uuid); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to restore backup: %s\n", err) - return subcommands.ExitFailure - } - - if err := os.RemoveAll(g.Path); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to remove old data: %s\n", err) - return subcommands.ExitFailure - } - - file, err := os.OpenFile(filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz"), os.O_RDONLY, 0) - if err != nil { - fmt.Fprintf(os.Stderr, "error: failed to open archive: %s\n", err) - return subcommands.ExitFailure - } - defer file.Close() - - if err := archive.Untar(file, g.Path); err != nil { - fmt.Fprintf(os.Stderr, "error: failed to extract archive: %s\n", err) - return subcommands.ExitFailure - } + panic("not implemented") return subcommands.ExitSuccess } diff --git a/cmd/cli/commands/list/list.go b/cmd/cli/commands/list/list.go index 3b80f24..330127e 100644 --- a/cmd/cli/commands/list/list.go +++ b/cmd/cli/commands/list/list.go @@ -2,8 +2,8 @@ package list import ( "cloudsave/cmd/cli/tools/prompt/credentials" + "cloudsave/pkg/data" "cloudsave/pkg/remote/client" - "cloudsave/pkg/repository" "context" "flag" "fmt" @@ -14,8 +14,9 @@ import ( type ( ListCmd struct { - remote bool - backup bool + Service *data.Service + remote bool + backup bool } ) @@ -44,25 +45,25 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) username, password, err := credentials.Read() if err != nil { - fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) + fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err) return subcommands.ExitFailure } - if err := remote(f.Arg(0), username, password, p.backup); err != nil { + if err := p.server(f.Arg(0), username, password, p.backup); err != nil { fmt.Fprintln(os.Stderr, "error:", err) return subcommands.ExitFailure } return subcommands.ExitSuccess } - if err := local(p.backup); err != nil { + if err := p.local(p.backup); err != nil { fmt.Fprintln(os.Stderr, "error:", err) return subcommands.ExitFailure } return subcommands.ExitSuccess } -func local(includeBackup bool) error { - games, err := repository.All() +func (p *ListCmd) local(includeBackup bool) error { + games, err := p.Service.AllGames() if err != nil { return fmt.Errorf("failed to load datastore: %w", err) } @@ -72,7 +73,7 @@ func local(includeBackup bool) error { fmt.Println("Name:", g.Name) fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") if includeBackup { - bk, err := repository.Archives(g.ID) + bk, err := p.Service.AllBackups(g.ID) if err != nil { return fmt.Errorf("failed to list backup files: %w", err) } @@ -89,7 +90,7 @@ func local(includeBackup bool) error { return nil } -func remote(url, username, password string, includeBackup bool) error { +func (p *ListCmd) server(url, username, password string, includeBackup bool) error { cli := client.New(url, username, password) if err := cli.Ping(); err != nil { diff --git a/cmd/cli/commands/pull/pull.go b/cmd/cli/commands/pull/pull.go index 4490d1c..0547bb6 100644 --- a/cmd/cli/commands/pull/pull.go +++ b/cmd/cli/commands/pull/pull.go @@ -2,20 +2,19 @@ package pull import ( "cloudsave/cmd/cli/tools/prompt/credentials" + "cloudsave/pkg/data" "cloudsave/pkg/remote/client" - "cloudsave/pkg/repository" - "cloudsave/pkg/tools/archive" "context" "flag" "fmt" "os" - "path/filepath" "github.com/google/subcommands" ) type ( PullCmd struct { + Service *data.Service } ) @@ -44,45 +43,33 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) username, password, err := credentials.Read() if err != nil { - fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) + fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err) return subcommands.ExitFailure } cli := client.New(url, username, password) if err := cli.Ping(); err != nil { - fmt.Fprintf(os.Stderr, "failed to connect to the remote: %s", err) + fmt.Fprintf(os.Stderr, "error: failed to connect to the remote: %s", err) return subcommands.ExitFailure } - archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") + if err := p.Service.PullCurrent(gameID, path, cli); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to pull current archive: %s", err) + return subcommands.ExitFailure + } - m, err := cli.Metadata(gameID) + ids, err := cli.ListArchives(gameID) if err != nil { - fmt.Fprintf(os.Stderr, "failed to get metadata: %s", err) + fmt.Fprintf(os.Stderr, "error: failed to list backup archive: %s", err) return subcommands.ExitFailure } - err = repository.Register(m, path) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err) - return subcommands.ExitFailure - } - - if err := cli.Pull(gameID, archivePath); err != nil { - fmt.Fprintf(os.Stderr, "failed to pull from the remote: %s", err) - return subcommands.ExitFailure - } - - fi, err := os.OpenFile(archivePath, os.O_RDONLY, 0) - if err != nil { - fmt.Fprintf(os.Stderr, "failed to open archive: %s", err) - return subcommands.ExitFailure - } - - if err := archive.Untar(fi, path); err != nil { - fmt.Fprintf(os.Stderr, "failed to unarchive file: %s", err) - return subcommands.ExitFailure + for _, id := range ids { + if err := p.Service.PullBackup(gameID, id, cli); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to pull backup archive %s: %s", id, err) + return subcommands.ExitFailure + } } return subcommands.ExitSuccess diff --git a/cmd/cli/commands/remote/remote.go b/cmd/cli/commands/remote/remote.go index d35a1c5..8bc8186 100644 --- a/cmd/cli/commands/remote/remote.go +++ b/cmd/cli/commands/remote/remote.go @@ -1,9 +1,9 @@ package remote import ( + "cloudsave/pkg/data" "cloudsave/pkg/remote" "cloudsave/pkg/remote/client" - "cloudsave/pkg/repository" "context" "flag" "fmt" @@ -14,8 +14,9 @@ import ( type ( RemoteCmd struct { - set bool - list bool + Service *data.Service + set bool + list bool } ) @@ -43,7 +44,7 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface switch { case p.list: { - if err := list(); err != nil { + if err := p.print(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) return subcommands.ExitFailure } @@ -68,8 +69,8 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface return subcommands.ExitSuccess } -func list() error { - games, err := repository.All() +func (p *RemoteCmd) print() error { + games, err := p.Service.AllGames() if err != nil { return fmt.Errorf("failed to load datastore: %w", err) } diff --git a/cmd/cli/commands/remove/remove.go b/cmd/cli/commands/remove/remove.go index bdfc1e1..1b424c1 100644 --- a/cmd/cli/commands/remove/remove.go +++ b/cmd/cli/commands/remove/remove.go @@ -1,7 +1,7 @@ package remove import ( - "cloudsave/pkg/repository" + "cloudsave/pkg/data" "context" "flag" "fmt" @@ -11,7 +11,9 @@ import ( ) type ( - RemoveCmd struct{} + RemoveCmd struct { + Service *data.Service + } ) func (*RemoveCmd) Name() string { return "remove" } @@ -33,7 +35,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} return subcommands.ExitUsageError } - err := repository.Remove(f.Arg(0)) + err := p.Service.RemoveGame(f.Arg(0)) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/run/run.go b/cmd/cli/commands/run/run.go index 8c260fb..e381a4f 100644 --- a/cmd/cli/commands/run/run.go +++ b/cmd/cli/commands/run/run.go @@ -1,22 +1,18 @@ package run import ( - "cloudsave/pkg/repository" - "cloudsave/pkg/tools/archive" + "cloudsave/pkg/data" "context" "flag" "fmt" - "io" "os" - "path/filepath" - "time" "github.com/google/subcommands" - "github.com/schollz/progressbar/v3" ) type ( RunCmd struct { + Service *data.Service } ) @@ -34,26 +30,15 @@ and a new archive is created with a new version number. func (p *RunCmd) SetFlags(f *flag.FlagSet) {} func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - datastore, err := repository.All() + datastore, err := p.Service.AllGames() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure } for _, metadata := range datastore { - metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID) - //todo transaction - err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run")) - if err != nil { - fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) - return subcommands.ExitFailure - } - if err := repository.SetVersion(metadata.ID, metadata.Version+1); err != nil { - fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) - return subcommands.ExitFailure - } - if err := repository.SetDate(metadata.ID, time.Now()); err != nil { - fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) + if err := p.Service.Scan(metadata.ID); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to scan:", err) return subcommands.ExitFailure } fmt.Println("✅", metadata.Name) @@ -62,78 +47,3 @@ func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s fmt.Println("done.") return subcommands.ExitSuccess } - -// archiveIfChanged will archive srcDir into destTarGz only if any file -// in srcDir has a modification time > the last run time stored in stateFile. -// After archiving, it updates stateFile to the current time. -func archiveIfChanged(gameID, srcDir, destTarGz, stateFile string) error { - pg := progressbar.New(-1) - destroyPg := func() { - pg.Finish() - pg.Clear() - pg.Close() - - } - defer destroyPg() - - pg.Describe("Scanning " + gameID + "...") - - // load last run time - var lastRun time.Time - data, err := os.ReadFile(stateFile) - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to reading state file: %w", err) - } - if err == nil { - lastRun, err = time.Parse(time.RFC3339, string(data)) - if err != nil { - return fmt.Errorf("parsing state file timestamp: %w", err) - } - } - - // check for changes - changed := false - err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - if info.ModTime().After(lastRun) { - changed = true - return io.EOF // early exit - } - return nil - }) - if err != nil && err != io.EOF { - return fmt.Errorf("failed to scanning source directory: %w", err) - } - - if !changed { - pg.Finish() - return nil - } - - // make a backup - pg.Describe("Backup current data...") - if err := repository.MakeArchive(gameID); err != nil { - return fmt.Errorf("failed to archive data: %w", err) - } - - // create archive - pg.Describe("Archiving new data...") - f, err := os.Create(destTarGz) - if err != nil { - return fmt.Errorf("failed to creating archive file: %w", err) - } - defer f.Close() - - if err := archive.Tar(f, srcDir); err != nil { - return fmt.Errorf("failed archiving files: %w", err) - } - - now := time.Now().UTC().Format(time.RFC3339) - if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil { - return fmt.Errorf("updating state file: %w", err) - } - - return nil -} diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 9702fbb..09d8b0d 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -3,6 +3,7 @@ package sync import ( "cloudsave/cmd/cli/tools/prompt" "cloudsave/cmd/cli/tools/prompt/credentials" + "cloudsave/pkg/data" "cloudsave/pkg/remote" "cloudsave/pkg/remote/client" "cloudsave/pkg/repository" @@ -12,7 +13,6 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "time" "github.com/google/subcommands" @@ -21,6 +21,7 @@ import ( type ( SyncCmd struct { + Service *data.Service } ) @@ -37,7 +38,7 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) { } func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - games, err := repository.All() + games, err := p.Service.AllGames() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure @@ -92,12 +93,6 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name)) - hlocal, err := repository.Hash(r.GameID) - if err != nil { - destroyPg() - slog.Error(err.Error()) - continue - } hremote, err := cli.Hash(r.GameID) if err != nil { @@ -106,13 +101,6 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - vlocal, err := repository.Version(r.GameID) - if err != nil { - destroyPg() - slog.Error(err.Error()) - continue - } - remoteMetadata, err := cli.Metadata(r.GameID) if err != nil { destroyPg() @@ -130,11 +118,11 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) slog.Warn("failed to push backup files", "err", err) } - if hlocal == hremote { + if g.MD5 == hremote { destroyPg() - if vlocal != remoteMetadata.Version { + if g.Version != remoteMetadata.Version { slog.Debug("version is not the same, but the hash is equal. Updating local database") - if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil { + if err := p.Service.SetVersion(r.GameID, remoteMetadata.Version); err != nil { fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) continue } @@ -143,7 +131,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - if vlocal > remoteMetadata.Version { + if g.Version > remoteMetadata.Version { pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name)) if err := push(g, cli); err != nil { destroyPg() @@ -155,22 +143,21 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - if vlocal < remoteMetadata.Version { + if g.Version < remoteMetadata.Version { destroyPg() if err := pull(r.GameID, cli); err != nil { destroyPg() fmt.Fprintln(os.Stderr, "failed to push:", err) return subcommands.ExitFailure } - if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil { + + g.Version = remoteMetadata.Version + g.Date = remoteMetadata.Date + + if err := p.Service.UpdateMetadata(g.ID, g); err != nil { destroyPg() - fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) - continue - } - if err := repository.SetDate(r.GameID, remoteMetadata.Date); err != nil { - destroyPg() - fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) - continue + fmt.Fprintln(os.Stderr, "failed to push:", err) + return subcommands.ExitFailure } fmt.Println(g.Name + ": pulled") continue @@ -178,8 +165,8 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) destroyPg() - if vlocal == remoteMetadata.Version { - if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil { + if g.Version == remoteMetadata.Version { + if err := p.conflict(r.GameID, g, remoteMetadata, cli); err != nil { fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err) continue } @@ -191,8 +178,8 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitSuccess } -func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error { - g, err := repository.One(gameID) +func (p *SyncCmd) conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error { + g, err := p.Service.One(gameID) if err != nil { slog.Warn("a conflict was found but the game is not found in the database") slog.Debug("debug info", "gameID", gameID) @@ -211,35 +198,33 @@ func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client. switch res { case prompt.My: { - if err := push(m, cli); err != nil { + if err := p.push(m, cli); err != nil { return fmt.Errorf("failed to push: %w", err) } } case prompt.Their: { - if err := pull(gameID, cli); err != nil { + if err := p.pull(gameID, cli); err != nil { return fmt.Errorf("failed to push: %w", err) } - if err := repository.SetVersion(gameID, remoteMetadata.Version); err != nil { - return fmt.Errorf("failed to synchronize version number: %w", err) - } - if err := repository.SetDate(gameID, remoteMetadata.Date); err != nil { - return fmt.Errorf("failed to synchronize date: %w", err) + g.Version = remoteMetadata.Version + g.Date = remoteMetadata.Date + + if err := p.Service.UpdateMetadata(g.ID, g); err != nil { + return fmt.Errorf("failed to push: %w", err) } } } return nil } -func push(m repository.Metadata, cli *client.Client) error { - archivePath := filepath.Join(repository.DatastorePath(), m.ID, "data.tar.gz") - - return cli.PushSave(archivePath, m) +func (p *SyncCmd) push(m repository.Metadata, cli *client.Client) error { + } -func pushBackup(m repository.Metadata, cli *client.Client) error { - bs, err := repository.Archives(m.ID) +func (p *SyncCmd) pushBackup(m repository.Metadata, cli *client.Client) error { + bs, err := p.Service.AllBackups(m.ID) if err != nil { return err } @@ -262,7 +247,7 @@ func pushBackup(m repository.Metadata, cli *client.Client) error { return nil } -func pullBackup(m repository.Metadata, cli *client.Client) error { +func (p *SyncCmd) pullBackup(m repository.Metadata, cli *client.Client) error { bs, err := cli.ListArchives(m.ID) if err != nil { return err @@ -274,20 +259,13 @@ func pullBackup(m repository.Metadata, cli *client.Client) error { return err } - linfo, err := repository.Archive(m.ID, uuid) + linfo, err := p.Service.Backup(m.ID, uuid) if err != nil { - if !errors.Is(err, os.ErrNotExist) { - return err - } - } - - path := filepath.Join(repository.DatastorePath(), m.ID, "hist", uuid) - if err := os.MkdirAll(path, 0740); err != nil { return err } - if rinfo.MD5 != linfo.MD5 { - if err := cli.PullBackup(m.ID, uuid, filepath.Join(path, "data.tar.gz")); err != nil { + if linfo != rinfo { + if err := p.Service.PullBackup(m.ID, uuid, cli); err != nil { return err } } @@ -295,10 +273,8 @@ func pullBackup(m repository.Metadata, cli *client.Client) error { return nil } -func pull(gameID string, cli *client.Client) error { - archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") - - return cli.Pull(gameID, archivePath) +func (p *SyncCmd) pull(gameID string, cli *client.Client) error { + return p.Service.PullArchive(gameID, "", cli) } func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) { diff --git a/cmd/cli/main.go b/cmd/cli/main.go index db4ce2a..2b43b79 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -9,27 +9,48 @@ import ( "cloudsave/cmd/cli/commands/run" "cloudsave/cmd/cli/commands/sync" "cloudsave/cmd/cli/commands/version" + "cloudsave/pkg/data" + "cloudsave/pkg/repository" "context" "flag" "os" + "path/filepath" "github.com/google/subcommands" ) func main() { + roaming, err := os.UserConfigDir() + if err != nil { + panic("failed to get user config path: " + err.Error()) + } + + datastorepath := filepath.Join(roaming, "cloudsave", "data") + err = os.MkdirAll(datastorepath, 0740) + if err != nil { + panic("cannot make the datastore:" + err.Error()) + } + + repo, err := repository.NewLazyRepository(datastorepath) + if err != nil { + panic("cannot make the datastore:" + err.Error()) + } + + s := data.NewService(repo) + subcommands.Register(subcommands.HelpCommand(), "help") subcommands.Register(subcommands.FlagsCommand(), "help") subcommands.Register(subcommands.CommandsCommand(), "help") subcommands.Register(&version.VersionCmd{}, "help") - subcommands.Register(&add.AddCmd{}, "management") - subcommands.Register(&run.RunCmd{}, "management") - subcommands.Register(&list.ListCmd{}, "management") - subcommands.Register(&remove.RemoveCmd{}, "management") + subcommands.Register(&add.AddCmd{Service: s}, "management") + subcommands.Register(&run.RunCmd{Service: s}, "management") + subcommands.Register(&list.ListCmd{Service: s}, "management") + subcommands.Register(&remove.RemoveCmd{Service: s}, "management") - subcommands.Register(&remote.RemoteCmd{}, "remote") - subcommands.Register(&sync.SyncCmd{}, "remote") - subcommands.Register(&pull.PullCmd{}, "remote") + subcommands.Register(&remote.RemoteCmd{Service: s}, "remote") + subcommands.Register(&sync.SyncCmd{Service: s}, "remote") + subcommands.Register(&pull.PullCmd{Service: s}, "remote") flag.Parse() ctx := context.Background() diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index 74c0c5c..c041293 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -1,7 +1,6 @@ package api import ( - "cloudsave/cmd/server/data" "cloudsave/pkg/repository" "encoding/json" "errors" diff --git a/cmd/server/data/data.go b/cmd/server/data/data.go deleted file mode 100644 index 1a99804..0000000 --- a/cmd/server/data/data.go +++ /dev/null @@ -1,243 +0,0 @@ -package data - -import ( - "cloudsave/pkg/repository" - "cloudsave/pkg/tools/hash" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "sync" -) - -type ( - cache map[string]cachedInfo - - cachedInfo struct { - MD5 string - Version int - } -) - -var ( - ErrBackupNotExists error = errors.New("backup not found") - ErrNotExists error = errors.New("not found") - - // singleton - hashCacheMu sync.RWMutex - hashCache cache = make(map[string]cachedInfo) -) - -func (c cache) Get(gameID string) (cachedInfo, bool) { - hashCacheMu.RLock() - defer hashCacheMu.RUnlock() - - if v, ok := c[gameID]; ok { - return v, true - } - return cachedInfo{}, false -} - -func (c cache) Register(gameID string, v cachedInfo) { - hashCacheMu.Lock() - defer hashCacheMu.Unlock() - - c[gameID] = v -} - -func (c cache) Remove(gameID string) { - hashCacheMu.Lock() - defer hashCacheMu.Unlock() - - delete(c, gameID) -} - -func Write(gameID, documentRoot string, r io.Reader) error { - dataFolderPath := filepath.Join(documentRoot, "data", gameID) - partPath := filepath.Join(dataFolderPath, "data.tar.gz.part") - finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz") - - if err := makeDataFolder(gameID, documentRoot); err != nil { - return err - } - - f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740) - if err != nil { - return err - } - - if _, err := io.Copy(f, r); err != nil { - f.Close() - if err := os.Remove(partPath); err != nil { - return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err) - } - return fmt.Errorf("failed to write the file: %w", err) - } - f.Close() - - if err := os.Rename(partPath, finalFilePath); err != nil { - return err - } - - hashCache.Remove(gameID) - return nil -} - -func WriteHist(gameID, documentRoot, uuid string, r io.Reader) error { - dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid) - partPath := filepath.Join(dataFolderPath, "data.tar.gz.part") - finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz") - - if err := makeDataFolder(gameID, documentRoot); err != nil { - return err - } - - if err := os.MkdirAll(dataFolderPath, 0740); err != nil { - return err - } - - f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740) - if err != nil { - return err - } - - if _, err := io.Copy(f, r); err != nil { - f.Close() - if err := os.Remove(partPath); err != nil { - return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err) - } - return fmt.Errorf("failed to write the file: %w", err) - } - f.Close() - - if err := os.Rename(partPath, finalFilePath); err != nil { - return err - } - - return nil -} - -func UpdateMetadata(gameID, documentRoot string, m repository.Metadata) error { - if err := makeDataFolder(gameID, documentRoot); err != nil { - return err - } - path := filepath.Join(documentRoot, "data", gameID, "metadata.json") - - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0740) - if err != nil { - return err - } - defer f.Close() - - e := json.NewEncoder(f) - return e.Encode(m) -} - -func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { - dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz") - cacheID := gameID + ":" + uuid - - finfo, err := os.Stat(dataFolderPath) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return repository.Backup{}, ErrBackupNotExists - } - return repository.Backup{}, err - } - - if m, ok := hashCache.Get(cacheID); ok { - return repository.Backup{ - CreatedAt: finfo.ModTime(), - UUID: uuid, - MD5: m.MD5, - }, nil - } - - h, err := hash.FileMD5(dataFolderPath) - if err != nil { - return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err) - } - - hashCache.Register(cacheID, cachedInfo{ - MD5: h, - }) - - return repository.Backup{ - CreatedAt: finfo.ModTime(), - UUID: uuid, - MD5: h, - }, nil -} - -func Hash(gameID, documentRoot string) (string, error) { - path := filepath.Clean(filepath.Join(documentRoot, "data", gameID)) - - sdir, err := os.Stat(path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return "", ErrNotExists - } - return "", err - } - - if !sdir.IsDir() { - return "", err - } - - v, err := getVersion(gameID, documentRoot) - if err != nil { - return "", fmt.Errorf("failed to read game metadata: %w", err) - } - - if m, ok := hashCache.Get(gameID); ok { - if v == m.Version { - return m.MD5, nil - } - } - - path = filepath.Join(path, "data.tar.gz") - - h, err := hash.FileMD5(path) - if err != nil { - return "", err - } - - hashCache.Register(gameID, cachedInfo{ - Version: v, - MD5: h, - }) - - return h, nil -} - -func getVersion(gameID, documentRoot string) (int, error) { - path := filepath.Join(documentRoot, "data", gameID, "metadata.json") - - f, err := os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - return 0, err - } - defer f.Close() - - d := json.NewDecoder(f) - var m repository.Metadata - if err := d.Decode(&m); err != nil { - return 0, err - } - - return m.Version, nil -} - -func makeDataFolder(gameID, documentRoot string) error { - if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil { - return err - } - - if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID, "hist"), 0740); err != nil { - return err - } - - return nil -} diff --git a/pkg/data/data.go b/pkg/data/data.go new file mode 100644 index 0000000..6c5d28d --- /dev/null +++ b/pkg/data/data.go @@ -0,0 +1,253 @@ +package data + +import ( + "cloudsave/pkg/remote/client" + "cloudsave/pkg/repository" + "cloudsave/pkg/tools/archive" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/google/uuid" +) + +type ( + Service struct { + repo repository.Repository + } +) + +func NewService(repo repository.Repository) *Service { + return &Service{ + repo: repo, + } +} + +func (s *Service) Add(name, path, remote string) (string, error) { + gameID := repository.NewGameIdentifier(uuid.NewString()) + + if err := s.repo.Mkdir(gameID); err != nil { + return "", fmt.Errorf("failed to add game reference: %w", err) + } + + m := repository.Metadata{ + ID: gameID.Key(), + Name: name, + Path: path, + Version: 1, + Date: time.Now(), + } + + if err := s.repo.WriteMetadata(gameID, m); err != nil { + return "", fmt.Errorf("failed to add game reference: %w", err) + } + + return gameID.Key(), nil +} + +func (s *Service) One(gameID string) (repository.Metadata, error) { + id := repository.NewGameIdentifier(gameID) + + m, err := s.repo.Metadata(id) + if err != nil { + return repository.Metadata{}, fmt.Errorf("failed to get metadata: %w", err) + } + + return m, nil +} + +func (s *Service) Backup(gameID, backupID string) (repository.Backup, error) { + id := repository.NewBackupIdentifier(gameID, backupID) + + return s.repo.Backup(id) +} + +func (s *Service) UpdateMetadata(gameID string, m repository.Metadata) error { + id := repository.NewGameIdentifier(gameID) + + if err := s.repo.WriteMetadata(id, m); err != nil { + return fmt.Errorf("failed to write metadate: %w", err) + } + + return nil +} + +func (s *Service) Scan(gameID string) error { + id := repository.NewGameIdentifier(gameID) + + lastRun, err := s.repo.LastScan(id) + if err != nil { + return fmt.Errorf("failed to get last scan time: %w", err) + } + + m, err := s.repo.Metadata(id) + if err != nil { + return fmt.Errorf("failed to get game metadata: %w", err) + } + + if !IsDirectoryChanged(m.Path, lastRun) { + return nil + } + + f, err := s.repo.WriteBlob(id) + if err != nil { + return fmt.Errorf("failed to get datastore stream: %w", err) + } + if v, ok := f.(io.Closer); ok { + defer v.Close() + } + + if err := archive.Tar(f, m.Path); err != nil { + return fmt.Errorf("failed to make archive: %w", err) + } + + if err := s.repo.ResetLastScan(id); err != nil { + return fmt.Errorf("failed to reset scan date: %w", err) + } + + m.Date = time.Now() + m.Version += 1 + + if err := s.repo.WriteMetadata(id, m); err != nil { + return fmt.Errorf("failed to update metadata: %w", err) + } + + return nil +} + +func (s *Service) AllGames() ([]repository.Metadata, error) { + ids, err := s.repo.All() + if err != nil { + return nil, fmt.Errorf("failed to get the list of ids: %w", err) + } + + var ms []repository.Metadata + for _, id := range ids { + m, err := s.repo.Metadata(repository.NewGameIdentifier(id)) + if err != nil { + return nil, fmt.Errorf("failed to open metadata: %w", err) + } + ms = append(ms, m) + } + + return ms, nil +} + +func (s *Service) AllBackups(gameID string) ([]repository.Backup, error) { + ids, err := s.repo.AllHist(repository.NewGameIdentifier(gameID)) + if err != nil { + return nil, fmt.Errorf("failed to get the list of ids: %w", err) + } + + var bs []repository.Backup + for _, id := range ids { + b, err := s.repo.Backup(repository.NewBackupIdentifier(gameID, id)) + if err != nil { + return nil, fmt.Errorf("failed to open metadata: %w", err) + } + bs = append(bs, b) + } + + return bs, nil +} + +func (l Service) PullArchive(gameID, backupID string, cli *client.Client) error { + if len(backupID) > 0 { + path := l.repo.DataPath(repository.NewBackupIdentifier(gameID, backupID)) + return cli.PullBackup(gameID, backupID, filepath.Join(path, "data.tar.gz")) + } + + path := l.repo.DataPath(repository.NewGameIdentifier(gameID)) + return cli.Pull(gameID, filepath.Join(path, "data.tar.gz")) +} + +func (l Service) PullCurrent(id, path string, cli *client.Client) error { + gameID := repository.NewGameIdentifier(id) + if err := l.repo.Mkdir(gameID); err != nil { + return err + } + + m, err := cli.Metadata(id) + if err != nil { + return fmt.Errorf("failed to get metadata from the server: %w", err) + } + + if err := l.repo.WriteMetadata(gameID, m); err != nil { + return fmt.Errorf("failed to write metadata: %w", err) + } + + archivePath := filepath.Join(l.repo.DataPath(gameID), "data.tar.gz") + + if err := cli.Pull(id, archivePath); err != nil { + return fmt.Errorf("failed to pull from the server: %w", err) + } + + f, err := l.repo.ReadBlob(gameID) + if err != nil { + return fmt.Errorf("failed to open blob from local repository: %w", err) + } + + if err := os.MkdirAll(path, 0740); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + if err := archive.Untar(f, path); err != nil { + return fmt.Errorf("failed to untar archive: %w", err) + } + + if err := l.repo.ResetLastScan(gameID); err != nil { + return fmt.Errorf("failed to create .last_run file: %w", err) + } + + return nil +} + +func (l Service) PullBackup(gameID, backupID string, cli *client.Client) error { + id := repository.NewBackupIdentifier(gameID, backupID) + + archivePath := filepath.Join(l.repo.DataPath(id), "data.tar.gz") + + if err := cli.PullBackup(gameID, backupID, archivePath); err != nil { + return fmt.Errorf("failed to pull backup: %w", err) + } + + return nil +} + +func (l Service) RemoveGame(gameID string) error { + return l.repo.Remove(repository.NewGameIdentifier(gameID)) +} + +func (l Service) SetVersion(gameID string, value int) error { + id := repository.NewGameIdentifier(gameID) + + m, err := l.repo.Metadata(id) + if err != nil { + return fmt.Errorf("failed to get metadata from the server: %w", err) + } + + m.Version = value + + if err := l.repo.WriteMetadata(id, m); err != nil { + return fmt.Errorf("failed to write metadata: %w", err) + } + + return nil +} + +func IsDirectoryChanged(path string, lastRun time.Time) bool { + changed := false + _ = filepath.Walk(path, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return nil + } + if info.ModTime().After(lastRun) { + changed = true + return io.EOF // early exit + } + return nil + }) + return changed +} diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index e69290b..c07882c 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -2,7 +2,6 @@ package repository import ( "cloudsave/pkg/tools/hash" - "cloudsave/pkg/tools/id" "encoding/json" "errors" "fmt" @@ -10,8 +9,6 @@ import ( "os" "path/filepath" "time" - - "github.com/google/uuid" ) type ( @@ -21,6 +18,12 @@ type ( Path string `json:"path"` Version int `json:"version"` Date time.Time `json:"date"` + MD5 string `json:"-"` + } + + Remote struct { + URL string `json:"url"` + GameID string `json:"-"` } Backup struct { @@ -29,6 +32,57 @@ type ( UUID string `json:"uuid"` ArchivePath string `json:"-"` } + + Data struct { + Metadata Metadata + DataPath string + Backup map[string]Data + } + + GameIdentifier struct { + gameID string + } + + BackupIdentifier struct { + gameID string + backupID string + } + + Identifier interface { + Key() string + } + + LazyRepository struct { + dataRoot string + } + + EagerRepository struct { + LazyRepository + + data map[string]Data + } + + Repository interface { + Mkdir(id Identifier) error + + All() ([]string, error) + AllHist(gameID GameIdentifier) ([]string, error) + + WriteBlob(ID Identifier) (io.Writer, error) + WriteMetadata(gameID GameIdentifier, m Metadata) error + + Metadata(gameID GameIdentifier) (Metadata, error) + LastScan(gameID GameIdentifier) (time.Time, error) + ReadBlob(gameID Identifier) (io.Reader, error) + Backup(id BackupIdentifier) (Backup, error) + + SetRemote(gameID GameIdentifier, url string) error + ResetLastScan(id GameIdentifier) error + + DataPath(id Identifier) string + + Remove(gameID GameIdentifier) error + } ) var ( @@ -36,347 +90,232 @@ var ( datastorepath string ) -func init() { - var err error - roaming, err = os.UserConfigDir() - if err != nil { - panic("failed to get user config path: " + err.Error()) +func NewGameIdentifier(gameID string) GameIdentifier { + return GameIdentifier{ + gameID: gameID, } +} +func (bi GameIdentifier) Key() string { + return bi.gameID +} - datastorepath = filepath.Join(roaming, "cloudsave", "data") - err = os.MkdirAll(datastorepath, 0740) - if err != nil { - panic("cannot make the datastore:" + err.Error()) +func NewBackupIdentifier(gameID, backupID string) BackupIdentifier { + return BackupIdentifier{ + gameID: gameID, + backupID: backupID, } } -func Add(name, path string) (Metadata, error) { - m := Metadata{ - ID: id.New(), - Name: name, - Path: path, - } - - err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740) - if err != nil { - panic("cannot make directory for the game:" + err.Error()) - } - - f, err := os.OpenFile(filepath.Join(datastorepath, m.ID, "metadata.json"), os.O_CREATE|os.O_WRONLY, 0740) - if err != nil { - return Metadata{}, fmt.Errorf("cannot open the metadata file in the datastore: %w", err) - } - defer f.Close() - - e := json.NewEncoder(f) - err = e.Encode(m) - if err != nil { - return Metadata{}, fmt.Errorf("cannot write into the metadata file in the datastore: %w", err) - } - - return m, nil +func (bi BackupIdentifier) Key() string { + return bi.gameID + ":" + bi.backupID } -func Register(m Metadata, path string) error { - m.Path = path - - err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740) +func NewLazyRepository(dataRootPath string) (Repository, error) { + m, err := os.Stat(dataRootPath) if err != nil { - panic("cannot make directory for the game:" + err.Error()) + return nil, fmt.Errorf("failed to open datastore: %w", err) } - f, err := os.OpenFile(filepath.Join(datastorepath, m.ID, "metadata.json"), os.O_CREATE|os.O_WRONLY, 0740) - if err != nil { - return fmt.Errorf("cannot open the metadata file in the datastore: %w", err) - } - defer f.Close() - - e := json.NewEncoder(f) - err = e.Encode(m) - if err != nil { - return fmt.Errorf("cannot write into the metadata file in the datastore: %w", err) + if !m.IsDir() { + return nil, fmt.Errorf("failed to open datastore: not a directory") } - return nil + return &LazyRepository{ + dataRoot: dataRootPath, + }, nil } -func All() ([]Metadata, error) { - ds, err := os.ReadDir(datastorepath) - if err != nil { - return nil, fmt.Errorf("cannot open the datastore: %w", err) - } - - var datastore []Metadata - for _, d := range ds { - content, err := os.ReadFile(filepath.Join(datastorepath, d.Name(), "metadata.json")) - if err != nil { - continue - } - - var m Metadata - err = json.Unmarshal(content, &m) - if err != nil { - return nil, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", d.Name(), err) - } - - datastore = append(datastore, m) - } - return datastore, nil +func (l LazyRepository) Mkdir(id Identifier) error { + return os.MkdirAll(l.DataPath(id), 0740) } -func One(gameID string) (Metadata, error) { - _, err := os.ReadDir(datastorepath) +func (l LazyRepository) All() ([]string, error) { + dir, err := os.ReadDir(l.dataRoot) if err != nil { - return Metadata{}, fmt.Errorf("cannot open the datastore: %w", err) + return nil, fmt.Errorf("failed to open directory: %w", err) } - content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "metadata.json")) - if err != nil { - return Metadata{}, fmt.Errorf("game not found: %w", err) + var res []string + for _, d := range dir { + res = append(res, d.Name()) } - var m Metadata - err = json.Unmarshal(content, &m) - if err != nil { - return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", gameID, err) - } - - return m, nil -} - -func MakeArchive(gameID string) error { - path := filepath.Join(datastorepath, gameID, "data.tar.gz") - - // open old - f, err := os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("failed to open old file: %w", err) - } - defer f.Close() - - histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid.NewString()) - if err := os.MkdirAll(histDirPath, 0740); err != nil { - return fmt.Errorf("failed to make directory: %w", err) - } - - // open new - nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) - if err != nil { - return fmt.Errorf("failed to open new file: %w", err) - } - defer nf.Close() - - // copy - if _, err := io.Copy(nf, f); err != nil { - return fmt.Errorf("failed to copy data: %w", err) - } - - return nil -} - -func RestoreArchive(gameID, uuid string) error { - histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid) - if err := os.MkdirAll(histDirPath, 0740); err != nil { - return fmt.Errorf("failed to make directory: %w", err) - } - - // open old - nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_RDONLY, 0) - if err != nil { - return fmt.Errorf("failed to open new file: %w", err) - } - defer nf.Close() - - path := filepath.Join(datastorepath, gameID, "data.tar.gz") - - // open new - f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil - } - return fmt.Errorf("failed to open old file: %w", err) - } - defer f.Close() - - // copy - if _, err := io.Copy(f, nf); err != nil { - return fmt.Errorf("failed to copy data: %w", err) - } - - return nil -} - -func Archive(gameID, uuid string) (Backup, error) { - histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid) - if err := os.MkdirAll(histDirPath, 0740); err != nil { - return Backup{}, fmt.Errorf("failed to make 'hist' directory") - } - - finfo, err := os.Stat(histDirPath) - if err != nil { - return Backup{}, fmt.Errorf("corrupted datastore: %w", err) - } - archivePath := filepath.Join(histDirPath, "data.tar.gz") - - h, err := hash.FileMD5(archivePath) - if err != nil { - return Backup{}, fmt.Errorf("failed to calculate md5 hash: %w", err) - } - - b := Backup{ - CreatedAt: finfo.ModTime(), - UUID: filepath.Base(finfo.Name()), - MD5: h, - ArchivePath: archivePath, - } - - return b, nil -} - -func Archives(gameID string) ([]Backup, error) { - histDirPath := filepath.Join(datastorepath, gameID, "hist") - if err := os.MkdirAll(histDirPath, 0740); err != nil { - return nil, fmt.Errorf("failed to make 'hist' directory") - } - - d, err := os.ReadDir(histDirPath) - if err != nil { - return nil, fmt.Errorf("failed to open 'hist' directory") - } - - var res []Backup - for _, f := range d { - finfo, err := f.Info() - if err != nil { - return nil, fmt.Errorf("corrupted datastore: %w", err) - } - path := filepath.Join(histDirPath, finfo.Name()) - archivePath := filepath.Join(path, "data.tar.gz") - - h, err := hash.FileMD5(archivePath) - if err != nil { - return nil, fmt.Errorf("failed to calculate md5 hash: %w", err) - } - - b := Backup{ - CreatedAt: finfo.ModTime(), - UUID: filepath.Base(finfo.Name()), - MD5: h, - ArchivePath: archivePath, - } - - res = append(res, b) - } return res, nil } -func DatastorePath() string { - return datastorepath +func (l LazyRepository) AllHist(id GameIdentifier) ([]string, error) { + path := l.DataPath(id) + + dir, err := os.ReadDir(filepath.Join(path, "hist")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("failed to open directory: %w", err) + } + + var res []string + for _, d := range dir { + res = append(res, d.Name()) + } + + return res, nil } -func Remove(gameID string) error { - err := os.RemoveAll(filepath.Join(datastorepath, gameID)) +func (l LazyRepository) WriteBlob(ID Identifier) (io.Writer, error) { + path := l.DataPath(ID) + + dst, err := os.OpenFile(filepath.Join(path, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) if err != nil { - return err + return nil, fmt.Errorf("failed to open destination file: %w", err) } - return nil + + return dst, nil } -func Hash(gameID string) (string, error) { - path := filepath.Join(datastorepath, gameID, "data.tar.gz") +func (l LazyRepository) WriteMetadata(id GameIdentifier, m Metadata) error { + path := l.DataPath(id) - return hash.FileMD5(path) -} - -func Version(gameID string) (int, error) { - path := filepath.Join(datastorepath, gameID, "metadata.json") - - f, err := os.OpenFile(path, os.O_RDONLY, 0) + dst, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) if err != nil { - return 0, err + return fmt.Errorf("failed to open destination file: %w", err) } - defer f.Close() + defer dst.Close() - var metadata Metadata - d := json.NewDecoder(f) - err = d.Decode(&metadata) - if err != nil { - return 0, err - } - - return metadata.Version, nil -} - -func SetVersion(gameID string, version int) error { - path := filepath.Join(datastorepath, gameID, "metadata.json") - - f, err := os.OpenFile(path, os.O_RDONLY, 0) - if err != nil { - return err - } - - var metadata Metadata - d := json.NewDecoder(f) - err = d.Decode(&metadata) - if err != nil { - f.Close() - return err - } - - f.Close() - - metadata.Version = version - - f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740) - if err != nil { - return err - } - defer f.Close() - - e := json.NewEncoder(f) - err = e.Encode(metadata) - if err != nil { - return err + e := json.NewEncoder(dst) + if err := e.Encode(m); err != nil { + return fmt.Errorf("failed to encode data: %w", err) } return nil } -func SetDate(gameID string, dt time.Time) error { - path := filepath.Join(datastorepath, gameID, "metadata.json") +func (l LazyRepository) Metadata(id GameIdentifier) (Metadata, error) { + path := l.DataPath(id) - f, err := os.OpenFile(path, os.O_RDONLY, 0) + src, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_RDONLY, 0) if err != nil { - return err + return Metadata{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err) } - var metadata Metadata - d := json.NewDecoder(f) - err = d.Decode(&metadata) - if err != nil { - f.Close() - return err + var m Metadata + d := json.NewDecoder(src) + if err := d.Decode(&m); err != nil { + return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse metadata: %w", err) } - f.Close() - - metadata.Date = dt - - f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740) + m.MD5, err = hash.FileMD5(filepath.Join(path, "data.tar.gz")) if err != nil { - return err + return Metadata{}, fmt.Errorf("failed to calculate md5: %w", err) + } + + return m, nil +} + +func (l LazyRepository) Backup(id BackupIdentifier) (Backup, error) { + path := l.DataPath(id) + + fs, err := os.Stat(filepath.Join(path, "data.tar.gz")) + if err != nil { + return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err) + } + + h, err := hash.FileMD5(filepath.Join(path, "data.tar.gz")) + if err != nil { + return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err) + } + + return Backup{ + CreatedAt: fs.ModTime(), + MD5: h, + UUID: id.backupID, + ArchivePath: filepath.Join(path, "data.tar.gz"), + }, nil +} + +func (l LazyRepository) LastScan(id GameIdentifier) (time.Time, error) { + path := l.DataPath(id) + + data, err := os.ReadFile(filepath.Join(path, ".last_run")) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return time.Time{}, nil + } + return time.Time{}, fmt.Errorf("failed to reading state file: %w", err) + } + + lastRun, err := time.Parse(time.RFC3339, string(data)) + if err != nil { + return time.Time{}, fmt.Errorf("parsing state file timestamp: %w", err) + } + + return lastRun, nil +} + +func (l LazyRepository) ResetLastScan(id GameIdentifier) error { + path := l.DataPath(id) + + f, err := os.OpenFile(filepath.Join(path, ".last_run"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) } defer f.Close() - e := json.NewEncoder(f) - err = e.Encode(metadata) - if err != nil { - return err + data := time.Now().Format(time.RFC3339) + + if _, err := f.WriteString(data); err != nil { + return fmt.Errorf("failed to write file: %w", err) } return nil } + +func (l LazyRepository) ReadBlob(id Identifier) (io.Reader, error) { + path := l.DataPath(id) + + dst, err := os.OpenFile(filepath.Join(path, "data.tar.gz"), os.O_RDONLY, 0) + if err != nil { + return nil, fmt.Errorf("failed to open blob: %w", err) + } + + return dst, nil +} + +func (l LazyRepository) SetRemote(id GameIdentifier, url string) error { + path := l.DataPath(id) + + src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) + if err != nil { + return fmt.Errorf("failed to open remote description: %w", err) + } + defer src.Close() + + var r Remote + d := json.NewEncoder(src) + if err := d.Encode(r); err != nil { + return fmt.Errorf("failed to marshall remote description: %w", err) + } + + return nil +} + +func (l LazyRepository) Remove(id GameIdentifier) error { + path := l.DataPath(id) + + if err := os.RemoveAll(path); err != nil { + return fmt.Errorf("failed to remove game folder from the datastore: %w", err) + } + + return nil +} + +func (r LazyRepository) DataPath(id Identifier) string { + switch identifier := id.(type) { + case GameIdentifier: + return filepath.Join(r.dataRoot, identifier.gameID) + case BackupIdentifier: + return filepath.Join(r.dataRoot, identifier.gameID, "hist", identifier.backupID) + } + + panic("identifier type not supported") +}