0001-gui-add-auto-scummer.patch

#612
Raw
Author
Anonymous
Created
Dec. 14, 2022, 12:35 p.m.
Expires
Never
Size
19.2ย KB
Hits
56
Syntax
Diff
Private
โœ— No
From b20a2678585b94aeea093085f92d8c3c6fb28233 Mon Sep 17 00:00:00 2001
From: Winston Weinert <git@winny.tech>
Date: Wed, 14 Dec 2022 06:32:25 -0600
Subject: [PATCH] gui: add auto-scummer

---
 .gitignore            |   1 +
 cmd/savecmd/save.go   |   9 +--
 go.mod                |  13 ++++-
 go.sum                |  24 ++++++++
 gui/main.go           |  84 +++++++++++++++++++++++++++-
 gui/models.go         |  23 ++++++++
 gui/newsavewatcher.go | 126 ++++++++++++++++++++++++++++++++++++++++++
 gui/paths.go          |  25 +++++++++
 savefile/savefile.go  |  16 +++++-
 9 files changed, 308 insertions(+), 13 deletions(-)
 create mode 100644 gui/models.go
 create mode 100644 gui/newsavewatcher.go
 create mode 100644 gui/paths.go

diff --git a/.gitignore b/.gitignore
index 5ef1910..6d08386 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
 /jhmod
+/jhmod.exe
 
 # Discourage accidental staging of scratch files used by the tool.
 core.*
diff --git a/cmd/savecmd/save.go b/cmd/savecmd/save.go
index 59160ed..d083691 100644
--- a/cmd/savecmd/save.go
+++ b/cmd/savecmd/save.go
@@ -29,14 +29,7 @@ func saveInfoCmd() *cobra.Command {
 				if debug {
 					fmt.Fprintf(os.Stderr, "Reading file %s\n", p)
 				}
-				f, openErr := os.Open(p)
-				if openErr != nil {
-					fmt.Fprintf(os.Stderr, "Failed to open '%s': %v\n", p, openErr)
-					errors++
-					continue
-				}
-				defer f.Close()
-				save, parseErr := savefile.Parse(f)
+				save, parseErr := savefile.ParseFile(p)
 				if parseErr != nil {
 					fmt.Fprintf(os.Stderr, "Failed to parse '%s: %v\n", p, parseErr)
 					errors++
diff --git a/go.mod b/go.mod
index 23b554d..20cbce1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,9 @@ module github.com/sector-f/jhmod
 go 1.17
 
 require (
+	github.com/adrg/xdg v0.4.0
 	github.com/dsnet/golib/memfile v1.0.0
+	github.com/fsnotify/fsnotify v1.6.0
 	github.com/spf13/cobra v1.5.0
 	github.com/spf13/pflag v1.0.5
 )
@@ -12,7 +14,6 @@ require (
 	fyne.io/systray v1.10.1-0.20220621085403-9a2652634e93 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/fredbi/uri v0.0.0-20181227131451-3dcfdacbaaf3 // indirect
-	github.com/fsnotify/fsnotify v1.5.4 // indirect
 	github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
 	github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 // indirect
 	github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
@@ -21,19 +22,25 @@ require (
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
 	github.com/goki/freetype v0.0.0-20181231101311-fa8a33aabaff // indirect
 	github.com/gopherjs/gopherjs v1.17.2 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
+	github.com/mattn/go-sqlite3 v1.14.16 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/srwiley/oksvg v0.0.0-20200311192757-870daf9aa564 // indirect
 	github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9 // indirect
 	github.com/stretchr/testify v1.7.2 // indirect
 	github.com/tevino/abool v1.2.0 // indirect
+	github.com/u-root/u-root v0.10.0 // indirect
 	github.com/yuin/goldmark v1.4.0 // indirect
 	golang.org/x/image v0.0.0-20220601225756-64ec528b34cd // indirect
 	golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee // indirect
-	golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d // indirect
-	golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
+	golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f // indirect
+	golang.org/x/sys v0.0.0-20220908164124-27713097b956 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gorm.io/driver/sqlite v1.4.3 // indirect
+	gorm.io/gorm v1.24.2 // indirect
 	honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 // indirect
 )
 
diff --git a/go.sum b/go.sum
index 3841d6e..6262fea 100644
--- a/go.sum
+++ b/go.sum
@@ -44,6 +44,8 @@ fyne.io/systray v1.10.1-0.20220621085403-9a2652634e93/go.mod h1:oM2AQqGJ1AMo4nNq
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/adrg/xdg v0.4.0 h1:RzRqFcjH4nE5C6oTAxhBtoE2IRyjBSa62SCbyPidvls=
+github.com/adrg/xdg v0.4.0/go.mod h1:N6ag73EX4wyxeaoeHctc1mas01KZgsj5tYiAIwqJE/E=
 github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -82,6 +84,8 @@ github.com/fredbi/uri v0.0.0-20181227131451-3dcfdacbaaf3/go.mod h1:CzM2G82Q9BDUv
 github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
 github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI=
 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU=
+github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
+github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
 github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4=
 github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg=
 github.com/fyne-io/glfw-js v0.0.0-20220120001248-ee7290d23504 h1:+31CdF/okdokeFNoy9L/2PccG3JFidQT3ev64/r4pYU=
@@ -198,6 +202,11 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/jackmordaunt/icns/v2 v2.2.1/go.mod h1:6aYIB9eSzyfHHMKqDf17Xrs1zetQPReAkiUSHzdw4cI=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/josephspurrier/goversioninfo v1.4.0/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY=
 github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@@ -216,6 +225,9 @@ github.com/lucor/goinfo v0.0.0-20210802170112-c078a2b0f08b/go.mod h1:PRq09yoB+Q2
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
+github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
 github.com/mcuadros/go-version v0.0.0-20190830083331-035f6764e8d2/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
 github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
@@ -281,6 +293,8 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/tevino/abool v1.2.0 h1:heAkClL8H6w+mK5md9dzsuohKeXHUpY7Vw0ZCKW+huA=
 github.com/tevino/abool v1.2.0/go.mod h1:qc66Pna1RiIsPa7O4Egxxs9OqkuxDX55zznh9K07Tzg=
+github.com/u-root/u-root v0.10.0 h1:nz3jSORXAxTl6bNXhRh5s9HT5oRo5hmCJXUJ0q/BK7s=
+github.com/u-root/u-root v0.10.0/go.mod h1:lqAiThZZ0/yg0rj49gxpSCK8hfv86NSc+wPhGp5idb4=
 github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -388,6 +402,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -458,8 +474,11 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956 h1:XeJjHH1KiLpKGb6lvMiksZ9l0fVUh+AmGcm0nOMEBOY=
+golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -647,6 +666,11 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
+gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
+gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
+gorm.io/gorm v1.24.2 h1:9wR6CFD+G8nOusLdvkZelOEhpJVwwHzpQOUM+REd6U0=
+gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
 honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2 h1:oomkgU6VaQDsV6qZby2uz1Lap0eXmku8+2em3A/l700=
 honnef.co/go/js/dom v0.0.0-20210725211120-f030747120f2/go.mod h1:sUMDUKNB2ZcVjt92UnLy3cdGs+wDAcrPdV3JP6sVgA4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/gui/main.go b/gui/main.go
index 09fd2eb..cc20102 100644
--- a/gui/main.go
+++ b/gui/main.go
@@ -1,14 +1,96 @@
 package gui
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"os"
+	path "path/filepath"
+	"time"
+
 	"fyne.io/fyne/v2/app"
 	"fyne.io/fyne/v2/widget"
+	"github.com/sector-f/jhmod/savefile"
+	"github.com/u-root/u-root/pkg/cp"
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm" // Base ORM
 )
 
+func sha256File(path string) ([]byte, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return make([]byte, 0), err
+	}
+	defer f.Close()
+	h := sha256.New()
+	if _, err := io.Copy(h, f); err != nil {
+		return make([]byte, 0), err
+	}
+	return h.Sum(nil), nil
+}
+
 func Run() {
 	a := app.New()
 	w := a.NewWindow("Hello World")
+	lbl := widget.NewLabel("Hello World!")
+
+	mkdirErr := os.Mkdir(getSaveScumDir(), 0750)
+	if mkdirErr != nil && !os.IsExist(mkdirErr) {
+		fmt.Fprintf(os.Stderr, "Cannot make savescumdir %v\n", mkdirErr)
+		return
+	}
+
+	dbPath := path.Join(
+		getSaveScumDir(),
+		"db.sqlite3",
+	)
+	db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
+	if err != nil {
+		panic(fmt.Sprintf("Unable to open db \"%s\" %v", dbPath, err))
+	}
+	if err = db.AutoMigrate(&StoredSaveFile{}); err != nil {
+		panic(fmt.Sprintf("Could not migrate db.\n"))
+	}
+
+	go watchForNewSave(func(p string) {
+		lbl.SetText(fmt.Sprintf("%s %s", time.Now().String(), p))
+
+		sd, err := savefile.ParseFile(p)
+		if err != nil {
+			fmt.Fprintf(os.Stderr, "Could not parse save \"%s\".  Aborting.\n", p)
+			return
+		}
+		digest, shaErr := sha256File(p)
+		if shaErr != nil {
+			fmt.Fprintf(os.Stderr, "Could not SHA256 \"%s\".  Aborting.\n", p)
+			return
+		}
+
+		digestHex := hex.EncodeToString(digest)
+		relPath := digestHex
+
+		storedSaveFile := &StoredSaveFile{
+			OriginalBase:  path.Base(p), // this don't work on the winders
+			StoredRelPath: relPath,
+			Sha256Hex:     digestHex,
+
+			PlayerName:   sd.PlayerName,
+			GameMode:     sd.GameMode,
+			CurrentLevel: sd.CurrentLevel,
+			Seed:         sd.Seed,
+		}
+
+		destAbs := path.Join(getSaveScumDir(), relPath)
+		if cpErr := cp.Copy(p, destAbs); cpErr != nil {
+			fmt.Fprintf(os.Stderr, "Failed to copy file \"%s\" to \"%s\" (%v)\n", p, destAbs, cpErr)
+			return
+		} else {
+			fmt.Printf("Copied \"%s\" to \"%s\" n saved to db.\n", p, destAbs)
+		}
+		db.Create(storedSaveFile)
+	})
 
-	w.SetContent(widget.NewLabel("Hello World!"))
+	w.SetContent(lbl)
 	w.ShowAndRun()
 }
diff --git a/gui/models.go b/gui/models.go
new file mode 100644
index 0000000..46a911d
--- /dev/null
+++ b/gui/models.go
@@ -0,0 +1,23 @@
+package gui
+
+import (
+	"gorm.io/gorm"
+)
+
+type StoredSaveFile struct {
+	gorm.Model
+	Id            int `gorm:"primary_key"`
+	OriginalBase  string
+	StoredRelPath string `gorm:"unique"`
+	Sha256Hex     string `gorm:"unique"`
+
+	// Copied verbatim from savefile.go
+	// Player name
+	PlayerName string
+	// Game mode.  This can be "jh", and various others.
+	GameMode string
+	// The current level's name.
+	CurrentLevel string
+	// The seed used to generate the game.
+	Seed uint32
+}
diff --git a/gui/newsavewatcher.go b/gui/newsavewatcher.go
new file mode 100644
index 0000000..a69a7e9
--- /dev/null
+++ b/gui/newsavewatcher.go
@@ -0,0 +1,126 @@
+package gui
+
+import (
+	"fmt"
+	"math"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/fsnotify/fsnotify"
+)
+
+type CreationCallback = func(*fsnotify.Event)
+
+// Depending on the system, a single "write" can generate many Write events; for
+// example compiling a large Go program can generate hundreds of Write events on
+// the binary.
+//
+// The general strategy to deal with this is to wait a short time for more write
+// events, resetting the wait period for every new event.
+func dedup(callback CreationCallback, paths ...string) {
+	if len(paths) < 1 {
+		panic("must specify at least one path to watch")
+	}
+
+	// Create a new watcher.
+	w, err := fsnotify.NewWatcher()
+	if err != nil {
+		panic(fmt.Sprintf("creating a new watcher: %s", err))
+	}
+	defer w.Close()
+
+	// Start listening for events.
+	go watchForCreated(w, callback)
+
+	// Add all paths from the commandline.
+	for _, p := range paths {
+		err = w.Add(p)
+		if err != nil {
+			panic(fmt.Sprintf("%q: %s", p, err))
+		}
+	}
+}
+
+func watchForCreated(w *fsnotify.Watcher, creatcb CreationCallback) {
+	var (
+		// Wait 100ms for new events; each new event resets the timer.
+		waitFor = 100 * time.Millisecond
+		// why is this not a const?
+
+		// Keep track of the timers, as path รขโ€ โ€™ timer.
+		mu     sync.Mutex
+		timers = make(map[string]*time.Timer)
+
+		// Callback we run.
+		cb = func(e fsnotify.Event) {
+			creatcb(&e)
+
+			// Don't need to remove the timer if you don't have a lot of files.
+			mu.Lock()
+			delete(timers, e.Name)
+			mu.Unlock()
+		}
+	)
+
+	for {
+		select {
+		// Read from Errors.
+		case _, ok := <-w.Errors:
+			if !ok { // Channel was closed (i.e. Watcher.Close() was called).
+				return
+			}
+		// Read from Events.
+		case e, ok := <-w.Events:
+			if !ok { // Channel was closed (i.e. Watcher.Close() was called).
+				return
+			}
+
+			// We just want to watch for file creation, so ignore everything
+			// outside of Create and Write.
+			if !e.Has(fsnotify.Create) && !e.Has(fsnotify.Write) {
+				continue
+			}
+
+			// Get timer.
+			mu.Lock()
+			t, ok := timers[e.Name]
+			mu.Unlock()
+
+			// No timer yet, so create one.
+			if !ok {
+				t = time.AfterFunc(math.MaxInt64, func() { cb(e) })
+				t.Stop()
+
+				mu.Lock()
+				timers[e.Name] = t
+				mu.Unlock()
+			}
+
+			// Reset the timer for this path, so it will start from 100ms again.
+			t.Reset(waitFor)
+		}
+	}
+}
+
+type NewSaveCallback = func(string)
+
+func watchForNewSave(cb NewSaveCallback) {
+	watcher, err := fsnotify.NewWatcher()
+	w := watcher
+	if err != nil {
+		panic("Could not create watcher")
+	}
+	defer watcher.Close()
+	err = watcher.Add(guessGameDir())
+	if err != nil {
+		panic(fmt.Sprintf("Could not create add dir to watcher %v", err))
+	}
+	watchForCreated(w, func(e *fsnotify.Event) {
+		base := filepath.Base(e.Name)
+		if base != "save_loading" && strings.HasPrefix(base, "save") {
+			cb(e.Name)
+		}
+	})
+}
diff --git a/gui/paths.go b/gui/paths.go
new file mode 100644
index 0000000..c341e6a
--- /dev/null
+++ b/gui/paths.go
@@ -0,0 +1,25 @@
+package gui
+
+import (
+	"runtime"
+
+	"github.com/adrg/xdg"
+)
+
+func guessGameDir() string {
+	os := runtime.GOOS
+	switch os {
+	case "windows":
+		return "C:/Program Files (x86)/Steam/steamapps/common/Jupiter Hell"
+	default:
+		panic("Don't know about OS default game dir")
+	}
+}
+
+func getSaveScumDir() string {
+	dir, err := xdg.DataFile("jhmod/savescum")
+	if err != nil {
+		panic(err)
+	}
+	return dir
+}
diff --git a/savefile/savefile.go b/savefile/savefile.go
index 224db88..c13f501 100644
--- a/savefile/savefile.go
+++ b/savefile/savefile.go
@@ -7,6 +7,7 @@ import (
 	"errors"
 	"io"
 	"io/ioutil"
+	"os"
 	"unicode"
 )
 
@@ -15,7 +16,7 @@ const (
 	magic = "\xde\xc0\xad\xde"
 )
 
-type savedata struct {
+type Savedata struct {
 	// Player name
 	PlayerName string
 	// Game mode.  This can be "jh", and various others.
@@ -26,6 +27,9 @@ type savedata struct {
 	Seed uint32
 }
 
+// TODO port legacy type `savedata` to `Savedata`
+type savedata = Savedata
+
 // No magic found on save file.
 var ErrNoMagicFound error = errors.New("no magic found")
 
@@ -135,3 +139,13 @@ func Parse(r io.Reader) (savedata, error) {
 		Seed:         seed,
 	}, nil
 }
+
+func ParseFile(path string) (savedata, error) {
+	f, openErr := os.Open(path)
+	if openErr != nil {
+		return savedata{}, openErr
+	}
+
+	defer f.Close()
+	return Parse(f)
+}
-- 
2.34.1.windows.1