first commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/cli
|
||||||
|
/server
|
||||||
56
cmd/cli/commands/add/add.go
Normal file
56
cmd/cli/commands/add/add.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
147
cmd/cli/commands/run/run.go
Normal file
147
cmd/cli/commands/run/run.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
24
cmd/cli/main.go
Normal file
24
cmd/cli/main.go
Normal file
@@ -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)))
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module cloudsave
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/google/subcommands v1.2.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -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=
|
||||||
86
pkg/game/game.go
Normal file
86
pkg/game/game.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
15
pkg/tools/id/id.go
Normal file
15
pkg/tools/id/id.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user