From 7827b9c0cc04dd6e0dd9c34a463b294dd93da2a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Sat, 10 May 2025 17:42:25 +0200 Subject: [PATCH] first commit --- .gitignore | 2 + cmd/cli/commands/add/add.go | 56 ++++++++++++++ cmd/cli/commands/run/run.go | 147 ++++++++++++++++++++++++++++++++++++ cmd/cli/main.go | 24 ++++++ go.mod | 5 ++ go.sum | 2 + pkg/game/game.go | 86 +++++++++++++++++++++ pkg/tools/id/id.go | 15 ++++ 8 files changed, 337 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/cli/commands/add/add.go create mode 100644 cmd/cli/commands/run/run.go create mode 100644 cmd/cli/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 pkg/game/game.go create mode 100644 pkg/tools/id/id.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a76f226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/cli +/server \ No newline at end of file diff --git a/cmd/cli/commands/add/add.go b/cmd/cli/commands/add/add.go new file mode 100644 index 0000000..6f89288 --- /dev/null +++ b/cmd/cli/commands/add/add.go @@ -0,0 +1,56 @@ +package add + +import ( + "cloudsave/pkg/game" + "context" + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/google/subcommands" +) + +type ( + AddCmd struct { + name string + } +) + +func (AddCmd) Name() string { return "add" } +func (AddCmd) Synopsis() string { return "Add a folder to the sync list" } +func (AddCmd) Usage() string { + return `add: + Add a folder to the sync list +` +} + +func (p AddCmd) SetFlags(f *flag.FlagSet) { + f.StringVar(&p.name, "name", "", "Override the name of the game") +} + +func (p AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if f.NArg() != 1 { + fmt.Fprintln(os.Stderr, "error: the command is expecting for 1 argument") + return subcommands.ExitUsageError + } + path, err := filepath.Abs(f.Arg(0)) + if err != nil { + fmt.Fprintln(os.Stderr, "error: cannot get the absolute path for this entry:", err) + return subcommands.ExitFailure + } + + if p.name == "" { + p.name = filepath.Base(filepath.Dir(path)) + } + + m, err := game.Add(p.name, path) + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err) + return subcommands.ExitFailure + } + + fmt.Println(m.ID) + + return subcommands.ExitSuccess +} diff --git a/cmd/cli/commands/run/run.go b/cmd/cli/commands/run/run.go new file mode 100644 index 0000000..c88b79c --- /dev/null +++ b/cmd/cli/commands/run/run.go @@ -0,0 +1,147 @@ +package run + +import ( + "archive/tar" + "cloudsave/pkg/game" + "compress/gzip" + "context" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/google/subcommands" +) + +type ( + RunCmd struct { + } +) + +func (RunCmd) Name() string { return "run" } +func (RunCmd) Synopsis() string { return "Check and process all the folder" } +func (RunCmd) Usage() string { + return `run: + Check and process all the folder +` +} + +func (p RunCmd) SetFlags(f *flag.FlagSet) {} + +func (p RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + datastore, err := game.All() + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) + return subcommands.ExitFailure + } + + for _, metadata := range datastore { + metadataPath := filepath.Join(game.DatastorePath(), metadata.ID) + err := archiveIfChanged(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 + } + } + + 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(srcDir, destTarGz, stateFile string) error { + // 1) Load last run time + var lastRun time.Time + data, err := os.ReadFile(stateFile) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("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) + } + } + + // 2) 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("scanning source directory: %w", err) + } + + if !changed { + fmt.Println("No changes detected; skipping archive.") + return nil + } + + // 3) Create tar.gz + f, err := os.Create(destTarGz) + if err != nil { + return fmt.Errorf("creating archive file: %w", err) + } + defer f.Close() + + gw := gzip.NewWriter(f) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + // Walk again to add files + err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + // Create tar header + header, err := tar.FileInfoHeader(info, path) + if err != nil { + return err + } + // Preserve directory structure relative to srcDir + relPath, err := filepath.Rel(filepath.Dir(srcDir), path) + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + if _, err := io.Copy(tw, file); err != nil { + return err + } + } + return nil + }) + if err != nil { + return fmt.Errorf("writing tar entries: %w", err) + } + + // 4) Update state file + 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) + } + + fmt.Printf("Archived %q to %q and updated state file.\n", srcDir, destTarGz) + return nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 0000000..751011a --- /dev/null +++ b/cmd/cli/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "cloudsave/cmd/cli/commands/add" + "cloudsave/cmd/cli/commands/run" + "context" + "flag" + "os" + + "github.com/google/subcommands" +) + +func main() { + subcommands.Register(subcommands.HelpCommand(), "help") + subcommands.Register(subcommands.FlagsCommand(), "help") + subcommands.Register(subcommands.CommandsCommand(), "help") + + subcommands.Register(add.AddCmd{}, "management") + subcommands.Register(run.RunCmd{}, "management") + + flag.Parse() + ctx := context.Background() + os.Exit(int(subcommands.Execute(ctx))) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..84a98c9 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module cloudsave + +go 1.24 + +require github.com/google/subcommands v1.2.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e3fd536 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= +github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= diff --git a/pkg/game/game.go b/pkg/game/game.go new file mode 100644 index 0000000..aad3467 --- /dev/null +++ b/pkg/game/game.go @@ -0,0 +1,86 @@ +package game + +import ( + "cloudsave/pkg/tools/id" + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type ( + Metadata struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + } +) + +var ( + roaming string + datastorepath string +) + +func init() { + var err error + 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()) + } +} + +func Add(name, path string) (Metadata, error) { + m := Metadata{ + ID: id.New(), + Name: name, + Path: path, + } + + 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 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 DatastorePath() string { + return datastorepath +} diff --git a/pkg/tools/id/id.go b/pkg/tools/id/id.go new file mode 100644 index 0000000..a9ccea3 --- /dev/null +++ b/pkg/tools/id/id.go @@ -0,0 +1,15 @@ +package id + +import ( + "crypto/rand" + "encoding/hex" +) + +func New() string { + bytes := make([]byte, 24) + _, err := rand.Read(bytes) + if err != nil { + panic(err) + } + return hex.EncodeToString(bytes) +}