Skip to content

Instantly share code, notes, and snippets.

@mitranim
Created August 2, 2024 11:30
Show Gist options
  • Save mitranim/1e061674159018895dd5a6869d2ce213 to your computer and use it in GitHub Desktop.
Save mitranim/1e061674159018895dd5a6869d2ce213 to your computer and use it in GitHub Desktop.
elden_ring_backup
/*
Simple CLI tool for backing up Elden Ring saves. Makes
backups periodically. Interval is configurable, see
constants below. Avoids redundant backups by checking
file modification timestamps.
Requires Go. To install Go on Windows, use any of the following,
depending on your preference:
* Official download: https://go.dev/dl/
* `scoop install golang` (if you use Scoop)
* `choco install golang` (if you use Chocolatey)
Before running, specify the source and destination path
by editing the constants `SRC_DIR` and `TAR_DIR` below.
To run, open a terminal, navigate to this directory, then:
go run .
*/
package main
import (
"errors"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/mitranim/gg"
)
// Edit the path to match your username.
const SRC_DIR = `C:\Users\mitranim\AppData\Roaming\EldenRing`
const TAR_DIR = `C:\Users\mitranim\Downloads\elden_ring_backups`
const DELAY_UP_TO_DATE = time.Hour
const DELAY_SUCCESS = time.Minute * 15
const DELAY_ERROR = time.Minute * 5
func main() {
for {
err := backup(SRC_DIR, TAR_DIR)
if gg.AnyIs[ErrUpToDate](err) {
log.Printf(`%v, retry after %v`, err, DELAY_UP_TO_DATE)
time.Sleep(DELAY_UP_TO_DATE)
continue
}
if err != nil {
log.Printf(`error, retry after %v: %+v`, DELAY_ERROR, err)
time.Sleep(DELAY_ERROR)
continue
}
log.Printf(`success, retry after %v`, DELAY_SUCCESS)
time.Sleep(DELAY_SUCCESS)
continue
}
}
type ErrUpToDate string
func (self ErrUpToDate) Error() string { return string(self) }
func backup(srcPath, tarDir string) (out error) {
defer gg.Rec(&out)
nextName, prevName := indexedName(tarDir, filepath.Base(srcPath))
if prevName != `` {
nextTime := maxModTime(srcPath)
prevTime := maxModTime(filepath.Join(tarDir, prevName))
if prevTime.After(nextTime) {
return ErrUpToDate(fmt.Sprintf(`latest backup %q is up to date`, prevName))
}
}
copyRecursive(srcPath, tarDir, nextName)
return
}
func copyRecursive(srcPath, tarDir, tarName string) {
srcInfo := gg.Try1(os.Stat(srcPath))
tarPath := filepath.Join(tarDir, tarName)
if srcInfo.IsDir() {
copyDirRecursive(srcPath, tarPath)
} else {
gg.Try(os.MkdirAll(tarDir, os.ModePerm))
copyFile(srcPath, tarPath)
}
}
func copyDirRecursive(srcDir, tarDir string) {
for _, name := range readDir(srcDir) {
copyRecursive(filepath.Join(srcDir, name), tarDir, name)
}
}
func copyFile(srcPath, tarPath string) {
src := gg.Try1(os.OpenFile(srcPath, os.O_RDONLY, os.ModePerm))
defer src.Close() // Ignore error.
out := gg.Try1(os.Create(tarPath))
defer gg.Close(out) // Do not ignore error.
gg.Try1(io.Copy(out, src))
}
func indexedName(dirPath, srcName string) (string, string) {
srcName, srcExt := fileNameSplit(srcName)
tarNames := readDir(dirPath)
var prevName string
var prevInd uint64
for _, tarBaseName := range tarNames {
tarName, tarInd, tarExt := indexedNameDecode(tarBaseName)
if tarName == srcName && tarExt == srcExt && tarInd > prevInd {
prevName = tarBaseName
prevInd = tarInd
}
}
tarNameSet := gg.SetOf(tarNames...)
for {
out := indexedNameEncode(srcName, prevInd, srcExt)
if tarNameSet.Has(out) {
prevInd++
continue
}
return out, prevName
}
}
const indexWidth = 4
const indexRadix = 10
func indexEncode(src uint64) string {
out := strconv.FormatUint(src, indexRadix)
missing := indexWidth - len(out)
if missing > 0 {
return repeatByte('0', missing) + out
}
return out
}
func repeatByte(src byte, count int) (_ string) {
if count > 0 {
buf := make([]byte, count)
for ind := range buf {
buf[ind] = src
}
return string(buf)
}
return
}
func indexedNameDecode(src string) (string, uint64, string) {
name, ext := fileNameSplit(src)
pre, suf, ok := strings.Cut(name, ` `)
if !ok {
return name, 0, ext
}
ind, err := strconv.ParseUint(suf, indexRadix, 64)
if err != nil {
return name, 0, ext
}
return pre, ind, ext
}
func indexedNameEncode(pre string, ind uint64, ext string) string {
return pre + ` ` + indexEncode(ind) + ext
}
// Workaround for bugs in `filepath.Ext`.
func fileNameSplit(src string) (string, string) {
name := filepath.Base(src)
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
if base == `` {
return ext, ``
}
return base, ext
}
// Difference from `os.ReadDir`: returns strings, not `[]os.FileInfo`.
func readDir(srcPath string) []string {
defer gg.SkipOnly(isErrFileNotFound)
src := gg.Try1(os.OpenFile(srcPath, os.O_RDONLY, os.ModePerm))
defer src.Close()
return gg.Try1(src.Readdirnames(-1))
}
func isErrFileNotFound(err error) bool { return errors.Is(err, os.ErrNotExist) }
/*
Note: despite its name, `filepath.WalkDir` also supports walking a single file.
This function should work for both directory backups and single file backups.
*/
func maxModTime(src string) (out time.Time) {
gg.Try(filepath.WalkDir(
src,
func(_ string, src fs.DirEntry, _ error) error {
if src == nil {
return nil
}
info, _ := src.Info()
if info == nil {
return nil
}
val := info.ModTime()
if val.After(out) {
out = val
}
return nil
},
))
return
}
module elden_ring_backup
go 1.22.5
require github.com/mitranim/gg v0.1.23
github.com/mitranim/gg v0.1.23 h1:U91GBI6qCG7+4VrWVg/Fm8OYkVAI6/1sE6zwVzrEDTw=
github.com/mitranim/gg v0.1.23/go.mod h1:x2V+nJJOpeMl/XEoHou9zlTvFxYAcGOCqOAKpVkF0Yc=
@mitranim
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment