From c92fab5f04203bff614887d7131ec12e7b99fe4f Mon Sep 17 00:00:00 2001
From: exTerEX <30706568+exTerEX@users.noreply.github.com>
Date: Tue, 31 Mar 2026 18:01:16 +0200
Subject: [PATCH 01/16] feat: Phase 0-3 Go rewrite - shared, web server,
organizer
- Create Go project structure under go/
- Port shared constants, utils, thumbnails, file browser, job store
- Set up gin web server with embedded static files
- Port organizer module (date_sorter, renamer, scanner)
- Add organizer API routes
- Add Go Makefile targets
- Single-pass filepath.WalkDir replaces 82+ Python rglob calls
- Generic JobStore with TTL-based cleanup replaces leaky Python dicts
- True goroutine parallelism throughout
---
.gitignore | 1 +
Makefile | 19 +
go/cmd/morphic/main.go | 48 +
go/go.mod | 45 +
go/go.sum | 143 +++
go/internal/organizer/date_sorter.go | 156 +++
go/internal/organizer/renamer.go | 126 ++
go/internal/organizer/scanner.go | 166 +++
go/internal/shared/constants.go | 107 ++
go/internal/shared/file_browser.go | 69 ++
go/internal/shared/jobs.go | 94 ++
go/internal/shared/thumbnails.go | 135 +++
go/internal/shared/utils.go | 141 +++
go/web/routes_organizer.go | 77 ++
go/web/routes_shared.go | 62 +
go/web/server.go | 45 +
go/web/static/app.js | 1633 ++++++++++++++++++++++++++
go/web/static/style.css | 824 +++++++++++++
go/web/templates/index.html | 531 +++++++++
19 files changed, 4422 insertions(+)
create mode 100644 go/cmd/morphic/main.go
create mode 100644 go/go.mod
create mode 100644 go/go.sum
create mode 100644 go/internal/organizer/date_sorter.go
create mode 100644 go/internal/organizer/renamer.go
create mode 100644 go/internal/organizer/scanner.go
create mode 100644 go/internal/shared/constants.go
create mode 100644 go/internal/shared/file_browser.go
create mode 100644 go/internal/shared/jobs.go
create mode 100644 go/internal/shared/thumbnails.go
create mode 100644 go/internal/shared/utils.go
create mode 100644 go/web/routes_organizer.go
create mode 100644 go/web/routes_shared.go
create mode 100644 go/web/server.go
create mode 100644 go/web/static/app.js
create mode 100644 go/web/static/style.css
create mode 100644 go/web/templates/index.html
diff --git a/.gitignore b/.gitignore
index 633961c..79f72e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -106,3 +106,4 @@ temp/
# PyPI
.pypirc
+bin/
diff --git a/Makefile b/Makefile
index b982023..db0e089 100644
--- a/Makefile
+++ b/Makefile
@@ -43,4 +43,23 @@ clean:
rm -rf .coverage
rm -rf .ruff_cache/
rm -rf docs/_build/
+ rm -rf bin/
find . -type d -name __pycache__ -exec rm -rf {} +
+
+# Go targets
+.PHONY: go-build go-test go-run go-vet go-tidy
+
+go-tidy:
+ cd go && go mod tidy
+
+go-build: go-tidy
+ cd go && go build -o ../bin/morphic ./cmd/morphic
+
+go-test:
+ cd go && go test ./...
+
+go-vet:
+ cd go && go vet ./...
+
+go-run: go-build
+ ./bin/morphic
diff --git a/go/cmd/morphic/main.go b/go/cmd/morphic/main.go
new file mode 100644
index 0000000..58b3294
--- /dev/null
+++ b/go/cmd/morphic/main.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os/exec"
+ "runtime"
+
+ "github.com/exterex/morphic/internal/shared"
+ "github.com/exterex/morphic/web"
+)
+
+func main() {
+ host := flag.String("host", "127.0.0.1", "Host to bind to")
+ port := flag.Int("port", 8000, "Port to listen on")
+ noBrowser := flag.Bool("no-browser", false, "Don't open browser automatically")
+ flag.Parse()
+
+ addr := fmt.Sprintf("%s:%d", *host, *port)
+ url := fmt.Sprintf("http://%s", addr)
+
+ if !*noBrowser {
+ go openBrowser(url)
+ }
+
+ log.Printf("Morphic %s starting on %s", shared.Version, url)
+
+ router := web.SetupRouter()
+ if err := router.Run(addr); err != nil {
+ log.Fatalf("Failed to start server: %v", err)
+ }
+}
+
+func openBrowser(url string) {
+ var cmd *exec.Cmd
+ switch runtime.GOOS {
+ case "linux":
+ cmd = exec.Command("xdg-open", url)
+ case "darwin":
+ cmd = exec.Command("open", url)
+ case "windows":
+ cmd = exec.Command("cmd", "/c", "start", url)
+ default:
+ return
+ }
+ _ = cmd.Start()
+}
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 0000000..d83f0ab
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,45 @@
+module github.com/exterex/morphic
+
+go 1.22
+
+require (
+ github.com/disintegration/imaging v1.6.2
+ github.com/dsoprea/go-exif/v3 v3.0.1
+ github.com/gin-gonic/gin v1.10.0
+ github.com/google/uuid v1.6.0
+)
+
+require (
+ github.com/bytedance/sonic v1.11.6 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
+ github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-errors/errors v1.4.2 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.20.0 // indirect
+ github.com/goccy/go-json v0.10.2 // indirect
+ github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.23.0 // indirect
+ golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
+ golang.org/x/net v0.25.0 // indirect
+ golang.org/x/sys v0.20.0 // indirect
+ golang.org/x/text v0.15.0 // indirect
+ google.golang.org/protobuf v1.34.1 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/go/go.sum b/go/go.sum
new file mode 100644
index 0000000..c2c7b19
--- /dev/null
+++ b/go/go.sum
@@ -0,0 +1,143 @@
+github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
+github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
+github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
+github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
+github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
+github.com/dsoprea/go-exif/v3 v3.0.0-20210625224831-a6301f85c82b/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
+github.com/dsoprea/go-exif/v3 v3.0.0-20221003160559-cf5cd88aa559/go.mod h1:rW6DMEv25U9zCtE5ukC7ttBRllXj7g7TAHl7tQrT5No=
+github.com/dsoprea/go-exif/v3 v3.0.0-20221003171958-de6cb6e380a8/go.mod h1:akyZEJZ/k5bmbC9gA612ZLQkcED8enS9vuTiuAkENr0=
+github.com/dsoprea/go-exif/v3 v3.0.1 h1:/IE4iW7gvY7BablV1XY0unqhMv26EYpOquVMwoBo/wc=
+github.com/dsoprea/go-exif/v3 v3.0.1/go.mod h1:10HkA1Wz3h398cDP66L+Is9kKDmlqlIJGPv8pk4EWvc=
+github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
+github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
+github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
+github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
+github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
+github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003142440-7a1927d49d9d/go.mod h1:LVjRU0RNUuMDqkPTxcALio0LWPFPXxxFCvVGVAwEpFc=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003160719-7bc88537c05e/go.mod h1:VZ7cB0pTjm1ADBWhJUOHESu4ZYy9JN+ZPqjfiW09EPU=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349 h1:DilThiXje0z+3UQ5YjYiSRRzVdtamFpvBQXKwMglWqw=
+github.com/dsoprea/go-utility/v2 v2.0.0-20221003172846-a3e1774ef349/go.mod h1:4GC5sXji84i/p+irqghpPFZBF8tRN/Q7+700G0/DLe8=
+github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
+github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
+github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
+github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
+github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
+github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
+github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
+github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
+github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
+github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
+github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 h1:gtexQ/VGyN+VVFRXSFiguSNcXmS6rkKT+X7FdIrTtfo=
+github.com/golang/geo v0.0.0-20210211234256-740aa86cb551/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
+github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
+golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
+google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/go/internal/organizer/date_sorter.go b/go/internal/organizer/date_sorter.go
new file mode 100644
index 0000000..bbd4e5a
--- /dev/null
+++ b/go/internal/organizer/date_sorter.go
@@ -0,0 +1,156 @@
+package organizer
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ exif "github.com/dsoprea/go-exif/v3"
+)
+
+// SortPlanEntry is a single planned file move/copy.
+type SortPlanEntry struct {
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
+}
+
+// GetFileDate extracts the date from EXIF data or falls back to modification time.
+func GetFileDate(path string) time.Time {
+ if t, err := getExifDate(path); err == nil {
+ return t
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ return time.Now()
+ }
+ return info.ModTime()
+}
+
+func getExifDate(path string) (time.Time, error) {
+ rawExif, err := exif.SearchFileAndExtractExif(path)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ entries, _, err := exif.GetFlatExifData(rawExif, nil)
+ if err != nil {
+ return time.Time{}, err
+ }
+
+ // Look for DateTimeOriginal (0x9003) or DateTime (0x0132)
+ for _, entry := range entries {
+ if entry.TagId == 0x9003 || entry.TagId == 0x0132 {
+ if s, ok := entry.Value.(string); ok {
+ s = strings.TrimRight(strings.TrimSpace(s), "\x00")
+ if s != "" && s != "0000:00:00 00:00:00" {
+ t, err := time.Parse("2006:01:02 15:04:05", s)
+ if err == nil {
+ return t, nil
+ }
+ }
+ }
+ }
+ }
+
+ return time.Time{}, fmt.Errorf("no date tag found")
+}
+
+// RenderTemplate replaces date tokens in a template string.
+func RenderTemplate(tmpl string, date time.Time) string {
+ r := strings.NewReplacer(
+ "{year}", fmt.Sprintf("%04d", date.Year()),
+ "{month}", fmt.Sprintf("%02d", date.Month()),
+ "{day}", fmt.Sprintf("%02d", date.Day()),
+ "{hour}", fmt.Sprintf("%02d", date.Hour()),
+ "{minute}", fmt.Sprintf("%02d", date.Minute()),
+ )
+ return r.Replace(tmpl)
+}
+
+// PlanSort creates a plan for sorting files into date-based folders.
+func PlanSort(files []string, template string, destination string) []SortPlanEntry {
+ var plan []SortPlanEntry
+
+ for _, path := range files {
+ date := GetFileDate(path)
+ subDir := RenderTemplate(template, date)
+ destDir := filepath.Join(destination, subDir)
+ destPath := filepath.Join(destDir, filepath.Base(path))
+
+ plan = append(plan, SortPlanEntry{
+ Source: path,
+ Destination: destPath,
+ Status: "pending",
+ })
+ }
+
+ return plan
+}
+
+// ExecuteSort executes the sort plan using the given operation (move or copy).
+func ExecuteSort(plan []SortPlanEntry, operation string) {
+ for i := range plan {
+ destDir := filepath.Dir(plan[i].Destination)
+ if err := os.MkdirAll(destDir, 0o755); err != nil {
+ plan[i].Status = "error"
+ plan[i].Error = err.Error()
+ continue
+ }
+
+ var err error
+ switch operation {
+ case "move":
+ err = os.Rename(plan[i].Source, plan[i].Destination)
+ if err != nil {
+ // Cross-device move: copy + remove
+ err = copyFile(plan[i].Source, plan[i].Destination)
+ if err == nil {
+ os.Remove(plan[i].Source)
+ }
+ }
+ case "copy":
+ err = copyFile(plan[i].Source, plan[i].Destination)
+ default:
+ err = fmt.Errorf("unknown operation: %s", operation)
+ }
+
+ if err != nil {
+ plan[i].Status = "error"
+ plan[i].Error = err.Error()
+ } else {
+ plan[i].Status = "done"
+ }
+ }
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ return err
+ }
+
+ // Preserve modification time
+ info, err := os.Stat(src)
+ if err == nil {
+ os.Chtimes(dst, info.ModTime(), info.ModTime())
+ }
+
+ return out.Close()
+}
diff --git a/go/internal/organizer/renamer.go b/go/internal/organizer/renamer.go
new file mode 100644
index 0000000..93e54ad
--- /dev/null
+++ b/go/internal/organizer/renamer.go
@@ -0,0 +1,126 @@
+package organizer
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "time"
+)
+
+// RenamePlanEntry is a single planned rename.
+type RenamePlanEntry struct {
+ Source string `json:"source"`
+ Destination string `json:"destination"`
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
+}
+
+var seqPaddedRegex = regexp.MustCompile(`\{seq:(\d+)\}`)
+
+// RenderName applies the rename template to a file.
+func RenderName(tmpl string, path string, seq int) string {
+ date := GetFileDate(path)
+ ext := filepath.Ext(path)
+ original := strings.TrimSuffix(filepath.Base(path), ext)
+
+ result := tmpl
+ result = strings.ReplaceAll(result, "{date}", date.Format("20060102"))
+ result = strings.ReplaceAll(result, "{datetime}", date.Format("20060102_150405"))
+ result = strings.ReplaceAll(result, "{original}", original)
+ result = strings.ReplaceAll(result, "{ext}", strings.TrimPrefix(ext, "."))
+
+ // Handle {seq:N} with zero-padded sequence
+ result = seqPaddedRegex.ReplaceAllStringFunc(result, func(match string) string {
+ sub := seqPaddedRegex.FindStringSubmatch(match)
+ if len(sub) < 2 {
+ return match
+ }
+ width := 0
+ fmt.Sscanf(sub[1], "%d", &width)
+ return fmt.Sprintf("%0*d", width, seq)
+ })
+
+ // Handle plain {seq}
+ result = strings.ReplaceAll(result, "{seq}", fmt.Sprintf("%d", seq))
+
+ return result + ext
+}
+
+type fileWithDate struct {
+ Path string
+ Date time.Time
+}
+
+// PlanRename creates a rename plan for the given files.
+func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry {
+ // Sort by (date, path) for consistent sequencing
+ fwd := make([]fileWithDate, len(files))
+ for i, f := range files {
+ fwd[i] = fileWithDate{Path: f, Date: GetFileDate(f)}
+ }
+ sort.Slice(fwd, func(i, j int) bool {
+ if fwd[i].Date.Equal(fwd[j].Date) {
+ return fwd[i].Path < fwd[j].Path
+ }
+ return fwd[i].Date.Before(fwd[j].Date)
+ })
+
+ plan := make([]RenamePlanEntry, len(fwd))
+ destSet := make(map[string]int) // destination -> first index
+
+ for i, f := range fwd {
+ newName := RenderName(tmpl, f.Path, i+1)
+ destPath := filepath.Join(filepath.Dir(f.Path), newName)
+
+ entry := RenamePlanEntry{
+ Source: f.Path,
+ Destination: destPath,
+ Status: "pending",
+ }
+
+ // Check for conflicts
+ if prevIdx, exists := destSet[destPath]; exists {
+ entry.Status = "conflict"
+ entry.Error = fmt.Sprintf("conflicts with file #%d", prevIdx+1)
+ plan[prevIdx].Status = "conflict"
+ if plan[prevIdx].Error == "" {
+ plan[prevIdx].Error = fmt.Sprintf("conflicts with file #%d", i+1)
+ }
+ } else {
+ destSet[destPath] = i
+ }
+
+ plan[i] = entry
+ }
+
+ return plan
+}
+
+// ExecuteRename executes the rename plan.
+func ExecuteRename(plan []RenamePlanEntry, operation string) {
+ for i := range plan {
+ if plan[i].Status == "conflict" {
+ continue
+ }
+
+ var err error
+ switch operation {
+ case "move":
+ err = os.Rename(plan[i].Source, plan[i].Destination)
+ case "copy":
+ err = copyFile(plan[i].Source, plan[i].Destination)
+ default:
+ err = fmt.Errorf("unknown operation: %s", operation)
+ }
+
+ if err != nil {
+ plan[i].Status = "error"
+ plan[i].Error = err.Error()
+ } else {
+ plan[i].Status = "done"
+ }
+ }
+}
diff --git a/go/internal/organizer/scanner.go b/go/internal/organizer/scanner.go
new file mode 100644
index 0000000..ffea6d1
--- /dev/null
+++ b/go/internal/organizer/scanner.go
@@ -0,0 +1,166 @@
+package organizer
+
+import (
+ "sync"
+ "time"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ScanJob represents an organizer scan/plan/execute job.
+type ScanJob struct {
+ shared.Job
+ mu sync.Mutex
+
+ Folder string `json:"folder"`
+ Phase string `json:"phase"`
+ ScanType string `json:"scan_type"`
+ Template string `json:"template"`
+ Destination string `json:"destination"`
+ Operation string `json:"operation"`
+ Mode string `json:"mode"`
+ Files []string `json:"files,omitempty"`
+ SortPlan []SortPlanEntry `json:"sort_plan,omitempty"`
+ RenamePlan []RenamePlanEntry `json:"rename_plan,omitempty"`
+ Total int `json:"total"`
+ Processed int `json:"processed"`
+}
+
+var store = shared.NewJobStore[ScanJob]()
+
+func init() {
+ store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+// StartPlanJob starts a new planning job.
+func StartPlanJob(folder, mode, template, destination, operation, scanType string) string {
+ job := &ScanJob{
+ Job: shared.NewJob(),
+ Folder: folder,
+ Phase: "scanning",
+ Mode: mode,
+ Template: template,
+ Destination: destination,
+ Operation: operation,
+ ScanType: scanType,
+ }
+ job.Status = shared.JobStatusRunning
+
+ store.Set(job.ID, job)
+
+ go runPlan(job)
+
+ return job.ID
+}
+
+// GetJob retrieves a job by ID.
+func GetJob(id string) (*ScanJob, bool) {
+ return store.Get(id)
+}
+
+// ExecuteJob starts the execution phase of a planned job.
+func ExecuteJob(id string) bool {
+ job, ok := store.Get(id)
+ if !ok || job.Phase != "planned" {
+ return false
+ }
+
+ job.mu.Lock()
+ job.Phase = "executing"
+ job.Progress = 0
+ job.Processed = 0
+ job.mu.Unlock()
+
+ go runExecute(job)
+ return true
+}
+
+func runPlan(job *ScanJob) {
+ var files []shared.FileInfo
+ var err error
+
+ switch job.ScanType {
+ case "images":
+ files, err = shared.FindImageFiles(job.Folder)
+ case "videos":
+ files, err = shared.FindVideoFiles(job.Folder)
+ default:
+ files, err = shared.FindAllMediaFiles(job.Folder)
+ }
+
+ if err != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = err.Error()
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ return
+ }
+
+ paths := make([]string, len(files))
+ for i, f := range files {
+ paths[i] = f.Path
+ }
+
+ job.mu.Lock()
+ job.Files = paths
+ job.Total = len(paths)
+ job.Phase = "planning"
+ job.Progress = 0.3
+ job.mu.Unlock()
+
+ switch job.Mode {
+ case "sort":
+ dest := job.Destination
+ if dest == "" {
+ dest = job.Folder
+ }
+ plan := PlanSort(paths, job.Template, dest)
+ job.mu.Lock()
+ job.SortPlan = plan
+ job.mu.Unlock()
+ case "rename":
+ plan := PlanRename(paths, job.Template, job.Operation)
+ job.mu.Lock()
+ job.RenamePlan = plan
+ job.mu.Unlock()
+ }
+
+ job.mu.Lock()
+ job.Phase = "planned"
+ job.Progress = 1.0
+ job.Message = "Plan ready for review"
+ job.mu.Unlock()
+}
+
+func runExecute(job *ScanJob) {
+ switch job.Mode {
+ case "sort":
+ ExecuteSort(job.SortPlan, job.Operation)
+ job.mu.Lock()
+ for _, e := range job.SortPlan {
+ if e.Status == "done" {
+ job.Processed++
+ }
+ }
+ job.mu.Unlock()
+ case "rename":
+ ExecuteRename(job.RenamePlan, job.Operation)
+ job.mu.Lock()
+ for _, e := range job.RenamePlan {
+ if e.Status == "done" {
+ job.Processed++
+ }
+ }
+ job.mu.Unlock()
+ }
+
+ job.mu.Lock()
+ job.Phase = "done"
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+}
diff --git a/go/internal/shared/constants.go b/go/internal/shared/constants.go
new file mode 100644
index 0000000..2f9f9ca
--- /dev/null
+++ b/go/internal/shared/constants.go
@@ -0,0 +1,107 @@
+package shared
+
+const Version = "0.1.0"
+
+const (
+ DefaultImageThreshold = 0.90
+ DefaultVideoThreshold = 0.85
+ DefaultHashSize = 16
+ DefaultNumFrames = 10
+ DefaultNumWorkers = 4
+ DefaultBatchSize = 1000
+)
+
+// ImageExtensions contains all supported image file extensions (lowercase, with dot prefix).
+var ImageExtensions = map[string]struct{}{
+ ".jpg": {},
+ ".jpeg": {},
+ ".png": {},
+ ".tif": {},
+ ".tiff": {},
+ ".bmp": {},
+ ".webp": {},
+ ".gif": {},
+ ".ico": {},
+ ".heic": {},
+ ".heif": {},
+ ".avif": {},
+ ".svg": {},
+ ".raw": {},
+ ".cr2": {},
+ ".nef": {},
+ ".arw": {},
+ ".dng": {},
+ ".orf": {},
+ ".rw2": {},
+ ".pef": {},
+ ".srw": {},
+}
+
+// VideoExtensions contains all supported video file extensions.
+var VideoExtensions = map[string]struct{}{
+ ".mp4": {},
+ ".mov": {},
+ ".avi": {},
+ ".mkv": {},
+ ".webm": {},
+ ".flv": {},
+ ".wmv": {},
+ ".m4v": {},
+ ".mpeg": {},
+ ".mpg": {},
+ ".3gp": {},
+ ".ts": {},
+ ".ogv": {},
+ ".mts": {},
+ ".m2ts": {},
+ ".vob": {},
+ ".divx": {},
+ ".xvid": {},
+ ".asf": {},
+ ".rm": {},
+ ".rmvb": {},
+}
+
+// ExcludedFolders contains folder names to skip during scanning.
+var ExcludedFolders = map[string]struct{}{
+ // Windows
+ "$recycle.bin": {},
+ "$recycle": {},
+ "recycler": {},
+ "recycled": {},
+ "system volume information": {},
+ "windows": {},
+ "appdata": {},
+ // macOS
+ ".trash": {},
+ ".trashes": {},
+ ".spotlight-v100": {},
+ ".fseventsd": {},
+ ".ds_store": {},
+ // Linux
+ "lost+found": {},
+ "trash": {},
+ // Thumbnails
+ ".thumbnails": {},
+ ".thumb": {},
+ "thumbs": {},
+ // NAS
+ "@eadir": {},
+ // Version control
+ ".git": {},
+ ".svn": {},
+ ".hg": {},
+ // Development
+ "__pycache__": {},
+ ".cache": {},
+ "node_modules": {},
+ ".venv": {},
+ "venv": {},
+}
+
+// Aliases maps alternative extensions to their canonical form.
+var Aliases = map[string]string{
+ ".jpeg": ".jpg",
+ ".tiff": ".tif",
+ ".mpg": ".mpeg",
+}
diff --git a/go/internal/shared/file_browser.go b/go/internal/shared/file_browser.go
new file mode 100644
index 0000000..52951e2
--- /dev/null
+++ b/go/internal/shared/file_browser.go
@@ -0,0 +1,69 @@
+package shared
+
+import (
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+)
+
+// OpenNativeFolderDialog opens a native folder selection dialog.
+// Returns the selected folder path or empty string if cancelled.
+func OpenNativeFolderDialog() (string, error) {
+ // Test mode: allow test to set folder via environment
+ if testFolder := os.Getenv("MORPHIC_TEST_FOLDER"); testFolder != "" {
+ return testFolder, nil
+ }
+
+ switch runtime.GOOS {
+ case "linux":
+ return linuxFolderDialog()
+ case "darwin":
+ return macFolderDialog()
+ case "windows":
+ return windowsFolderDialog()
+ default:
+ return "", nil
+ }
+}
+
+func linuxFolderDialog() (string, error) {
+ // Try zenity first
+ if path, err := exec.LookPath("zenity"); err == nil && path != "" {
+ cmd := exec.Command("zenity", "--file-selection", "--directory", "--title=Select Folder")
+ out, err := cmd.Output()
+ if err == nil {
+ return strings.TrimSpace(string(out)), nil
+ }
+ }
+
+ // Try kdialog
+ if path, err := exec.LookPath("kdialog"); err == nil && path != "" {
+ cmd := exec.Command("kdialog", "--getexistingdirectory", ".")
+ out, err := cmd.Output()
+ if err == nil {
+ return strings.TrimSpace(string(out)), nil
+ }
+ }
+
+ return "", nil
+}
+
+func macFolderDialog() (string, error) {
+ cmd := exec.Command("osascript", "-e", `POSIX path of (choose folder with prompt "Select Folder")`)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", nil
+ }
+ return strings.TrimSpace(string(out)), nil
+}
+
+func windowsFolderDialog() (string, error) {
+ script := `Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; if ($f.ShowDialog() -eq 'OK') { $f.SelectedPath }`
+ cmd := exec.Command("powershell", "-NoProfile", "-Command", script)
+ out, err := cmd.Output()
+ if err != nil {
+ return "", nil
+ }
+ return strings.TrimSpace(string(out)), nil
+}
diff --git a/go/internal/shared/jobs.go b/go/internal/shared/jobs.go
new file mode 100644
index 0000000..2871510
--- /dev/null
+++ b/go/internal/shared/jobs.go
@@ -0,0 +1,94 @@
+package shared
+
+import (
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// JobStatus represents the state of a background job.
+type JobStatus string
+
+const (
+ JobStatusPending JobStatus = "pending"
+ JobStatusRunning JobStatus = "running"
+ JobStatusDone JobStatus = "done"
+ JobStatusFailed JobStatus = "failed"
+ JobStatusPlanned JobStatus = "planned"
+)
+
+// Job is a base type embedded in all module-specific jobs.
+type Job struct {
+ ID string `json:"id"`
+ Status JobStatus `json:"status"`
+ Progress float64 `json:"progress"`
+ Message string `json:"message,omitempty"`
+ Error string `json:"error,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+ DoneAt time.Time `json:"done_at,omitempty"`
+}
+
+// NewJob creates a new job with a unique ID.
+func NewJob() Job {
+ return Job{
+ ID: uuid.New().String(),
+ Status: JobStatusPending,
+ Progress: 0,
+ StartedAt: time.Now(),
+ }
+}
+
+// JobStore is a thread-safe generic store for background jobs.
+type JobStore[T any] struct {
+ mu sync.RWMutex
+ jobs map[string]*T
+}
+
+// NewJobStore creates a new JobStore.
+func NewJobStore[T any]() *JobStore[T] {
+ return &JobStore[T]{
+ jobs: make(map[string]*T),
+ }
+}
+
+// Set stores a job.
+func (s *JobStore[T]) Set(id string, job *T) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.jobs[id] = job
+}
+
+// Get retrieves a job by ID.
+func (s *JobStore[T]) Get(id string) (*T, bool) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ job, ok := s.jobs[id]
+ return job, ok
+}
+
+// Delete removes a job by ID.
+func (s *JobStore[T]) Delete(id string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ delete(s.jobs, id)
+}
+
+// StartCleanup runs a background goroutine that removes jobs older than ttl.
+func (s *JobStore[T]) StartCleanup(ttl time.Duration, getDoneAt func(*T) time.Time) {
+ go func() {
+ ticker := time.NewTicker(ttl / 2)
+ defer ticker.Stop()
+ for range ticker.C {
+ now := time.Now()
+ s.mu.Lock()
+ for id, job := range s.jobs {
+ doneAt := getDoneAt(job)
+ if !doneAt.IsZero() && now.Sub(doneAt) > ttl {
+ delete(s.jobs, id)
+ }
+ }
+ s.mu.Unlock()
+ }
+ }()
+}
diff --git a/go/internal/shared/thumbnails.go b/go/internal/shared/thumbnails.go
new file mode 100644
index 0000000..c8a1220
--- /dev/null
+++ b/go/internal/shared/thumbnails.go
@@ -0,0 +1,135 @@
+package shared
+
+import (
+ "bytes"
+ "fmt"
+ "image/jpeg"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync"
+
+ "github.com/disintegration/imaging"
+)
+
+const (
+ DefaultThumbnailSize = 200
+ DefaultThumbnailQuality = 80
+)
+
+// ThumbnailCache provides a thread-safe cache for generated thumbnails.
+type ThumbnailCache struct {
+ mu sync.RWMutex
+ store map[string][]byte
+}
+
+var thumbnailCache = &ThumbnailCache{
+ store: make(map[string][]byte),
+}
+
+// GenerateImageThumbnail creates a JPEG thumbnail for an image file.
+func GenerateImageThumbnail(path string, size int) ([]byte, error) {
+ if size <= 0 {
+ size = DefaultThumbnailSize
+ }
+
+ cacheKey := fmt.Sprintf("%s:%d", path, size)
+ thumbnailCache.mu.RLock()
+ if data, ok := thumbnailCache.store[cacheKey]; ok {
+ thumbnailCache.mu.RUnlock()
+ return data, nil
+ }
+ thumbnailCache.mu.RUnlock()
+
+ img, err := imaging.Open(path, imaging.AutoOrientation(true))
+ if err != nil {
+ return nil, fmt.Errorf("failed to open image %s: %w", path, err)
+ }
+
+ thumb := imaging.Fit(img, size, size, imaging.Lanczos)
+
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, thumb, &jpeg.Options{Quality: DefaultThumbnailQuality}); err != nil {
+ return nil, fmt.Errorf("failed to encode thumbnail: %w", err)
+ }
+
+ data := buf.Bytes()
+ thumbnailCache.mu.Lock()
+ thumbnailCache.store[cacheKey] = data
+ thumbnailCache.mu.Unlock()
+
+ return data, nil
+}
+
+// GenerateVideoThumbnail creates a JPEG thumbnail for a video file using ffmpeg.
+func GenerateVideoThumbnail(path string, size int) ([]byte, error) {
+ if size <= 0 {
+ size = DefaultThumbnailSize
+ }
+
+ cacheKey := fmt.Sprintf("video:%s:%d", path, size)
+ thumbnailCache.mu.RLock()
+ if data, ok := thumbnailCache.store[cacheKey]; ok {
+ thumbnailCache.mu.RUnlock()
+ return data, nil
+ }
+ thumbnailCache.mu.RUnlock()
+
+ // Try extracting frame at 1 second, fallback to 0 seconds
+ data, err := extractVideoFrame(path, "00:00:01", size)
+ if err != nil {
+ data, err = extractVideoFrame(path, "00:00:00", size)
+ if err != nil {
+ return nil, fmt.Errorf("failed to extract video frame from %s: %w", path, err)
+ }
+ }
+
+ thumbnailCache.mu.Lock()
+ thumbnailCache.store[cacheKey] = data
+ thumbnailCache.mu.Unlock()
+
+ return data, nil
+}
+
+func extractVideoFrame(videoPath, seekTime string, size int) ([]byte, error) {
+ cmd := exec.Command("ffmpeg",
+ "-ss", seekTime,
+ "-i", videoPath,
+ "-vframes", "1",
+ "-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", size, size),
+ "-f", "image2pipe",
+ "-vcodec", "mjpeg",
+ "-q:v", "5",
+ "pipe:1",
+ )
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("ffmpeg failed: %w, stderr: %s", err, stderr.String())
+ }
+
+ return stdout.Bytes(), nil
+}
+
+// IsImageFile checks if a path has an image extension.
+func IsImageFile(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ if alias, ok := Aliases[ext]; ok {
+ ext = alias
+ }
+ _, ok := ImageExtensions[ext]
+ return ok
+}
+
+// IsVideoFile checks if a path has a video extension.
+func IsVideoFile(path string) bool {
+ ext := strings.ToLower(filepath.Ext(path))
+ if alias, ok := Aliases[ext]; ok {
+ ext = alias
+ }
+ _, ok := VideoExtensions[ext]
+ return ok
+}
diff --git a/go/internal/shared/utils.go b/go/internal/shared/utils.go
new file mode 100644
index 0000000..15662fd
--- /dev/null
+++ b/go/internal/shared/utils.go
@@ -0,0 +1,141 @@
+package shared
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// FileInfo holds metadata about a discovered file.
+type FileInfo struct {
+ Path string
+ Name string
+ Size int64
+ Ext string
+}
+
+// FindFilesByExtension walks the folder tree once and returns all files
+// matching the given extensions. This replaces the Python version which
+// called rglob() twice per extension (82+ traversals).
+func FindFilesByExtension(folder string, extensions map[string]struct{}, excludedFolders map[string]struct{}) ([]FileInfo, error) {
+ var files []FileInfo
+ seen := make(map[string]struct{})
+
+ err := filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil // skip inaccessible entries
+ }
+
+ if d.IsDir() {
+ name := strings.ToLower(d.Name())
+ if _, excluded := excludedFolders[name]; excluded {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+
+ ext := strings.ToLower(filepath.Ext(path))
+ if alias, ok := Aliases[ext]; ok {
+ ext = alias
+ }
+
+ if _, ok := extensions[ext]; !ok {
+ return nil
+ }
+
+ // Deduplicate by resolved path
+ abs, err := filepath.Abs(path)
+ if err != nil {
+ abs = path
+ }
+ if _, dup := seen[abs]; dup {
+ return nil
+ }
+ seen[abs] = struct{}{}
+
+ info, err := d.Info()
+ if err != nil {
+ return nil
+ }
+
+ files = append(files, FileInfo{
+ Path: abs,
+ Name: d.Name(),
+ Size: info.Size(),
+ Ext: ext,
+ })
+
+ return nil
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ sort.Slice(files, func(i, j int) bool {
+ return files[i].Path < files[j].Path
+ })
+
+ return files, nil
+}
+
+// IsExcludedPath checks if any component of the path is in the exclusion set.
+func IsExcludedPath(path string, excludedFolders map[string]struct{}) bool {
+ parts := strings.Split(filepath.ToSlash(path), "/")
+ for _, part := range parts {
+ if _, excluded := excludedFolders[strings.ToLower(part)]; excluded {
+ return true
+ }
+ }
+ return false
+}
+
+// FindImageFiles returns all image files in the given folder.
+func FindImageFiles(folder string) ([]FileInfo, error) {
+ return FindFilesByExtension(folder, ImageExtensions, ExcludedFolders)
+}
+
+// FindVideoFiles returns all video files in the given folder.
+func FindVideoFiles(folder string) ([]FileInfo, error) {
+ return FindFilesByExtension(folder, VideoExtensions, ExcludedFolders)
+}
+
+// FindAllMediaFiles returns all image and video files.
+func FindAllMediaFiles(folder string) ([]FileInfo, error) {
+ allExts := make(map[string]struct{})
+ for k, v := range ImageExtensions {
+ allExts[k] = v
+ }
+ for k, v := range VideoExtensions {
+ allExts[k] = v
+ }
+ return FindFilesByExtension(folder, allExts, ExcludedFolders)
+}
+
+// FormatFileSize formats file size in human-readable format.
+func FormatFileSize(sizeBytes int64) string {
+ size := float64(sizeBytes)
+ for _, unit := range []string{"B", "KB", "MB", "GB"} {
+ if size < 1024 {
+ return fmt.Sprintf("%.2f %s", size, unit)
+ }
+ size /= 1024
+ }
+ return fmt.Sprintf("%.2f TB", size)
+}
+
+// FormatDuration formats duration in human-readable format.
+func FormatDuration(seconds float64) string {
+ hours := int(seconds) / 3600
+ minutes := (int(seconds) % 3600) / 60
+ secs := int(seconds) % 60
+ if hours > 0 {
+ return fmt.Sprintf("%dh %dm %ds", hours, minutes, secs)
+ }
+ if minutes > 0 {
+ return fmt.Sprintf("%dm %ds", minutes, secs)
+ }
+ return fmt.Sprintf("%ds", secs)
+}
diff --git a/go/web/routes_organizer.go b/go/web/routes_organizer.go
new file mode 100644
index 0000000..988f9b8
--- /dev/null
+++ b/go/web/routes_organizer.go
@@ -0,0 +1,77 @@
+package web
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/organizer"
+)
+
+func registerOrganizerRoutes(r *gin.Engine) {
+ g := r.Group("/api/organizer")
+ {
+ g.POST("/scan", handleOrganizerScan)
+ g.POST("/execute/:id", handleOrganizerExecute)
+ g.GET("/status/:id", handleOrganizerStatus)
+ }
+}
+
+func handleOrganizerScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Mode string `json:"mode"`
+ Template string `json:"template"`
+ Destination string `json:"destination"`
+ Operation string `json:"operation"`
+ ScanType string `json:"scan_type"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ if req.Folder == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "folder is required"})
+ return
+ }
+
+ jobID := organizer.StartPlanJob(
+ req.Folder, req.Mode, req.Template,
+ req.Destination, req.Operation, req.ScanType,
+ )
+
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleOrganizerExecute(c *gin.Context) {
+ id := c.Param("id")
+ if !organizer.ExecuteJob(id) {
+ c.JSON(http.StatusNotFound, gin.H{"error": "job not found or not in planned state"})
+ return
+ }
+
+ c.JSON(http.StatusAccepted, gin.H{"status": "executing"})
+}
+
+func handleOrganizerStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := organizer.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "job not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "phase": job.Phase,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ "total": job.Total,
+ "processed": job.Processed,
+ "sort_plan": job.SortPlan,
+ "rename_plan": job.RenamePlan,
+ })
+}
diff --git a/go/web/routes_shared.go b/go/web/routes_shared.go
new file mode 100644
index 0000000..e417557
--- /dev/null
+++ b/go/web/routes_shared.go
@@ -0,0 +1,62 @@
+package web
+
+import (
+ "net/http"
+ "runtime"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+func registerSharedRoutes(r *gin.Engine) {
+ r.POST("/api/browse", handleBrowse)
+ r.GET("/api/thumbnail", handleThumbnail)
+ r.GET("/api/system-info", handleSystemInfo)
+}
+
+func handleBrowse(c *gin.Context) {
+ folder, err := shared.OpenNativeFolderDialog()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ if folder == "" {
+ c.JSON(http.StatusOK, gin.H{"folder": nil})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"folder": folder})
+}
+
+func handleThumbnail(c *gin.Context) {
+ path := c.Query("path")
+ if path == "" {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+
+ var data []byte
+ var err error
+
+ if shared.IsVideoFile(path) {
+ data, err = shared.GenerateVideoThumbnail(path, shared.DefaultThumbnailSize)
+ } else {
+ data, err = shared.GenerateImageThumbnail(path, shared.DefaultThumbnailSize)
+ }
+
+ if err != nil {
+ c.Status(http.StatusInternalServerError)
+ return
+ }
+
+ c.Data(http.StatusOK, "image/jpeg", data)
+}
+
+func handleSystemInfo(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "version": shared.Version,
+ "platform": runtime.GOOS,
+ "arch": runtime.GOARCH,
+ "go": runtime.Version(),
+ "cpus": runtime.NumCPU(),
+ })
+}
diff --git a/go/web/server.go b/go/web/server.go
new file mode 100644
index 0000000..5f34087
--- /dev/null
+++ b/go/web/server.go
@@ -0,0 +1,45 @@
+package web
+
+import (
+ "embed"
+ "html/template"
+ "io/fs"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed templates static
+var webFS embed.FS
+
+// SetupRouter creates and configures the gin router with all routes.
+func SetupRouter() *gin.Engine {
+ r := gin.Default()
+
+ // Parse templates from embedded FS
+ tmpl := template.Must(template.ParseFS(webFS, "templates/*.html"))
+ r.SetHTMLTemplate(tmpl)
+
+ // Serve static files from embedded FS
+ staticFS, _ := fs.Sub(webFS, "static")
+ r.StaticFS("/static", http.FS(staticFS))
+
+ // No-cache middleware (mirrors Python's @app.after_request)
+ r.Use(func(c *gin.Context) {
+ c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
+ c.Header("Pragma", "no-cache")
+ c.Header("Expires", "0")
+ c.Next()
+ })
+
+ // Index route
+ r.GET("/", func(c *gin.Context) {
+ c.HTML(http.StatusOK, "index.html", nil)
+ })
+
+ // Register API route groups
+ registerSharedRoutes(r)
+ registerOrganizerRoutes(r)
+
+ return r
+}
diff --git a/go/web/static/app.js b/go/web/static/app.js
new file mode 100644
index 0000000..5261a06
--- /dev/null
+++ b/go/web/static/app.js
@@ -0,0 +1,1633 @@
+/* ── Morphic — Unified JS ─────────────────────────────────────────── */
+
+// =====================================================================
+// Global state
+// =====================================================================
+
+let activeTab = 'converter';
+let lastSelectedFolder = '';
+
+// Converter state
+let convScanData = null; // last scan result
+let convFilterType = 'both'; // images|videos|both
+let convFilterExt = null; // filter by specific extension
+let convSelectedFiles = []; // files selected for conversion
+let convAvailableTargets = []; // all targets currently available for batch conversion
+let convAv1Available = false; // AV1 support from ffmpeg
+let convLastFailedFiles = new Set(); // set of last conversion failures
+let convBatchMode = 'intersection'; // union|intersection
+let convAv1Crf = 32;
+let convJobId = null;
+let convPollTimer = null;
+let showFullPaths = false;
+
+async function convLoadFormats() {
+ try {
+ const savedMode = localStorage.getItem('convBatchMode');
+ if (savedMode === 'union' || savedMode === 'intersection') {
+ convBatchMode = savedMode;
+ }
+
+ const savedAv1Crf = Number(localStorage.getItem('convAv1Crf'));
+ if (!Number.isNaN(savedAv1Crf) && savedAv1Crf >= 10 && savedAv1Crf <= 63) {
+ convAv1Crf = savedAv1Crf;
+ }
+
+ const modeSelect = document.getElementById('convBatchMode');
+ if (modeSelect) {
+ modeSelect.value = convBatchMode;
+ }
+
+ const av1CrfInput = document.getElementById('convAv1Crf');
+ if (av1CrfInput) {
+ av1CrfInput.value = convAv1Crf;
+ document.getElementById('convAv1CrfValue').textContent = convAv1Crf;
+ }
+
+ const resp = await fetch('/api/converter/formats');
+ const data = await resp.json();
+
+ // Augment with system diagnostic info for AV1 support
+ try {
+ const sysResp = await fetch('/api/system_info');
+ const sysInfo = await sysResp.json();
+ const ffenc = sysInfo.ffmpeg && sysInfo.ffmpeg.encoders || [];
+ convAv1Available = ffenc.some(e => e.includes('av1_nvenc') || e.includes('libsvtav1') || e.includes('libaom-av1'));
+ } catch (e) {
+ convAv1Available = false;
+ }
+
+ const targets = new Set();
+ if (data.image) {
+ Object.values(data.image).flat().forEach(t => targets.add(t));
+ }
+ if (data.video) {
+ Object.values(data.video).flat().forEach(t => targets.add(t));
+ }
+
+ if (convAv1Available) {
+ targets.add('.mp4-av1');
+ targets.add('.mkv-av1');
+ targets.add('.webm-av1');
+ }
+
+ convAvailableTargets = [...targets].sort();
+ convSetBatchTargets(convAvailableTargets);
+ } catch (e) {
+ console.error('Failed to load converter formats:', e);
+ }
+}
+
+// Dupfinder state
+let dupJobId = null;
+let dupPollTimer = null;
+let dupAllGroups = [];
+let dupSelectedFiles = new Set();
+
+// Inspector state
+let inspJobId = null;
+let inspPollTimer = null;
+
+// Resizer state
+let resJobId = null;
+let resPollTimer = null;
+
+// Organizer state
+let orgJobId = null;
+let orgPollTimer = null;
+
+// =====================================================================
+// Tabs
+// =====================================================================
+
+function switchTab(tab) {
+ activeTab = tab;
+ document.querySelectorAll('.tab-btn').forEach(b => {
+ b.classList.toggle('active', b.dataset.tab === tab);
+ });
+ document.querySelectorAll('.tab-content').forEach(c => {
+ c.classList.toggle('active', c.id === 'tab-' + tab);
+ });
+}
+
+// =====================================================================
+// Shared: In-page Folder Browser
+// =====================================================================
+
+const _browserPrefixes = {
+ converter: 'conv', dupfinder: 'dup',
+ inspector: 'insp', resizer: 'res',
+ organizer: 'org',
+};
+
+function _bp(tab) { return _browserPrefixes[tab] || tab; }
+
+function _setLastFolder(path) {
+ lastSelectedFolder = path;
+ try {
+ localStorage.setItem('morphic_last_folder', path);
+ } catch (e) {
+ // ignore
+ }
+}
+
+function _storeFolder(tab, path) {
+ _setLastFolder(path);
+ try {
+ localStorage.setItem(`morphic_folder_${tab}`, path);
+ } catch (e) {
+ // Ignore storage errors in privacy modes
+ }
+}
+
+function _loadFolder(tab) {
+ try {
+ return localStorage.getItem(`morphic_folder_${tab}`) || '';
+ } catch (e) {
+ return '';
+ }
+}
+
+function _loadLastFolder() {
+ try {
+ return localStorage.getItem('morphic_last_folder') || '';
+ } catch (e) {
+ return '';
+ }
+}
+
+
+function loadFolderPreferences() {
+ lastSelectedFolder = _loadLastFolder();
+ const tabs = ['converter', 'dupfinder', 'inspector', 'resizer', 'organizer'];
+ for (const tab of tabs) {
+ const input = document.getElementById(_bp(tab) + 'Folder');
+ if (!input) continue;
+
+ let saved = _loadFolder(tab);
+ if (!saved) {
+ saved = lastSelectedFolder || '';
+ }
+
+ if (saved) {
+ input.value = saved;
+ _setLastFolder(saved);
+ }
+
+ let indicator = input.closest('.form-group')?.querySelector('.folder-saved-indicator');
+ if (!indicator && input.closest('.form-group')) {
+ indicator = document.createElement('span');
+ indicator.className = 'folder-saved-indicator';
+ indicator.style.marginLeft = '10px';
+ indicator.style.fontSize = '12px';
+ indicator.style.color = 'var(--success, #06c)';
+ input.closest('.form-group').appendChild(indicator);
+ }
+
+ if (indicator) {
+ indicator.textContent = saved ? '✅ saved' : '';
+ }
+
+ input.addEventListener('input', () => {
+ const value = input.value.trim();
+ _storeFolder(tab, value);
+ if (indicator) {
+ indicator.textContent = value ? '✅ saved' : '';
+ }
+ });
+ }
+}
+
+function toggleBrowser(tab) {
+ const browser = document.getElementById(_bp(tab) + 'Browser');
+ if (browser.classList.contains('open')) {
+ browser.classList.remove('open');
+ } else {
+ const input = document.getElementById(_bp(tab) + 'Folder');
+ browseTo(input.value.trim() || '~', tab);
+ }
+}
+
+async function openNativeFolderExplorer(tab) {
+ const input = document.getElementById(_bp(tab) + 'Folder');
+ const initialDir = input?.value.trim() || lastSelectedFolder || '~';
+
+ try {
+ const resp = await fetch('/api/browse/native', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ initial_dir: initialDir }),
+ });
+ const data = await resp.json();
+
+ if (data.folder) {
+ if (input) {
+ input.value = data.folder;
+ _storeFolder(tab, data.folder);
+ }
+ showToast('Folder selected: ' + data.folder, 'success');
+ } else {
+ showToast(data.message || 'Native folder dialog was canceled', 'info');
+ }
+ } catch (e) {
+ showToast('Native folder open failed: ' + e.message, 'error');
+ }
+}
+
+function closeBrowser(tab) {
+ document.getElementById(_bp(tab) + 'Browser').classList.remove('open');
+}
+
+function selectBrowserFolder(tab) {
+ closeBrowser(tab);
+ showToast('Folder selected', 'success');
+}
+
+async function browseTo(path, tab) {
+ try {
+ const resp = await fetch(`/api/browse?path=${encodeURIComponent(path)}`);
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+
+ const prefix = _bp(tab);
+ const input = document.getElementById(prefix + 'Folder');
+ input.value = data.current;
+ _storeFolder(tab, data.current);
+
+ const browser = document.getElementById(prefix + 'Browser');
+ const fbPath = browser.querySelector('.fb-path');
+ fbPath.textContent = data.current;
+
+ const parentEl = browser.querySelector('.fb-parent');
+ if (data.parent) {
+ parentEl.style.display = 'flex';
+ parentEl.onclick = () => browseTo(data.parent, tab);
+ } else {
+ parentEl.style.display = 'none';
+ }
+
+ const entriesEl = browser.querySelector('.fb-entries');
+ entriesEl.innerHTML = '';
+ for (const entry of data.entries) {
+ const div = document.createElement('div');
+ div.className = 'fb-item';
+ div.innerHTML = `📁 ${escapeHtml(entry.name)}`;
+ div.onclick = () => browseTo(entry.path, tab);
+ entriesEl.appendChild(div);
+ }
+
+ browser.classList.add('open');
+ } catch (e) {
+ showToast('Failed to browse: ' + e.message, 'error');
+ }
+}
+
+// =====================================================================
+// Converter: Scan
+// =====================================================================
+
+async function convScan() {
+ const folder = document.getElementById('convFolder').value.trim();
+ if (!folder) { showToast('Enter a folder path', 'error'); return; }
+ _storeFolder('converter', folder);
+ convLastFailedFiles = new Set();
+
+ const includeSubfolders = document.getElementById('convSubfolders').checked;
+ const filterType = document.getElementById('convFilterType').value;
+
+ document.getElementById('convScanBtn').disabled = true;
+ document.getElementById('convResults').style.display = 'none';
+
+ try {
+ const resp = await fetch('/api/converter/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ folder, include_subfolders: includeSubfolders, filter_type: filterType,
+ }),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+
+ convScanData = data;
+ convFilterType = filterType;
+ convFilterExt = null;
+ convSelectedFiles = [];
+ renderConvResults();
+ } catch (e) {
+ showToast('Scan failed: ' + e.message, 'error');
+ } finally {
+ document.getElementById('convScanBtn').disabled = false;
+ }
+}
+
+function renderConvResults() {
+ if (!convScanData) return;
+ const section = document.getElementById('convResults');
+ section.style.display = 'block';
+
+ // Summary
+ const summary = document.getElementById('convSummary');
+ const entries = Object.entries(convScanData.summary);
+ if (entries.length === 0) {
+ summary.innerHTML = '
';
+ document.getElementById('convFileTable').innerHTML = '';
+ return;
+ }
+
+ let html = `${convScanData.total} files found in ${escapeHtml(convScanData.folder)}`;
+ html += '';
+ html += `All `;
+ for (const [ext, count] of entries) {
+ const isActive = convFilterExt === ext;
+ html += `${ext} (${count}) `;
+ }
+ html += '
';
+ summary.innerHTML = html;
+
+ // File table
+ let files = convScanData.files;
+ if (convFilterExt) {
+ files = files.filter(f => f.ext === convFilterExt);
+ }
+
+ const table = document.getElementById('convFileTable');
+ if (files.length === 0) {
+ table.innerHTML = 'No files match current filter.
';
+ return;
+ }
+
+ let thtml = `';
+ table.innerHTML = thtml;
+ convUpdateBatchTargets();
+}
+
+function convSetBatchTargets(targets) {
+ const select = document.getElementById('convBatchTarget');
+ const prevValue = select.value;
+
+ const targetArray = Array.isArray(targets) ? targets.filter(Boolean) : [];
+ const uniqueTargets = [...new Set(targetArray.map(t => t.toLowerCase()))].sort();
+
+ select.innerHTML = '';
+ if (uniqueTargets.length === 0) {
+ select.disabled = true;
+ const opt = document.createElement('option');
+ opt.value = '';
+ opt.textContent = 'No compatible target formats';
+ opt.disabled = true;
+ select.appendChild(opt);
+ return;
+ }
+
+ select.disabled = false;
+ for (const target of uniqueTargets) {
+ const option = document.createElement('option');
+ option.value = target;
+ option.textContent = target;
+ select.appendChild(option);
+ }
+
+ if (prevValue && uniqueTargets.includes(prevValue.toLowerCase())) {
+ select.value = prevValue.toLowerCase();
+ } else {
+ select.value = uniqueTargets[0];
+ }
+}
+
+function convGetBatchTargets() {
+ const selectedFilePaths = new Set(convSelectedFiles || []);
+ const modeSelect = document.getElementById('convBatchMode');
+ if (modeSelect) {
+ convBatchMode = modeSelect.value || 'union';
+ localStorage.setItem('convBatchMode', convBatchMode);
+ }
+
+ const av1CrfInput = document.getElementById('convAv1Crf');
+ if (av1CrfInput) {
+ const val = Number(av1CrfInput.value);
+ if (!Number.isNaN(val) && val >= 10 && val <= 63) {
+ convAv1Crf = val;
+ localStorage.setItem('convAv1Crf', convAv1Crf);
+ const av1CrfValue = document.getElementById('convAv1CrfValue');
+ if (av1CrfValue) {
+ av1CrfValue.textContent = String(convAv1Crf);
+ }
+ }
+ }
+
+ const modeHint = document.getElementById('convBatchModeHint');
+ if (modeHint) {
+ modeHint.textContent = convBatchMode === 'intersection'
+ ? 'Intersection = formats supported by each selected file; Union = any selected file'
+ : 'Union = formats supported by at least one selected file; Intersection = common to all';
+ }
+
+ // If no scan data yet, show global format list from converter/formats.
+ if (!convScanData || !convScanData.files || convScanData.files.length === 0) {
+ return [...new Set(convAvailableTargets)].sort();
+ }
+
+ const files = convScanData.files.filter(f => selectedFilePaths.size === 0 || selectedFilePaths.has(f.path));
+ if (files.length === 0) {
+ return [...new Set(convAvailableTargets)].sort();
+ }
+
+ const hasVideo = files.some(f => f.type === 'video');
+ const fileTargets = files.map(f => new Set((f.targets || []).map(t => t.toLowerCase())));
+
+ if (convBatchMode === 'intersection') {
+ let intersection = new Set(fileTargets[0]);
+ for (let i = 1; i < fileTargets.length; i++) {
+ intersection = new Set([...intersection].filter(t => fileTargets[i].has(t)));
+ }
+ if (hasVideo) {
+ ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => intersection.add(v));
+ }
+ return [...intersection].sort();
+ }
+
+ // union
+ const union = new Set();
+ for (const targetSet of fileTargets) {
+ for (const t of targetSet) union.add(t);
+ }
+ if (hasVideo) {
+ ['.mp4-av1', '.mkv-av1', '.webm-av1'].forEach(v => union.add(v));
+ }
+ return [...union].sort();
+}
+
+function convUpdateBatchTargets() {
+ const targets = convGetBatchTargets();
+ convSetBatchTargets(targets);
+
+ const batchBtn = document.getElementById('convBatchBtn');
+ if (batchBtn) {
+ batchBtn.disabled = !targets || targets.length === 0;
+ }
+}
+
+function convSetExtFilter(ext) {
+ convFilterExt = ext;
+ renderConvResults();
+}
+
+function convToggleAll(checked) {
+ document.querySelectorAll('.conv-check').forEach(cb => cb.checked = checked);
+ convUpdateSelection();
+}
+
+function convUpdateSelection() {
+ convSelectedFiles = [];
+ document.querySelectorAll('.conv-check:checked').forEach(cb => {
+ convSelectedFiles.push(cb.value);
+ });
+
+ const bar = document.getElementById('convBulkBar');
+ if (convSelectedFiles.length > 0) {
+ bar.style.display = 'flex';
+ document.getElementById('convBulkCount').textContent = convSelectedFiles.length;
+ } else {
+ bar.style.display = 'none';
+ }
+
+ convUpdateBatchTargets();
+}
+
+function toggleFullPaths() {
+ showFullPaths = !showFullPaths;
+ renderConvResults();
+}
+
+// =====================================================================
+// Converter: Convert
+// =====================================================================
+
+async function convConvertSingle(filePath, btnEl) {
+ const row = btnEl.closest('tr');
+ const targetSelect = row.querySelector('.conv-target');
+ const targetExt = targetSelect.value;
+ const deleteOrig = document.getElementById('convDeleteOrig').checked;
+
+ btnEl.disabled = true;
+ btnEl.textContent = '...';
+
+ try {
+ const resp = await fetch('/api/converter/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: [filePath],
+ target_ext: targetExt,
+ delete_original: deleteOrig,
+ av1_crf: convAv1Crf,
+ }),
+ });
+ const data = await resp.json();
+ if (data.job_id) {
+ await convWaitForJob(data.job_id);
+ }
+ } catch (e) {
+ showToast('Convert failed: ' + e.message, 'error');
+ } finally {
+ btnEl.disabled = false;
+ btnEl.textContent = 'Convert';
+ }
+}
+
+async function convConvertBatch() {
+ if (convSelectedFiles.length === 0) return;
+ const targetExt = document.getElementById('convBatchTarget').value;
+ if (!targetExt) {
+ showToast('No valid target format available for selected files', 'error');
+ return;
+ }
+ const deleteOrig = document.getElementById('convDeleteOrig').checked;
+
+ document.getElementById('convBatchBtn').disabled = true;
+
+ try {
+ const resp = await fetch('/api/converter/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ files: convSelectedFiles,
+ target_ext: targetExt,
+ delete_original: deleteOrig,
+ av1_crf: convAv1Crf,
+ }),
+ });
+ const data = await resp.json();
+ if (data.job_id) {
+ convShowProgress();
+ convPollProgress(data.job_id);
+ }
+ } catch (e) {
+ showToast('Batch convert failed: ' + e.message, 'error');
+ document.getElementById('convBatchBtn').disabled = false;
+ }
+}
+
+function convShowProgress() {
+ document.getElementById('convProgress').classList.add('active');
+}
+
+function convPollProgress(jobId) {
+ let lastCompleted = -1;
+ convPollTimer = setInterval(async () => {
+ try {
+ const resp = await fetch(`/api/converter/progress/${jobId}/poll?last=${lastCompleted}`);
+ const data = await resp.json();
+ if (data.error) return;
+
+ lastCompleted = data.completed;
+ const pct = data.total > 0 ? Math.round((data.completed / data.total) * 100) : 0;
+ document.getElementById('convProgressBar').style.width = pct + '%';
+ document.getElementById('convProgressPct').textContent = pct + '%';
+ document.getElementById('convProgressMsg').textContent =
+ data.current_file ? `Converting: ${data.current_file}` : 'Processing...';
+
+ if (data.status === 'done') {
+ clearInterval(convPollTimer);
+ convShowConversionResults(data);
+ document.getElementById('convProgress').classList.remove('active');
+ document.getElementById('convBatchBtn').disabled = false;
+ }
+ } catch (e) { /* retry */ }
+ }, 500);
+}
+
+async function convWaitForJob(jobId) {
+ // Simple wait for single-file conversions
+ while (true) {
+ const resp = await fetch(`/api/converter/progress/${jobId}`);
+ const data = await resp.json();
+ if (data.status === 'done') {
+ const r = data.results[0];
+ if (r.status === 'ok') {
+ const sizeInfo = r.original_size_fmt + ' → ' + r.new_size_fmt;
+ showToast(`Converted! (${sizeInfo})`, 'success');
+ convLastFailedFiles.delete(r.source);
+ } else {
+ showToast('Error: ' + (r.error || 'unknown'), 'error');
+ convLastFailedFiles.add(r.source);
+ }
+ renderConvResults();
+ return;
+ }
+ await sleep(300);
+ }
+}
+
+async function convRetrySingle(filePath, btnEl) {
+ const row = btnEl.closest('tr');
+ const targetSelect = row.querySelector('.conv-target');
+ const targetExt = targetSelect?.value;
+ const deleteOrig = document.getElementById('convDeleteOrig').checked;
+
+ if (!targetExt) {
+ showToast('No target selected for retry', 'error');
+ return;
+ }
+
+ btnEl.disabled = true;
+ btnEl.textContent = 'Retrying...';
+
+ try {
+ const resp = await fetch('/api/converter/convert', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files: [filePath], target_ext: targetExt, delete_original: deleteOrig, av1_crf: convAv1Crf }),
+ });
+ const data = await resp.json();
+ if (data.job_id) {
+ await convWaitForJob(data.job_id);
+ }
+ } catch (e) {
+ showToast('Retry failed: ' + e.message, 'error');
+ } finally {
+ btnEl.disabled = false;
+ btnEl.textContent = 'Retry';
+ }
+}
+
+function convShowConversionResults(data) {
+ let ok = 0, fail = 0;
+ const failedFiles = [];
+ convLastFailedFiles = new Set();
+
+ for (const r of data.results) {
+ if (r.status === 'ok') {
+ ok++;
+ } else {
+ fail++;
+ failedFiles.push(`${r.source} (${r.error || 'unknown error'})`);
+ }
+ }
+
+ if (fail > 0) {
+ const feed = failedFiles.slice(0, 5).join(', ');
+ const rest = failedFiles.length > 5 ? `, +${failedFiles.length - 5} more` : '';
+ showToast(`Done! ${ok} converted, ${fail} failed: ${feed}${rest}`, 'error');
+ } else {
+ showToast(`Done! ${ok} converted`, 'success');
+ }
+
+ // Refresh list with failure markers
+ renderConvResults();
+}
+
+async function convDeleteSingle(filePath) {
+ if (!confirm('Delete this file permanently?')) return;
+ try {
+ const resp = await fetch('/api/converter/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files: [filePath] }),
+ });
+ const data = await resp.json();
+ const r = data.results[0];
+ if (r.status === 'deleted') {
+ showToast('Deleted', 'success');
+ // Remove from scan data
+ if (convScanData) {
+ convScanData.files = convScanData.files.filter(f => f.path !== filePath);
+ convScanData.total = convScanData.files.length;
+ }
+ renderConvResults();
+ } else {
+ showToast('Delete failed: ' + (r.status), 'error');
+ }
+ } catch (e) {
+ showToast('Delete failed: ' + e.message, 'error');
+ }
+}
+
+async function convDeleteBatch() {
+ if (convSelectedFiles.length === 0) return;
+ if (!confirm(`Permanently delete ${convSelectedFiles.length} file(s)?`)) return;
+ try {
+ const resp = await fetch('/api/converter/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files: convSelectedFiles }),
+ });
+ const data = await resp.json();
+ const deleted = data.results.filter(r => r.status === 'deleted');
+ const failed = data.results.filter(r => r.status !== 'deleted');
+
+ if (deleted.length > 0) {
+ showToast(`Deleted ${deleted.length} file(s), freed ${data.total_freed_formatted}`, 'success');
+ if (convScanData) {
+ const deletedPaths = new Set(deleted.map(r => r.path));
+ convScanData.files = convScanData.files.filter(f => !deletedPaths.has(f.path));
+ convScanData.total = convScanData.files.length;
+ // Recalculate summary
+ const newSummary = {};
+ for (const f of convScanData.files) {
+ newSummary[f.ext] = (newSummary[f.ext] || 0) + 1;
+ }
+ convScanData.summary = newSummary;
+ }
+ }
+ if (failed.length > 0) {
+ showToast(`Failed to delete ${failed.length} file(s)`, 'error');
+ }
+
+ convSelectedFiles = [];
+ renderConvResults();
+ } catch (e) {
+ showToast('Delete failed: ' + e.message, 'error');
+ }
+}
+
+function copyPath(path) {
+ navigator.clipboard.writeText(path).then(() => {
+ showToast('Path copied', 'success');
+ });
+}
+
+// =====================================================================
+// Dupfinder: Scan
+// =====================================================================
+
+async function dupStartScan() {
+ const folder = document.getElementById('dupFolder').value.trim();
+ if (!folder) { showToast('Enter a folder path', 'error'); return; }
+ _storeFolder('dupfinder', folder);
+
+ const scanType = document.getElementById('dupScanType').value;
+ const imgThreshold = parseInt(document.getElementById('dupImgThreshold').value) / 100;
+ const vidThreshold = parseInt(document.getElementById('dupVidThreshold').value) / 100;
+
+ document.getElementById('dupScanBtn').disabled = true;
+ document.getElementById('dupResults').classList.remove('active');
+ document.getElementById('dupProgress').classList.add('active');
+ document.getElementById('dupProgressBar').style.width = '0%';
+ document.getElementById('dupProgressPct').textContent = '0%';
+ document.getElementById('dupProgressMsg').textContent = 'Starting scan...';
+
+ try {
+ const resp = await fetch('/api/dupfinder/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ folder, type: scanType,
+ image_threshold: imgThreshold,
+ video_threshold: vidThreshold,
+ }),
+ });
+ const data = await resp.json();
+ if (data.error) {
+ showToast(data.error, 'error');
+ dupResetUI();
+ return;
+ }
+ dupJobId = data.job_id;
+ dupPollProgress();
+ } catch (e) {
+ showToast('Scan failed: ' + e.message, 'error');
+ dupResetUI();
+ }
+}
+
+function dupPollProgress() {
+ if (!dupJobId) return;
+ dupPollTimer = setInterval(async () => {
+ try {
+ const resp = await fetch(`/api/dupfinder/scan/${dupJobId}/status`);
+ const data = await resp.json();
+
+ const pct = Math.round(data.progress * 100);
+ document.getElementById('dupProgressBar').style.width = pct + '%';
+ document.getElementById('dupProgressPct').textContent = pct + '%';
+ document.getElementById('dupProgressMsg').textContent = data.message || '';
+ document.getElementById('dupProgressElapsed').textContent = formatDuration(data.elapsed_seconds || 0);
+
+ if (data.status === 'done') {
+ clearInterval(dupPollTimer);
+ await dupLoadResults();
+ } else if (data.status === 'error') {
+ clearInterval(dupPollTimer);
+ showToast('Scan failed: ' + (data.error || 'Unknown'), 'error');
+ dupResetUI();
+ }
+ } catch (e) { /* retry */ }
+ }, 500);
+}
+
+async function dupLoadResults() {
+ try {
+ const resp = await fetch(`/api/dupfinder/scan/${dupJobId}/results`);
+ const data = await resp.json();
+
+ dupAllGroups = [];
+ let idx = 0;
+ for (const g of (data.image_groups || [])) dupAllGroups.push({ index: idx++, type: 'image', items: g });
+ for (const g of (data.video_groups || [])) dupAllGroups.push({ index: idx++, type: 'video', items: g });
+
+ dupRenderResults(data);
+ document.getElementById('dupProgress').classList.remove('active');
+ document.getElementById('dupResults').classList.add('active');
+ dupResetUI();
+
+ document.getElementById('dupNoResults').style.display =
+ dupAllGroups.length === 0 ? 'block' : 'none';
+ } catch (e) {
+ showToast('Failed to load results', 'error');
+ dupResetUI();
+ }
+}
+
+function dupResetUI() {
+ document.getElementById('dupScanBtn').disabled = false;
+}
+
+// =====================================================================
+// Dupfinder: Render
+// =====================================================================
+
+function dupRenderResults(data) {
+ const totalGroups = dupAllGroups.length;
+ const totalDups = dupAllGroups.reduce((s, g) => s + g.items.length - 1, 0);
+
+ document.getElementById('dupSummary').innerHTML = `
+ ${totalGroups}
Duplicate Groups
+ ${totalDups}
Duplicate Files
+ ${data.space_savings_formatted}
Potential Savings
+ `;
+
+ document.getElementById('dupTitle').textContent =
+ `${totalGroups} Duplicate Group${totalGroups !== 1 ? 's' : ''} Found`;
+
+ const container = document.getElementById('dupGroups');
+ container.innerHTML = '';
+ for (const group of dupAllGroups) {
+ container.appendChild(dupCreateGroupCard(group));
+ }
+}
+
+function dupCreateGroupCard(group) {
+ const div = document.createElement('div');
+ div.className = 'group-card';
+ div.dataset.groupIndex = group.index;
+
+ const typeLabel = group.type === 'image' ? 'Image' : 'Video';
+ const badgeClass = group.type === 'video' ? 'group-badge video' : 'group-badge';
+
+ div.innerHTML = `
+
+
+
+ ${group.items.map((item, i) => dupCreateFileCard(item, i === 0, group.index)).join('')}
+
+
+ `;
+ return div;
+}
+
+function dupCreateFileCard(item, isBest) {
+ const isSelected = dupSelectedFiles.has(item.path);
+ const selClass = isSelected ? 'selected-for-delete' : (isBest ? 'is-best' : '');
+
+ let thumb;
+ if (item.type === 'video') {
+ thumb = ` `;
+ } else {
+ thumb = ` `;
+ }
+
+ const badges = [];
+ if (isBest) badges.push(`★ BEST `);
+ badges.push(`${item.similarity}% `);
+
+ const meta = [];
+ meta.push(item.resolution);
+ meta.push(item.file_size_formatted);
+ if (item.duration_formatted) meta.push(item.duration_formatted);
+ if (item.format) meta.push(item.format);
+
+ return `
+
+
+ ${thumb}
+
${badges.join('')}
+
Click to preview
+
+
+
${escapeHtml(item.filename)}
+
${escapeHtml(item.directory)}
+
${meta.join(' · ')}
+
+
+ ${isBest
+ ? `
+ ${isSelected ? '↩ Unselect (★ best)' : '★ Recommended to keep'}
+ `
+ : `
+ ${isSelected ? '↩ Unselect' : '🗑 Select to delete'}
+ `
+ }
+
+
+ `;
+}
+
+// =====================================================================
+// Dupfinder: Selection & Deletion
+// =====================================================================
+
+function dupToggleGroup(index) {
+ const body = document.getElementById(`dupGroupBody${index}`);
+ body.classList.toggle('collapsed');
+ const icon = body.closest('.group-card').querySelector('.toggle-icon');
+ icon.textContent = body.classList.contains('collapsed') ? '▶' : '▼';
+}
+
+function dupExpandAll() {
+ document.querySelectorAll('#dupGroups .group-body').forEach(b => b.classList.remove('collapsed'));
+ document.querySelectorAll('#dupGroups .toggle-icon').forEach(i => i.textContent = '▼');
+}
+
+function dupCollapseAll() {
+ document.querySelectorAll('#dupGroups .group-body').forEach(b => b.classList.add('collapsed'));
+ document.querySelectorAll('#dupGroups .toggle-icon').forEach(i => i.textContent = '▶');
+}
+
+function dupToggleFile(path) {
+ if (dupSelectedFiles.has(path)) dupSelectedFiles.delete(path);
+ else dupSelectedFiles.add(path);
+ dupUpdateSelectionUI();
+}
+
+function dupAutoSelect() {
+ dupSelectedFiles.clear();
+ for (const g of dupAllGroups) {
+ for (let i = 1; i < g.items.length; i++) {
+ dupSelectedFiles.add(g.items[i].path);
+ }
+ }
+ dupUpdateSelectionUI();
+ showToast(`Selected ${dupSelectedFiles.size} duplicate(s)`, 'success');
+}
+
+// Initialize converter formats for batch target dropdown
+convLoadFormats();
+loadFolderPreferences();
+
+function dupClearSelection() {
+ dupSelectedFiles.clear();
+ dupUpdateSelectionUI();
+}
+
+function dupUpdateSelectionUI() {
+ document.querySelectorAll('#dupGroups .file-card').forEach(card => {
+ const path = card.dataset.path;
+ const isSelected = dupSelectedFiles.has(path);
+ card.classList.toggle('selected-for-delete', isSelected);
+ card.classList.toggle('is-best', !isSelected && !!card.querySelector('.best-badge'));
+
+ const btn = card.querySelector('.file-actions button');
+ if (btn) {
+ const hasBest = !!card.querySelector('.best-badge');
+ if (isSelected) {
+ btn.className = 'btn btn-ghost btn-sm';
+ btn.style.flex = '1';
+ btn.style.borderColor = hasBest ? 'var(--danger)' : '';
+ btn.innerHTML = hasBest ? '↩ Unselect (★ best)' : '↩ Unselect';
+ } else if (hasBest) {
+ btn.className = 'btn btn-ghost btn-sm';
+ btn.style.flex = '1';
+ btn.style.borderColor = 'var(--success)';
+ btn.innerHTML = '★ Recommended to keep';
+ } else {
+ btn.className = 'btn btn-danger btn-sm';
+ btn.style.flex = '1';
+ btn.style.borderColor = '';
+ btn.innerHTML = '🗑 Select to delete';
+ }
+ }
+ });
+
+ const bulkBar = document.getElementById('dupBulkBar');
+ if (dupSelectedFiles.size > 0) {
+ bulkBar.classList.add('active');
+ document.getElementById('dupBulkCount').textContent = dupSelectedFiles.size;
+ let totalSize = 0;
+ document.querySelectorAll('#dupGroups .file-card').forEach(card => {
+ if (dupSelectedFiles.has(card.dataset.path)) {
+ totalSize += parseInt(card.dataset.size) || 0;
+ }
+ });
+ document.getElementById('dupBulkSize').textContent = formatBytes(totalSize);
+ } else {
+ bulkBar.classList.remove('active');
+ }
+}
+
+function dupConfirmDelete() {
+ if (dupSelectedFiles.size === 0) return;
+ const fileList = document.getElementById('dupDeleteFileList');
+ fileList.innerHTML = '';
+ for (const path of dupSelectedFiles) {
+ const div = document.createElement('div');
+ div.textContent = path;
+ fileList.appendChild(div);
+ }
+ document.getElementById('dupDeleteModal').classList.add('active');
+}
+
+function dupCloseModal() {
+ document.getElementById('dupDeleteModal').classList.remove('active');
+}
+
+async function dupExecuteDelete() {
+ const files = Array.from(dupSelectedFiles);
+ const btn = document.getElementById('dupConfirmDeleteBtn');
+ btn.disabled = true;
+ btn.textContent = 'Deleting...';
+
+ try {
+ const resp = await fetch('/api/dupfinder/delete', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files }),
+ });
+ const data = await resp.json();
+ const deleted = data.results.filter(r => r.status === 'deleted');
+ const failed = data.results.filter(r => r.status !== 'deleted');
+
+ if (deleted.length > 0) showToast(`Deleted ${deleted.length} file(s), freed ${data.total_freed_formatted}`, 'success');
+ if (failed.length > 0) showToast(`Failed to delete ${failed.length} file(s)`, 'error');
+
+ for (const r of deleted) {
+ dupSelectedFiles.delete(r.path);
+ const card = document.querySelector(`#dupGroups .file-card[data-path="${CSS.escape(r.path)}"]`);
+ if (card) card.remove();
+ for (const g of dupAllGroups) {
+ g.items = g.items.filter(item => item.path !== r.path);
+ }
+ }
+
+ dupAllGroups = dupAllGroups.filter(g => g.items.length > 1);
+ const container = document.getElementById('dupGroups');
+ container.innerHTML = '';
+ for (const g of dupAllGroups) container.appendChild(dupCreateGroupCard(g));
+ dupUpdateSelectionUI();
+
+ if (dupAllGroups.length === 0) {
+ document.getElementById('dupNoResults').style.display = 'block';
+ document.getElementById('dupTitle').textContent = 'All duplicates resolved!';
+ document.getElementById('dupSummary').innerHTML = '';
+ }
+ } catch (e) {
+ showToast('Delete failed: ' + e.message, 'error');
+ } finally {
+ dupCloseModal();
+ btn.disabled = false;
+ btn.textContent = '🗑️ Delete';
+ }
+}
+
+// =====================================================================
+// Inspector Tab
+// =====================================================================
+
+async function inspStartScan() {
+ const folder = document.getElementById('inspFolder').value.trim();
+ if (!folder) { showToast('Enter a folder path', 'error'); return; }
+ _storeFolder('inspector', folder);
+ const mode = document.getElementById('inspMode').value;
+
+ document.getElementById('inspScanBtn').disabled = true;
+ document.getElementById('inspResults').style.display = 'none';
+ document.getElementById('inspProgress').style.display = 'block';
+ document.getElementById('inspProgressBar').style.width = '0%';
+ document.getElementById('inspProgressPct').textContent = '0%';
+ document.getElementById('inspProgressMsg').textContent = 'Starting...';
+
+ try {
+ const resp = await fetch('/api/inspector/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ folder, mode }),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+ inspJobId = data.job_id;
+ inspPollTimer = setInterval(inspPollProgress, 800);
+ } catch (e) {
+ showToast('Scan failed: ' + e.message, 'error');
+ document.getElementById('inspProgress').style.display = 'none';
+ } finally {
+ document.getElementById('inspScanBtn').disabled = false;
+ }
+}
+
+async function inspPollProgress() {
+ try {
+ const resp = await fetch(`/api/inspector/scan/${inspJobId}/status`);
+ const data = await resp.json();
+ const total = data.total_files || 0;
+ const processed = data.processed_files || 0;
+ const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
+ document.getElementById('inspProgressBar').style.width = pct + '%';
+ document.getElementById('inspProgressPct').textContent = pct + '%';
+ document.getElementById('inspProgressMsg').textContent = `${processed} / ${total} files`;
+
+ if (data.status === 'done' || data.status === 'error') {
+ clearInterval(inspPollTimer);
+ document.getElementById('inspProgress').style.display = 'none';
+ if (data.status === 'error') {
+ showToast('Scan error: ' + (data.error || 'Unknown'), 'error');
+ } else {
+ inspLoadResults();
+ }
+ }
+ } catch (e) {
+ clearInterval(inspPollTimer);
+ showToast('Poll error: ' + e.message, 'error');
+ }
+}
+
+async function inspLoadResults() {
+ try {
+ const resp = await fetch(`/api/inspector/scan/${inspJobId}/results`);
+ const data = await resp.json();
+ const container = document.getElementById('inspResultsContent');
+ const section = document.getElementById('inspResults');
+ section.style.display = 'block';
+
+ if (!data.results || data.results.length === 0) {
+ container.innerHTML = '';
+ return;
+ }
+
+ const mode = document.getElementById('inspMode').value;
+ if (mode === 'exif') {
+ inspRenderExif(data.results, container);
+ } else {
+ inspRenderIntegrity(data.results, container);
+ }
+ } catch (e) {
+ showToast('Failed to load results: ' + e.message, 'error');
+ }
+}
+
+function inspRenderExif(results, container) {
+ let html = `📊 EXIF Results — ${results.length} file(s)
`;
+ for (const item of results) {
+ const fname = item.file.split('/').pop();
+ const exif = item.exif || {};
+ const keys = Object.keys(exif).filter(k => !k.startsWith('_'));
+ html += `
+
+ 📄 ${escapeHtml(fname)} ${keys.length} tags
+
+
+
+ 🧹 Strip EXIF
+
+
`;
+ for (const k of keys) {
+ const val = typeof exif[k] === 'object' ? JSON.stringify(exif[k]) : String(exif[k]);
+ html += `${escapeHtml(k)} ${escapeHtml(val.substring(0, 200))} `;
+ }
+ if (exif._gps_lat && exif._gps_lng) {
+ html += `📍 GPS ${exif._gps_lat.toFixed(6)}, ${exif._gps_lng.toFixed(6)} `;
+ }
+ html += `
`;
+ }
+ container.innerHTML = html;
+}
+
+function inspRenderIntegrity(results, container) {
+ let ok = 0, bad = 0;
+ for (const r of results) { if (r.ok) ok++; else bad++; }
+
+ let html = ``;
+
+ if (bad > 0) {
+ html += '⚠️ Failed Files
';
+ for (const r of results.filter(x => !x.ok)) {
+ html += `
${escapeHtml(r.file.split('/').pop())} ${escapeHtml(r.error || 'Unknown error')}
`;
+ }
+ html += '
';
+ }
+ container.innerHTML = html;
+}
+
+async function inspStripOne(filePath) {
+ try {
+ const resp = await fetch('/api/inspector/exif/strip', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ files: [filePath] }),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+ showToast('EXIF stripped from 1 file', 'success');
+ } catch (e) {
+ showToast('Strip failed: ' + e.message, 'error');
+ }
+}
+
+// =====================================================================
+// Resizer Tab
+// =====================================================================
+
+async function resStartResize() {
+ const folder = document.getElementById('resFolder').value.trim();
+ if (!folder) { showToast('Enter a folder path', 'error'); return; }
+ _storeFolder('resizer', folder);
+
+ const width = parseInt(document.getElementById('resWidth').value) || 1920;
+ const height = parseInt(document.getElementById('resHeight').value) || 1080;
+ const mode = document.getElementById('resMode').value;
+ const quality = parseInt(document.getElementById('resQuality').value) || 90;
+ const bgColor = document.getElementById('resBgColor').value;
+ const outputFolder = document.getElementById('resOutput').value.trim() || null;
+
+ document.getElementById('resScanBtn').disabled = true;
+ document.getElementById('resResults').style.display = 'none';
+ document.getElementById('resProgress').style.display = 'block';
+ document.getElementById('resProgressBar').style.width = '0%';
+ document.getElementById('resProgressPct').textContent = '0%';
+ document.getElementById('resProgressMsg').textContent = 'Starting...';
+
+ try {
+ const resp = await fetch('/api/resizer/scan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ folder, width, height, mode, quality, bg_color: bgColor, output_folder: outputFolder }),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+ resJobId = data.job_id;
+ resPollTimer = setInterval(resPollProgress, 800);
+ } catch (e) {
+ showToast('Resize failed: ' + e.message, 'error');
+ document.getElementById('resProgress').style.display = 'none';
+ } finally {
+ document.getElementById('resScanBtn').disabled = false;
+ }
+}
+
+async function resPollProgress() {
+ try {
+ const resp = await fetch(`/api/resizer/scan/${resJobId}/status`);
+ const data = await resp.json();
+ const total = data.total_files || 0;
+ const processed = data.processed_files || 0;
+ const pct = total > 0 ? Math.round((processed / total) * 100) : 0;
+ document.getElementById('resProgressBar').style.width = pct + '%';
+ document.getElementById('resProgressPct').textContent = pct + '%';
+ document.getElementById('resProgressMsg').textContent = `${processed} / ${total} images`;
+
+ if (data.status === 'done' || data.status === 'error') {
+ clearInterval(resPollTimer);
+ document.getElementById('resProgress').style.display = 'none';
+ if (data.status === 'error') {
+ showToast('Resize error: ' + (data.error || 'Unknown'), 'error');
+ } else {
+ resLoadResults();
+ }
+ }
+ } catch (e) {
+ clearInterval(resPollTimer);
+ showToast('Poll error: ' + e.message, 'error');
+ }
+}
+
+async function resLoadResults() {
+ try {
+ const resp = await fetch(`/api/resizer/scan/${resJobId}/results`);
+ const data = await resp.json();
+ const container = document.getElementById('resResultsContent');
+ const section = document.getElementById('resResults');
+ section.style.display = 'block';
+
+ const results = data.results || [];
+ let ok = 0, errors = 0;
+ for (const r of results) { if (r.status === 'ok') ok++; else errors++; }
+
+ let html = ``;
+
+ if (errors > 0) {
+ html += '⚠️ Errors
';
+ for (const r of results.filter(x => x.status !== 'ok')) {
+ html += `
${escapeHtml(r.input.split('/').pop())} ${escapeHtml(r.error || 'Unknown')}
`;
+ }
+ html += '
';
+ }
+ container.innerHTML = html;
+ } catch (e) {
+ showToast('Failed to load results: ' + e.message, 'error');
+ }
+}
+
+// =====================================================================
+// Organizer Tab
+// =====================================================================
+
+function orgModeChanged() {
+ const mode = document.getElementById('orgMode').value;
+ document.getElementById('orgSortOptions').style.display = mode === 'sort' ? 'block' : 'none';
+ document.getElementById('orgRenameOptions').style.display = mode === 'rename' ? 'block' : 'none';
+ document.getElementById('orgExecBtn').style.display = 'none';
+ document.getElementById('orgPlanResults').style.display = 'none';
+ document.getElementById('orgExecResults').style.display = 'none';
+}
+
+async function orgStartPlan() {
+ const folder = document.getElementById('orgFolder').value.trim();
+ if (!folder) { showToast('Enter a folder path', 'error'); return; }
+ _storeFolder('organizer', folder);
+
+ const mode = document.getElementById('orgMode').value;
+ const operation = document.getElementById('orgOperation').value;
+
+ let body = { folder, mode, operation };
+ if (mode === 'sort') {
+ body.template = document.getElementById('orgTemplate').value.trim() || '{year}/{month}';
+ body.destination = document.getElementById('orgDest').value.trim() || null;
+ } else {
+ body.template = document.getElementById('orgRenameTemplate').value.trim() || '{date}_{seq:4}';
+ body.start_seq = parseInt(document.getElementById('orgStartSeq').value) || 1;
+ }
+
+ document.getElementById('orgPlanBtn').disabled = true;
+ document.getElementById('orgExecBtn').style.display = 'none';
+ document.getElementById('orgPlanResults').style.display = 'none';
+ document.getElementById('orgExecResults').style.display = 'none';
+ document.getElementById('orgProgress').style.display = 'block';
+ document.getElementById('orgProgressBar').style.width = '0%';
+ document.getElementById('orgProgressMsg').textContent = 'Planning...';
+
+ try {
+ const resp = await fetch('/api/organizer/plan', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+ orgJobId = data.job_id;
+ orgPollTimer = setInterval(orgPollStatus, 600);
+ } catch (e) {
+ showToast('Plan failed: ' + e.message, 'error');
+ document.getElementById('orgProgress').style.display = 'none';
+ } finally {
+ document.getElementById('orgPlanBtn').disabled = false;
+ }
+}
+
+async function orgPollStatus() {
+ try {
+ const resp = await fetch(`/api/organizer/status/${orgJobId}`);
+ const data = await resp.json();
+
+ const pct = Math.round((data.progress || 0) * 100);
+ document.getElementById('orgProgressBar').style.width = pct + '%';
+ document.getElementById('orgProgressPct').textContent = pct + '%';
+
+ if (data.phase === 'planned') {
+ clearInterval(orgPollTimer);
+ document.getElementById('orgProgress').style.display = 'none';
+ orgRenderPlan(data);
+ } else if (data.phase === 'done') {
+ clearInterval(orgPollTimer);
+ document.getElementById('orgProgress').style.display = 'none';
+ orgRenderExecResults(data);
+ } else if (data.phase === 'error' || data.status === 'error') {
+ clearInterval(orgPollTimer);
+ document.getElementById('orgProgress').style.display = 'none';
+ showToast('Error: ' + (data.error || 'Unknown'), 'error');
+ } else {
+ document.getElementById('orgProgressMsg').textContent =
+ data.phase === 'executing' ? `Executing...` : `Planning...`;
+ }
+ } catch (e) {
+ clearInterval(orgPollTimer);
+ showToast('Poll error: ' + e.message, 'error');
+ }
+}
+
+function orgRenderPlan(data) {
+ const content = document.getElementById('orgPlanContent');
+ const plan = data.plan || [];
+ const conflicts = data.conflicts || 0;
+ const planCount = data.plan_count || plan.length;
+
+ let html = `📋 Plan Preview — ${planCount} file(s)
`;
+ if (conflicts > 0) {
+ html += `⚠️ ${conflicts} conflict(s) detected — those will be skipped.
`;
+ }
+
+ html += 'Source → Destination ';
+ for (const item of plan.slice(0, 200)) {
+ const srcName = item.source.split('/').pop();
+ const dstName = item.destination.split('/').pop();
+ const conflict = item.conflict ? ' style="color:var(--danger);"' : '';
+ html += `${escapeHtml(srcName)} → ${escapeHtml(dstName)} `;
+ }
+ if (plan.length > 200) html += `… and ${plan.length - 200} more `;
+ html += '
';
+
+ content.innerHTML = html;
+ document.getElementById('orgPlanResults').style.display = 'block';
+ document.getElementById('orgExecBtn').style.display = 'inline-flex';
+}
+
+async function orgExecute() {
+ if (!orgJobId) { showToast('No plan to execute', 'error'); return; }
+
+ document.getElementById('orgExecBtn').disabled = true;
+ document.getElementById('orgProgress').style.display = 'block';
+ document.getElementById('orgProgressMsg').textContent = 'Executing...';
+ document.getElementById('orgProgressBar').style.width = '0%';
+
+ try {
+ const resp = await fetch('/api/organizer/execute', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ job_id: orgJobId }),
+ });
+ const data = await resp.json();
+ if (data.error) { showToast(data.error, 'error'); return; }
+ orgPollTimer = setInterval(orgPollStatus, 600);
+ } catch (e) {
+ showToast('Execute failed: ' + e.message, 'error');
+ document.getElementById('orgProgress').style.display = 'none';
+ } finally {
+ document.getElementById('orgExecBtn').disabled = false;
+ }
+}
+
+function orgRenderExecResults(data) {
+ const container = document.getElementById('orgExecContent');
+ const r = data.execution || {};
+ const completed = r.completed || 0;
+ const errList = r.errors || [];
+ const errors = typeof errList === 'number' ? errList : errList.length;
+ const skipped = r.skipped || 0;
+
+ let html = ``;
+
+ if (errors > 0) {
+ html += '⚠️ Errors
';
+ for (const e of (r.errors || [])) {
+ html += `
${escapeHtml(e.file || 'unknown')} ${escapeHtml(e.error || '')}
`;
+ }
+ html += '
';
+ }
+
+ container.innerHTML = html;
+ document.getElementById('orgExecResults').style.display = 'block';
+ document.getElementById('orgPlanResults').style.display = 'none';
+ document.getElementById('orgExecBtn').style.display = 'none';
+ showToast(`Done: ${completed} file(s) processed`, 'success');
+}
+
+// =====================================================================
+// Shared: Preview
+// =====================================================================
+
+function openPreview(path, type) {
+ const content = document.getElementById('previewContent');
+ if (type === 'video') {
+ content.innerHTML = ` `;
+ } else {
+ content.innerHTML = ` `;
+ }
+ document.getElementById('previewOverlay').classList.add('active');
+}
+
+function closePreview() {
+ const overlay = document.getElementById('previewOverlay');
+ overlay.classList.remove('active');
+ const video = overlay.querySelector('video');
+ if (video) video.pause();
+ document.getElementById('previewContent').innerHTML = '';
+}
+
+document.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { closePreview(); dupCloseModal(); }
+});
+
+// =====================================================================
+// Utilities
+// =====================================================================
+
+function escapeHtml(str) {
+ const d = document.createElement('div');
+ d.textContent = str;
+ return d.innerHTML;
+}
+
+function escapeAttr(str) {
+ return str.replace(/'/g, "\\'").replace(/"/g, '"');
+}
+
+function formatBytes(bytes) {
+ if (bytes === 0) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return (bytes / Math.pow(1024, i)).toFixed(2) + ' ' + units[i];
+}
+
+function formatDuration(seconds) {
+ if (seconds < 60) return Math.round(seconds) + 's';
+ const m = Math.floor(seconds / 60);
+ const s = Math.round(seconds % 60);
+ return `${m}m ${s}s`;
+}
+
+function showToast(message, type = 'success') {
+ const container = document.getElementById('toastContainer');
+ const toast = document.createElement('div');
+ toast.className = `toast ${type}`;
+ toast.textContent = message;
+ container.appendChild(toast);
+ setTimeout(() => {
+ toast.style.opacity = '0';
+ toast.style.transition = 'opacity 0.3s';
+ setTimeout(() => toast.remove(), 300);
+ }, 4000);
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
diff --git a/go/web/static/style.css b/go/web/static/style.css
new file mode 100644
index 0000000..39ecaac
--- /dev/null
+++ b/go/web/static/style.css
@@ -0,0 +1,824 @@
+/* ── Morphic – Shared Dark Theme ──────────────────────────────────── */
+
+:root {
+ --bg: #0f1117;
+ --surface: #1a1d27;
+ --surface2: #242835;
+ --border: #2e3345;
+ --text: #e1e4ed;
+ --text-dim: #8b8fa3;
+ --accent: #6c7bf7;
+ --accent-hover: #8590ff;
+ --danger: #f75c6c;
+ --danger-hover: #ff7a87;
+ --success: #4ade80;
+ --warning: #fbbf24;
+ --radius: 10px;
+ --shadow: 0 2px 12px rgba(0,0,0,0.3);
+}
+
+* { margin: 0; padding: 0; box-sizing: border-box; }
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+/* ── Header ──────────────────────────────────────────────────────── */
+
+.app-header {
+ background: var(--surface);
+ border-bottom: 1px solid var(--border);
+ padding: 0 24px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ height: 56px;
+}
+
+.app-header h1 {
+ font-size: 20px;
+ font-weight: 700;
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ margin-right: auto;
+}
+
+/* ── Tabs ─────────────────────────────────────────────────────────── */
+
+.tab-bar {
+ display: flex;
+ gap: 0;
+ height: 100%;
+}
+
+.tab-btn {
+ background: none;
+ border: none;
+ color: var(--text-dim);
+ font-size: 14px;
+ font-weight: 500;
+ padding: 0 20px;
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: all 0.2s;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.tab-btn:hover {
+ color: var(--text);
+ background: var(--surface2);
+}
+
+.tab-btn.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* ── Container ───────────────────────────────────────────────────── */
+
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 24px;
+}
+
+/* ── Cards ────────────────────────────────────────────────────────── */
+
+.card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 24px;
+ margin-bottom: 20px;
+}
+
+.card-title {
+ font-size: 16px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.card-title .icon { font-size: 20px; }
+
+/* ── Form Controls ───────────────────────────────────────────────── */
+
+.form-row {
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+ min-width: 200px;
+}
+
+.form-group.narrow {
+ flex: 0 0 auto;
+ min-width: 140px;
+}
+
+label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-dim);
+}
+
+input[type="text"], select {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 10px 14px;
+ color: var(--text);
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+input[type="text"]:focus, select:focus {
+ border-color: var(--accent);
+}
+
+input[type="range"] {
+ width: 100%;
+ accent-color: var(--accent);
+}
+
+/* ── Checkboxes ──────────────────────────────────────────────────── */
+
+.checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ color: var(--text-dim);
+}
+
+.checkbox-label input[type="checkbox"] {
+ accent-color: var(--accent);
+}
+
+/* ── Buttons ─────────────────────────────────────────────────────── */
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 20px;
+ border-radius: 6px;
+ border: none;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+ white-space: nowrap;
+}
+
+.btn-primary { background: var(--accent); color: #fff; }
+.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
+
+.btn-danger { background: var(--danger); color: #fff; }
+.btn-danger:hover:not(:disabled) { background: var(--danger-hover); }
+
+.btn-ghost {
+ background: transparent;
+ color: var(--text-dim);
+ border: 1px solid var(--border);
+}
+.btn-ghost:hover { background: var(--surface2); color: var(--text); }
+
+.btn-sm { padding: 6px 12px; font-size: 13px; }
+
+.btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* ── Folder Browser ──────────────────────────────────────────────── */
+
+.folder-input-row {
+ display: flex;
+ gap: 8px;
+}
+
+.folder-input-row input { flex: 1; }
+
+.folder-browser {
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ max-height: 250px;
+ overflow-y: auto;
+ margin-top: 8px;
+ display: none;
+}
+
+.folder-browser.open { display: block; }
+
+.folder-browser .fb-header {
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--text-dim);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ background: var(--surface2);
+}
+
+.folder-browser .fb-item {
+ padding: 8px 12px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ transition: background 0.15s;
+}
+
+.folder-browser .fb-item:hover { background: var(--border); }
+.folder-browser .fb-item .icon { opacity: 0.6; }
+
+.folder-browser .fb-footer {
+ padding: 8px 12px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ position: sticky;
+ bottom: 0;
+ background: var(--surface2);
+}
+
+/* ── Progress ────────────────────────────────────────────────────── */
+
+.progress-section { display: none; }
+.progress-section.active { display: block; }
+
+.progress-bar-track {
+ width: 100%;
+ height: 6px;
+ background: var(--surface2);
+ border-radius: 3px;
+ overflow: hidden;
+ margin: 12px 0;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent), #a78bfa);
+ border-radius: 3px;
+ transition: width 0.3s ease;
+ width: 0%;
+}
+
+.progress-info {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+ color: var(--text-dim);
+}
+
+.progress-message {
+ font-size: 14px;
+ margin-top: 8px;
+}
+
+.spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid var(--border);
+ border-top: 2px solid var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ vertical-align: middle;
+ margin-right: 6px;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+/* ── Filter Pills ────────────────────────────────────────────────── */
+
+.filter-pills {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 12px;
+}
+
+.filter-pill {
+ padding: 4px 12px;
+ border-radius: 20px;
+ font-size: 12px;
+ font-weight: 500;
+ background: var(--surface2);
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.filter-pill:hover { color: var(--text); border-color: var(--text-dim); }
+.filter-pill.active { background: var(--accent); color: #fff; border-color: var(--accent); }
+
+/* ── File Table ──────────────────────────────────────────────────── */
+
+.file-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 12px;
+}
+
+.file-table th, .file-table td {
+ padding: 8px 12px;
+ text-align: left;
+ font-size: 13px;
+ border-bottom: 1px solid var(--border);
+}
+
+.file-table th {
+ font-weight: 500;
+ color: var(--text-dim);
+ background: var(--surface2);
+ position: sticky;
+ top: 0;
+}
+
+.file-table tr:hover td { background: var(--surface2); }
+
+.file-table .file-name {
+ max-width: 300px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ cursor: pointer;
+}
+
+..file-table .file-name:hover { color: var(--accent); }
+
+.file-table tr.failed-file td {
+ background: rgba(247, 92, 108, 0.12);
+}
+
+.failed-badge {
+ margin-left: 8px;
+ padding: 1px 6px;
+ border-radius: 999px;
+ font-size: 11px;
+ font-weight: 600;
+ background: rgba(247, 92, 108, 0.2);
+ color: var(--danger);
+}
+
+.file-thumb {
+ width: 40px;
+ height: 40px;
+ object-fit: cover;
+ border-radius: 4px;
+ background: #111;
+ cursor: pointer;
+}
+
+/* ── Converter Results ───────────────────────────────────────────── */
+
+.conv-result {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+}
+
+.conv-result .status-ok { color: var(--success); }
+.conv-result .status-err { color: var(--danger); }
+
+.conv-result .size-change {
+ font-size: 12px;
+ color: var(--text-dim);
+}
+
+/* ── Dupfinder Results ───────────────────────────────────────────── */
+
+.results-section { display: none; }
+.results-section.active { display: block; }
+
+.results-summary {
+ display: flex;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 20px;
+}
+
+.stat-card {
+ background: var(--surface2);
+ border-radius: 8px;
+ padding: 16px 20px;
+ flex: 1;
+ min-width: 150px;
+}
+
+.stat-card .stat-value {
+ font-size: 28px;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+.stat-card .stat-label {
+ font-size: 13px;
+ color: var(--text-dim);
+ margin-top: 4px;
+}
+
+.stat-card.savings .stat-value { color: var(--success); }
+
+/* ── Duplicate Groups ────────────────────────────────────────────── */
+
+.group-card {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: 16px;
+ overflow: hidden;
+}
+
+.group-header {
+ padding: 14px 20px;
+ background: var(--surface2);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+}
+
+.group-header:hover { background: var(--border); }
+
+.group-title {
+ font-weight: 600;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.group-badge {
+ background: var(--accent);
+ color: #fff;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.group-badge.video { background: #7c3aed; }
+
+.group-body { padding: 16px; }
+.group-body.collapsed { display: none; }
+
+.dup-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 16px;
+}
+
+/* ── File Card (Dupfinder) ───────────────────────────────────────── */
+
+.file-card {
+ background: var(--surface2);
+ border: 2px solid transparent;
+ border-radius: 8px;
+ overflow: hidden;
+ transition: border-color 0.2s, transform 0.15s;
+ position: relative;
+}
+
+.file-card.selected-for-delete { border-color: var(--danger); }
+.file-card.is-best { border-color: var(--success); }
+
+.file-card .thumb-container {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16/10;
+ overflow: hidden;
+ background: #111;
+ cursor: pointer;
+}
+
+.file-card .thumb-container img,
+.file-card .thumb-container video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+
+.file-card .thumb-overlay {
+ position: absolute;
+ top: 0; left: 0; right: 0; bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0,0,0,0.5);
+ opacity: 0;
+ transition: opacity 0.2s;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.file-card .thumb-container:hover .thumb-overlay { opacity: 1; }
+
+.file-card .badge-row {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ display: flex;
+ gap: 6px;
+}
+
+.file-card .sim-badge {
+ background: rgba(0,0,0,0.7);
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.file-card .best-badge {
+ background: var(--success);
+ color: #000;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.file-card .file-info { padding: 12px; }
+
+.file-card .file-name {
+ font-size: 13px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 4px;
+}
+
+.file-card .file-meta {
+ font-size: 12px;
+ color: var(--text-dim);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px 12px;
+}
+
+.file-card .file-actions {
+ padding: 0 12px 12px;
+ display: flex;
+ gap: 8px;
+}
+
+.file-card .file-dir {
+ font-size: 11px;
+ color: var(--text-dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 6px;
+ opacity: 0.7;
+}
+
+/* ── Bulk Bar ────────────────────────────────────────────────────── */
+
+.bulk-bar {
+ display: none;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: var(--surface);
+ border-top: 1px solid var(--border);
+ padding: 14px 24px;
+ z-index: 200;
+ box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
+}
+
+.bulk-bar.active {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.bulk-bar .bulk-info { font-size: 14px; }
+.bulk-bar .bulk-info strong { color: var(--danger); }
+.bulk-bar .bulk-actions { display: flex; gap: 10px; }
+
+/* ── Modal / Overlay ─────────────────────────────────────────────── */
+
+.modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.8);
+ z-index: 500;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-overlay.active { display: flex; }
+
+.modal {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ max-width: 600px;
+ width: 90%;
+ padding: 24px;
+}
+
+.modal h3 { margin-bottom: 12px; }
+
+.modal .file-list {
+ max-height: 200px;
+ overflow-y: auto;
+ background: var(--surface2);
+ border-radius: 6px;
+ padding: 8px 12px;
+ margin: 12px 0;
+ font-size: 13px;
+}
+
+.modal .file-list div {
+ padding: 4px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.modal .file-list div:last-child { border-bottom: none; }
+.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
+
+.preview-overlay {
+ display: none;
+ position: fixed;
+ top: 0; left: 0; right: 0; bottom: 0;
+ background: rgba(0,0,0,0.9);
+ z-index: 400;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.preview-overlay.active { display: flex; }
+.preview-overlay img, .preview-overlay video { max-width: 90vw; max-height: 90vh; border-radius: 8px; }
+
+.preview-close {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ font-size: 32px;
+ color: #fff;
+ cursor: pointer;
+ z-index: 401;
+ background: rgba(0,0,0,0.5);
+ border-radius: 50%;
+ width: 44px;
+ height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* ── Toast ────────────────────────────────────────────────────────── */
+
+.toast-container {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ z-index: 600;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.toast {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px 16px;
+ font-size: 14px;
+ box-shadow: var(--shadow);
+ animation: slideIn 0.3s ease;
+ max-width: 400px;
+}
+
+.toast.success { border-left: 3px solid var(--success); }
+.toast.error { border-left: 3px solid var(--danger); }
+
+@keyframes slideIn {
+ from { opacity: 0; transform: translateX(20px); }
+ to { opacity: 1; transform: translateX(0); }
+}
+
+/* ── Empty State ─────────────────────────────────────────────────── */
+
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: var(--text-dim);
+}
+
+.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
+.empty-state h3 { margin-bottom: 8px; color: var(--text); }
+
+/* ── Scrollbar ───────────────────────────────────────────────────── */
+
+::-webkit-scrollbar { width: 8px; }
+::-webkit-scrollbar-track { background: var(--surface); }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
+
+/* ── Inspector: EXIF cards ───────────────────────────────────────── */
+
+.exif-card .exif-details { display: none; margin-top: 12px; }
+.exif-card.expanded .exif-details { display: block; }
+
+.exif-table { width: 100%; border-collapse: collapse; font-size: 13px; }
+.exif-table th, .exif-table td {
+ padding: 6px 10px;
+ border-bottom: 1px solid var(--border);
+ text-align: left;
+}
+.exif-table .exif-key {
+ font-weight: 600;
+ color: var(--accent);
+ white-space: nowrap;
+ width: 200px;
+}
+
+/* ── Inspector: Integrity items ──────────────────────────────────── */
+
+.integrity-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border);
+ font-size: 13px;
+}
+.integrity-item.bad { background: rgba(255, 85, 85, 0.08); }
+.integrity-error { color: var(--danger); font-size: 12px; }
+
+/* ── Stat row (shared) ───────────────────────────────────────────── */
+
+.stat-row {
+ display: flex;
+ gap: 12px;
+ margin-top: 12px;
+ flex-wrap: wrap;
+}
+.stat-card {
+ flex: 1;
+ min-width: 100px;
+ text-align: center;
+ padding: 16px 12px;
+ background: var(--surface2);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+}
+.stat-value { font-size: 28px; font-weight: 700; }
+.stat-label { font-size: 12px; color: var(--text-dim); margin-top: 4px; }
+
+/* ── Responsive ──────────────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+ .form-row { flex-direction: column; }
+ .form-group.narrow { min-width: 100%; }
+ .container { padding: 12px; }
+ .dup-grid { grid-template-columns: 1fr; }
+ .tab-btn { padding: 0 12px; font-size: 13px; }
+ .stat-row { flex-direction: column; }
+}
diff --git a/go/web/templates/index.html b/go/web/templates/index.html
new file mode 100644
index 0000000..b885376
--- /dev/null
+++ b/go/web/templates/index.html
@@ -0,0 +1,531 @@
+
+
+
+
+
+ Morphic
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📁 Scan Folder
+
+
+
+
+
+
+ 🔍 Scan Folder
+ Toggle full paths
+
+
+
+
+
+
Converting...
+
+
+ 0%
+
+
Starting...
+
+
+
+
+
+
+
+
+ 0 file(s) selected
+
+
+
+ Convert to:
+
+ Loading available targets…
+
+
+
+ Show:
+
+ Common only (safer)
+ All compatible
+
+
+
+ AV1 CRF:
+
+ 32
+
+ Intersection = formats supported by each selected file; Union = any selected file
+ 🔄 Convert Selected
+ 🗑️ Delete Selected
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
⚙️ Scan Configuration
+
+
+
+
+
+
🔍 Start Scan
+
+
+
+
+
Scanning...
+
+
+ 0%
+ 0s
+
+
Initializing...
+
+
+
+
+
+
+
+
+
+ ⚡ Auto-select duplicates
+ ✕ Clear selection
+ ▼ Expand all
+ ▲ Collapse all
+
+
+
+
+
+
+
✅
+
No duplicates found!
+
Your media collection looks clean.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🏷️ Inspect Files
+
+
+
+
+
+
🔍 Start Scan
+
+
+
+
+
Scanning...
+
+
+ 0%
+
+
Initializing...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📐 Batch Resize
+
+
+
+
+
+
+
+
📐 Start Resize
+
+
+
+
+
Resizing...
+
+
+ 0%
+
+
Starting...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📂 Organize Files
+
+
+
+
+
+
+
+
+
+
+ 📋 Preview Plan
+ ▶️ Execute
+
+
+
+
+
+
Working...
+
+
+ 0%
+
+
Initializing...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0 file(s) selected for deletion
+ (0 B )
+
+
+ Cancel
+ 🗑️ Delete Selected
+
+
+
+
+
+
+
+
+
+
⚠️ Confirm Deletion
+
The following files will be permanently deleted :
+
+
This action cannot be undone.
+
+ Cancel
+ 🗑️ Delete
+
+
+
+
+
+
+
From 9e217cf13023875d6e75a8d9bb86ce79034d9354 Mon Sep 17 00:00:00 2001
From: exTerEX <30706568+exTerEX@users.noreply.github.com>
Date: Tue, 31 Mar 2026 18:18:20 +0200
Subject: [PATCH 02/16] feat: implement Inspector, Resizer, Converter,
Dupfinder modules (Phases 4-7)
- Inspector: EXIF read/strip via go-exif, integrity check via imaging + ffprobe
- Resizer: fit/fill/stretch/pad modes using disintegration/imaging
- Converter: image conversion via imaging, video via ffmpeg subprocess
- Dupfinder: CPU-only perceptual hashing via goimagehash, video frame extraction
- All modules use goroutine workers with semaphore concurrency control
- Added shared utilities: IsImage, IsVideo, NormaliseExt
- Registered all routes in server.go (inspector, resizer, converter, dupfinder)
- Added corona10/goimagehash v1.1.0 dependency
---
go/go.mod | 2 +
go/go.sum | 4 +
go/internal/converter/constants.go | 98 ++++++++++
go/internal/converter/converter.go | 186 +++++++++++++++++++
go/internal/converter/scanner.go | 130 ++++++++++++++
go/internal/dupfinder/images.go | 182 +++++++++++++++++++
go/internal/dupfinder/scanner.go | 280 +++++++++++++++++++++++++++++
go/internal/dupfinder/videos.go | 233 ++++++++++++++++++++++++
go/internal/inspector/exif.go | 173 ++++++++++++++++++
go/internal/inspector/integrity.go | 169 +++++++++++++++++
go/internal/inspector/scanner.go | 213 ++++++++++++++++++++++
go/internal/resizer/operations.go | 128 +++++++++++++
go/internal/resizer/scanner.go | 153 ++++++++++++++++
go/internal/shared/utils.go | 23 +++
go/web/routes_converter.go | 275 ++++++++++++++++++++++++++++
go/web/routes_dupfinder.go | 150 ++++++++++++++++
go/web/routes_inspector.go | 163 +++++++++++++++++
go/web/routes_resizer.go | 111 ++++++++++++
go/web/server.go | 4 +
19 files changed, 2677 insertions(+)
create mode 100644 go/internal/converter/constants.go
create mode 100644 go/internal/converter/converter.go
create mode 100644 go/internal/converter/scanner.go
create mode 100644 go/internal/dupfinder/images.go
create mode 100644 go/internal/dupfinder/scanner.go
create mode 100644 go/internal/dupfinder/videos.go
create mode 100644 go/internal/inspector/exif.go
create mode 100644 go/internal/inspector/integrity.go
create mode 100644 go/internal/inspector/scanner.go
create mode 100644 go/internal/resizer/operations.go
create mode 100644 go/internal/resizer/scanner.go
create mode 100644 go/web/routes_converter.go
create mode 100644 go/web/routes_dupfinder.go
create mode 100644 go/web/routes_inspector.go
create mode 100644 go/web/routes_resizer.go
diff --git a/go/go.mod b/go/go.mod
index d83f0ab..1eff467 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -3,6 +3,7 @@ module github.com/exterex/morphic
go 1.22
require (
+ github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/dsoprea/go-exif/v3 v3.0.1
github.com/gin-gonic/gin v1.10.0
@@ -30,6 +31,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
diff --git a/go/go.sum b/go/go.sum
index c2c7b19..d33a3d9 100644
--- a/go/go.sum
+++ b/go/go.sum
@@ -6,6 +6,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
+github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -75,6 +77,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
+github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
diff --git a/go/internal/converter/constants.go b/go/internal/converter/constants.go
new file mode 100644
index 0000000..4827f9a
--- /dev/null
+++ b/go/internal/converter/constants.go
@@ -0,0 +1,98 @@
+package converter
+
+import (
+ "sort"
+ "strings"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// Canonical image formats we can write to.
+var canonicalImage = map[string]struct{}{
+ ".jpg": {},
+ ".png": {},
+ ".tif": {},
+ ".bmp": {},
+ ".webp": {},
+ ".gif": {},
+ ".ico": {},
+ ".heic": {},
+ ".heif": {},
+ ".avif": {},
+}
+
+// Canonical video formats we can write to.
+var canonicalVideo = map[string]struct{}{
+ ".mp4": {},
+ ".mov": {},
+ ".avi": {},
+ ".mkv": {},
+ ".webm": {},
+ ".flv": {},
+ ".wmv": {},
+ ".m4v": {},
+ ".mpeg": {},
+ ".3gp": {},
+ ".ts": {},
+}
+
+// ImageConversions maps source image extension to list of target extensions.
+var ImageConversions map[string][]string
+
+// VideoConversions maps source video extension to list of target extensions.
+var VideoConversions map[string][]string
+
+func init() {
+ ImageConversions = make(map[string][]string)
+ for ext := range shared.ImageExtensions {
+ norm := shared.NormaliseExt(ext)
+ if _, ok := canonicalImage[norm]; !ok {
+ continue
+ }
+ var targets []string
+ for t := range canonicalImage {
+ if t != norm {
+ targets = append(targets, t)
+ }
+ }
+ sort.Strings(targets)
+ ImageConversions[ext] = targets
+ }
+
+ VideoConversions = make(map[string][]string)
+ for ext := range shared.VideoExtensions {
+ norm := shared.NormaliseExt(ext)
+ if _, ok := canonicalVideo[norm]; !ok {
+ continue
+ }
+ var targets []string
+ for t := range canonicalVideo {
+ if t != norm {
+ targets = append(targets, t)
+ }
+ }
+ sort.Strings(targets)
+ VideoConversions[ext] = targets
+ }
+}
+
+// GetCompatibleTargets returns the list of extensions a source can convert to.
+func GetCompatibleTargets(sourcePath string) []string {
+ ext := shared.NormaliseExt(strings.ToLower(extOf(sourcePath)))
+ if targets, ok := ImageConversions[ext]; ok {
+ return targets
+ }
+ if targets, ok := VideoConversions[ext]; ok {
+ return targets
+ }
+ return nil
+}
+
+func extOf(path string) string {
+ for i := len(path) - 1; i >= 0; i-- {
+ if path[i] == '.' {
+ return path[i:]
+ }
+ }
+ return ""
+}
diff --git a/go/internal/converter/converter.go b/go/internal/converter/converter.go
new file mode 100644
index 0000000..6477cf5
--- /dev/null
+++ b/go/internal/converter/converter.go
@@ -0,0 +1,186 @@
+package converter
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "github.com/disintegration/imaging"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ffmpegAvailable checks if ffmpeg is on PATH.
+func ffmpegAvailable() bool {
+ _, err := exec.LookPath("ffmpeg")
+ return err == nil
+}
+
+// ffmpegHasEncoder checks if ffmpeg has a particular video encoder.
+func ffmpegHasEncoder(encoder string) bool {
+ out, err := exec.Command("ffmpeg", "-hide_banner", "-encoders").Output()
+ if err != nil {
+ return false
+ }
+ for _, line := range strings.Split(string(out), "\n") {
+ if strings.Contains(line, encoder) {
+ return true
+ }
+ }
+ return false
+}
+
+// getVideoEncoder selects a video encoder for the target extension.
+// Returns (encoder, useHwaccel, outputExtension).
+func getVideoEncoder(targetExt string) (string, bool, string) {
+ ext := strings.ToLower(strings.TrimPrefix(targetExt, "."))
+ outputExt := ext
+
+ // AV1 variants (e.g., "mp4-av1")
+ if strings.HasSuffix(ext, "-av1") {
+ outputExt = strings.Split(ext, "-")[0]
+ if ffmpegHasEncoder("libsvtav1") {
+ return "libsvtav1", false, outputExt
+ }
+ if ffmpegHasEncoder("libaom-av1") {
+ return "libaom-av1", false, outputExt
+ }
+ if ffmpegHasEncoder("libvpx-vp9") {
+ return "libvpx-vp9", false, outputExt
+ }
+ return "libx264", false, outputExt
+ }
+
+ // Standard containers
+ switch outputExt {
+ case "mp4", "mkv", "mov":
+ return "libx264", false, outputExt
+ case "webm":
+ return "libvpx-vp9", false, outputExt
+ case "avi":
+ return "mpeg4", false, outputExt
+ case "flv", "mpeg", "3gp", "ts":
+ return "libx264", false, outputExt
+ }
+
+ return "libx264", false, outputExt
+}
+
+// ConvertImage converts an image file using the imaging library.
+func ConvertImage(source, targetExt, outputDir string) (string, error) {
+ ext := shared.NormaliseExt(normaliseTargetExt(targetExt))
+
+ stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source))
+ var dest string
+ if outputDir != "" {
+ os.MkdirAll(outputDir, 0755)
+ dest = filepath.Join(outputDir, stem+ext)
+ } else {
+ dest = filepath.Join(filepath.Dir(source), stem+ext)
+ }
+
+ // Avoid overwriting
+ if _, err := os.Stat(dest); err == nil {
+ dest = filepath.Join(filepath.Dir(dest),
+ strings.TrimSuffix(filepath.Base(dest), ext)+"_converted"+ext)
+ }
+
+ img, err := imaging.Open(source)
+ if err != nil {
+ return "", err
+ }
+
+ opts := []imaging.EncodeOption{}
+ extLower := strings.ToLower(ext)
+ if extLower == ".jpg" || extLower == ".jpeg" {
+ opts = append(opts, imaging.JPEGQuality(95))
+ }
+
+ if err := imaging.Save(img, dest, opts...); err != nil {
+ return "", err
+ }
+
+ return dest, nil
+}
+
+// ConvertVideo converts a video file using ffmpeg.
+func ConvertVideo(source, targetExt, outputDir string, av1CRF int) (string, error) {
+ if !ffmpegAvailable() {
+ return "", fmt.Errorf("ffmpeg is not installed or not on PATH")
+ }
+
+ ext := normaliseTargetExt(targetExt)
+
+ codecTargetExt := ext
+ if strings.Contains(ext, "-av1") {
+ codecTargetExt = "." + strings.Split(strings.TrimPrefix(ext, "."), "-")[0]
+ }
+
+ stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source))
+ var dest string
+ if outputDir != "" {
+ os.MkdirAll(outputDir, 0755)
+ dest = filepath.Join(outputDir, stem+codecTargetExt)
+ } else {
+ dest = filepath.Join(filepath.Dir(source), stem+codecTargetExt)
+ }
+
+ // Avoid overwriting
+ if _, err := os.Stat(dest); err == nil {
+ dest = filepath.Join(filepath.Dir(dest),
+ strings.TrimSuffix(filepath.Base(dest), codecTargetExt)+"_converted"+codecTargetExt)
+ }
+
+ var cmd []string
+
+ // Stream-copy for container-only conversions
+ if codecTargetExt == ".mkv" || codecTargetExt == ".ts" {
+ cmd = []string{"ffmpeg", "-y", "-i", source, "-c", "copy", dest}
+ } else {
+ encoder, _, _ := getVideoEncoder(targetExt)
+ audioCodec := "aac"
+ if codecTargetExt == ".avi" {
+ audioCodec = "libmp3lame"
+ }
+
+ cmd = []string{"ffmpeg", "-y", "-i", source, "-c:v", encoder, "-c:a", audioCodec}
+
+ if strings.Contains(encoder, "av1") || encoder == "libaom-av1" || encoder == "libsvtav1" {
+ crf := 28
+ if av1CRF >= 10 && av1CRF <= 63 {
+ crf = av1CRF
+ }
+ cmd = append(cmd, "-preset", "fast", "-crf", fmt.Sprintf("%d", crf))
+ } else {
+ cmd = append(cmd, "-preset", "fast", "-crf", "23")
+ }
+
+ cmd = append(cmd, dest)
+ }
+
+ out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("ffmpeg error: %s", strings.TrimSpace(string(out)))
+ }
+
+ return dest, nil
+}
+
+// ConvertFile is the high-level converter — routes to image or video handler.
+func ConvertFile(source, targetExt, outputDir string, av1CRF int) (string, error) {
+ if shared.IsImage(source) {
+ return ConvertImage(source, targetExt, outputDir)
+ }
+ if shared.IsVideo(source) {
+ return ConvertVideo(source, targetExt, outputDir, av1CRF)
+ }
+ return "", fmt.Errorf("unsupported file type: %s", source)
+}
+
+func normaliseTargetExt(ext string) string {
+ if !strings.HasPrefix(ext, ".") {
+ ext = "." + ext
+ }
+ return shared.NormaliseExt(ext)
+}
diff --git a/go/internal/converter/scanner.go b/go/internal/converter/scanner.go
new file mode 100644
index 0000000..0969782
--- /dev/null
+++ b/go/internal/converter/scanner.go
@@ -0,0 +1,130 @@
+package converter
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ScanResult holds the output of a folder scan.
+type ScanResult struct {
+ Folder string `json:"folder"`
+ Summary map[string]int `json:"summary"`
+ Files []FileEntry `json:"files"`
+ Total int `json:"total"`
+}
+
+// FileEntry describes one file found during scanning.
+type FileEntry struct {
+ Path string `json:"path"`
+ Name string `json:"name"`
+ Ext string `json:"ext"`
+ Size int64 `json:"size"`
+ Type string `json:"type"`
+ Targets []string `json:"targets"`
+}
+
+// ScanFolder walks folder and returns an inventory of convertible media files.
+func ScanFolder(folder string, includeSubfolders bool, filterType string) (*ScanResult, error) {
+ allowed := make(map[string]struct{})
+ if filterType == "images" || filterType == "both" {
+ for k, v := range shared.ImageExtensions {
+ allowed[k] = v
+ }
+ }
+ if filterType == "videos" || filterType == "both" {
+ for k, v := range shared.VideoExtensions {
+ allowed[k] = v
+ }
+ }
+
+ summary := make(map[string]int)
+ var files []FileEntry
+
+ if includeSubfolders {
+ filepath.WalkDir(folder, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return nil
+ }
+ if d.IsDir() {
+ name := strings.ToLower(d.Name())
+ if _, excl := shared.ExcludedFolders[name]; excl {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ addFileEntry(path, d, allowed, summary, &files)
+ return nil
+ })
+ } else {
+ entries, err := os.ReadDir(folder)
+ if err != nil {
+ return nil, err
+ }
+ for _, d := range entries {
+ if d.IsDir() {
+ continue
+ }
+ path := filepath.Join(folder, d.Name())
+ addFileEntry(path, d, allowed, summary, &files)
+ }
+ }
+
+ // Sort summary by count descending
+ type kv struct {
+ K string
+ V int
+ }
+ var sorted []kv
+ for k, v := range summary {
+ sorted = append(sorted, kv{k, v})
+ }
+ sort.Slice(sorted, func(i, j int) bool { return sorted[i].V > sorted[j].V })
+ sortedSummary := make(map[string]int, len(sorted))
+ for _, s := range sorted {
+ sortedSummary[s.K] = s.V
+ }
+
+ // Sort files by name
+ sort.Slice(files, func(i, j int) bool {
+ return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
+ })
+
+ return &ScanResult{
+ Folder: folder,
+ Summary: sortedSummary,
+ Files: files,
+ Total: len(files),
+ }, nil
+}
+
+func addFileEntry(path string, d os.DirEntry, allowed map[string]struct{}, summary map[string]int, files *[]FileEntry) {
+ ext := shared.NormaliseExt(strings.ToLower(filepath.Ext(path)))
+ if _, ok := allowed[ext]; !ok {
+ return
+ }
+
+ var size int64
+ if info, err := d.Info(); err == nil {
+ size = info.Size()
+ }
+
+ ftype := "image"
+ if _, ok := shared.VideoExtensions[ext]; ok {
+ ftype = "video"
+ }
+
+ targets := GetCompatibleTargets(path)
+ summary[ext]++
+ *files = append(*files, FileEntry{
+ Path: path,
+ Name: d.Name(),
+ Ext: ext,
+ Size: size,
+ Type: ftype,
+ Targets: targets,
+ })
+}
diff --git a/go/internal/dupfinder/images.go b/go/internal/dupfinder/images.go
new file mode 100644
index 0000000..b7ecb0d
--- /dev/null
+++ b/go/internal/dupfinder/images.go
@@ -0,0 +1,182 @@
+package dupfinder
+
+import (
+ "log"
+ "math/bits"
+ "os"
+ "sort"
+ "sync"
+
+ "github.com/corona10/goimagehash"
+ "github.com/disintegration/imaging"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ImageInfo stores information about an image file.
+type ImageInfo struct {
+ Path string `json:"path"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FileSize int64 `json:"file_size"`
+ Format string `json:"format"`
+ PHash uint64 `json:"-"`
+ AHash uint64 `json:"-"`
+ DHash uint64 `json:"-"`
+ HasHash bool `json:"-"`
+}
+
+// ComputeImageHashes loads an image and computes perceptual hashes.
+func ComputeImageHashes(path string) ImageInfo {
+ info := ImageInfo{Path: path}
+
+ st, err := os.Stat(path)
+ if err != nil {
+ return info
+ }
+ info.FileSize = st.Size()
+
+ img, err := imaging.Open(path)
+ if err != nil {
+ log.Printf("dupfinder: cannot open image %s: %v", path, err)
+ return info
+ }
+
+ bounds := img.Bounds()
+ info.Width = bounds.Dx()
+ info.Height = bounds.Dy()
+
+ ph, err := goimagehash.PerceptionHash(img)
+ if err == nil {
+ info.PHash = ph.GetHash()
+ }
+ ah, err := goimagehash.AverageHash(img)
+ if err == nil {
+ info.AHash = ah.GetHash()
+ }
+ dh, err := goimagehash.DifferenceHash(img)
+ if err == nil {
+ info.DHash = dh.GetHash()
+ }
+
+ info.HasHash = info.PHash != 0 || info.AHash != 0 || info.DHash != 0
+ return info
+}
+
+// ProcessImages hashes all images concurrently and returns successful results.
+func ProcessImages(files []shared.FileInfo, numWorkers int) map[string]*ImageInfo {
+ result := make(map[string]*ImageInfo)
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, numWorkers)
+
+ for _, f := range files {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(fi shared.FileInfo) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ info := ComputeImageHashes(fi.Path)
+ if info.HasHash {
+ mu.Lock()
+ result[fi.Path] = &info
+ mu.Unlock()
+ }
+ }(f)
+ }
+ wg.Wait()
+ return result
+}
+
+// hashSimilarity returns the similarity (0-1) between two 64-bit hashes.
+func hashSimilarity(a, b uint64) float64 {
+ dist := bits.OnesCount64(a ^ b)
+ return 1.0 - float64(dist)/64.0
+}
+
+// ComputeSimilarity computes average similarity across phash, ahash, dhash.
+func ComputeSimilarity(a, b *ImageInfo) float64 {
+ var total, count float64
+ if a.PHash != 0 && b.PHash != 0 {
+ total += hashSimilarity(a.PHash, b.PHash)
+ count++
+ }
+ if a.AHash != 0 && b.AHash != 0 {
+ total += hashSimilarity(a.AHash, b.AHash)
+ count++
+ }
+ if a.DHash != 0 && b.DHash != 0 {
+ total += hashSimilarity(a.DHash, b.DHash)
+ count++
+ }
+ if count == 0 {
+ return 0
+ }
+ return total / count
+}
+
+// DuplicateEntry represents one file in a duplicate group.
+type DuplicateEntry struct {
+ Path string `json:"path"`
+ Similarity float64 `json:"similarity"`
+}
+
+// FindImageDuplicates finds groups of duplicate images.
+func FindImageDuplicates(infos map[string]*ImageInfo, threshold float64) [][]DuplicateEntry {
+ // Bucket exact PHash matches first
+ buckets := make(map[uint64][]string)
+ for path, info := range infos {
+ if info.PHash != 0 {
+ buckets[info.PHash] = append(buckets[info.PHash], path)
+ }
+ }
+
+ var groups [][]DuplicateEntry
+ assigned := make(map[string]bool)
+
+ // Exact hash groups
+ for _, paths := range buckets {
+ if len(paths) > 1 {
+ sort.Strings(paths)
+ var group []DuplicateEntry
+ for _, p := range paths {
+ group = append(group, DuplicateEntry{Path: p, Similarity: 1.0})
+ assigned[p] = true
+ }
+ groups = append(groups, group)
+ }
+ }
+
+ // Near-duplicate detection on remaining images
+ var remaining []string
+ for path := range infos {
+ if !assigned[path] {
+ remaining = append(remaining, path)
+ }
+ }
+ sort.Strings(remaining)
+
+ for i := 0; i < len(remaining); i++ {
+ if assigned[remaining[i]] {
+ continue
+ }
+ group := []DuplicateEntry{{Path: remaining[i], Similarity: 1.0}}
+
+ for j := i + 1; j < len(remaining); j++ {
+ if assigned[remaining[j]] {
+ continue
+ }
+ sim := ComputeSimilarity(infos[remaining[i]], infos[remaining[j]])
+ if sim >= threshold {
+ group = append(group, DuplicateEntry{Path: remaining[j], Similarity: sim})
+ assigned[remaining[j]] = true
+ }
+ }
+
+ if len(group) > 1 {
+ assigned[remaining[i]] = true
+ groups = append(groups, group)
+ }
+ }
+
+ return groups
+}
diff --git a/go/internal/dupfinder/scanner.go b/go/internal/dupfinder/scanner.go
new file mode 100644
index 0000000..a61ec27
--- /dev/null
+++ b/go/internal/dupfinder/scanner.go
@@ -0,0 +1,280 @@
+package dupfinder
+
+import (
+ "fmt"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ScanJob represents a running or completed dupfinder job.
+type ScanJob struct {
+ shared.Job
+ mu sync.Mutex
+
+ Folder string `json:"folder"`
+ ScanType string `json:"scan_type"` // "images", "videos", "both"
+ ImageThreshold float64 `json:"image_threshold"`
+ VideoThreshold float64 `json:"video_threshold"`
+ ImageGroups [][]map[string]interface{} `json:"image_groups,omitempty"`
+ VideoGroups [][]map[string]interface{} `json:"video_groups,omitempty"`
+ TotalFound int `json:"total_files_found"`
+ TotalProcessed int `json:"total_files_processed"`
+ SpaceSavings int64 `json:"space_savings"`
+}
+
+var store = shared.NewJobStore[ScanJob]()
+
+func init() {
+ store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+// StartJob creates and launches a new dupfinder job.
+func StartJob(folder, scanType string, imageThreshold, videoThreshold float64) string {
+ job := &ScanJob{
+ Job: shared.NewJob(),
+ Folder: folder,
+ ScanType: scanType,
+ ImageThreshold: imageThreshold,
+ VideoThreshold: videoThreshold,
+ }
+ job.Status = shared.JobStatusRunning
+ store.Set(job.ID, job)
+ go runScan(job)
+ return job.ID
+}
+
+// GetJob retrieves a job by ID.
+func GetJob(id string) (*ScanJob, bool) {
+ return store.Get(id)
+}
+
+func runScan(job *ScanJob) {
+ defer func() {
+ if r := recover(); r != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = fmt.Sprintf("%v", r)
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ }
+ }()
+
+ job.mu.Lock()
+ job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder)
+ job.mu.Unlock()
+
+ // Image scan
+ if job.ScanType == "images" || job.ScanType == "both" {
+ scanImages(job)
+ }
+
+ // Video scan
+ if job.ScanType == "videos" || job.ScanType == "both" {
+ scanVideos(job)
+ }
+
+ // Finalise
+ job.mu.Lock()
+ job.SpaceSavings = calculateSpaceSavings(job)
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ elapsed := job.DoneAt.Sub(job.StartedAt).Seconds()
+ totalGroups := len(job.ImageGroups) + len(job.VideoGroups)
+ job.Message = fmt.Sprintf("Done! Found %d duplicate group(s) in %.1fs. Potential savings: %s",
+ totalGroups, elapsed, shared.FormatFileSize(job.SpaceSavings))
+ job.mu.Unlock()
+}
+
+func scanImages(job *ScanJob) {
+ job.mu.Lock()
+ job.Message = "Finding image files..."
+ job.mu.Unlock()
+
+ files, err := shared.FindImageFiles(job.Folder)
+ if err != nil {
+ return
+ }
+
+ job.mu.Lock()
+ job.TotalFound += len(files)
+ job.Message = fmt.Sprintf("Found %d images. Processing hashes...", len(files))
+ job.Progress = 0.1
+ job.mu.Unlock()
+
+ if len(files) == 0 {
+ return
+ }
+
+ infos := ProcessImages(files, shared.DefaultNumWorkers)
+
+ job.mu.Lock()
+ job.TotalProcessed += len(infos)
+ job.Progress = 0.4
+ job.Message = fmt.Sprintf("Processed %d images. Finding duplicates...", len(infos))
+ job.mu.Unlock()
+
+ groups := FindImageDuplicates(infos, job.ImageThreshold)
+ formatted := formatImageGroups(groups, infos)
+
+ job.mu.Lock()
+ job.ImageGroups = formatted
+ if job.ScanType == "both" {
+ job.Progress = 0.5
+ } else {
+ job.Progress = 0.95
+ }
+ job.mu.Unlock()
+}
+
+func scanVideos(job *ScanJob) {
+ job.mu.Lock()
+ job.Message = "Finding video files..."
+ job.mu.Unlock()
+
+ files, err := shared.FindVideoFiles(job.Folder)
+ if err != nil {
+ return
+ }
+
+ job.mu.Lock()
+ job.TotalFound += len(files)
+ job.Message = fmt.Sprintf("Found %d videos. Processing hashes...", len(files))
+ if job.ScanType == "both" {
+ job.Progress = 0.6
+ } else {
+ job.Progress = 0.1
+ }
+ job.mu.Unlock()
+
+ if len(files) == 0 {
+ return
+ }
+
+ infos := ProcessVideos(files, shared.DefaultNumFrames, shared.DefaultNumWorkers)
+
+ job.mu.Lock()
+ job.TotalProcessed += len(infos)
+ if job.ScanType == "both" {
+ job.Progress = 0.8
+ } else {
+ job.Progress = 0.7
+ }
+ job.Message = fmt.Sprintf("Processed %d videos. Finding duplicates...", len(infos))
+ job.mu.Unlock()
+
+ groups := FindVideoDuplicates(infos, job.VideoThreshold)
+ formatted := formatVideoGroups(groups, infos)
+
+ job.mu.Lock()
+ job.VideoGroups = formatted
+ job.Progress = 0.95
+ job.mu.Unlock()
+}
+
+func formatImageGroups(groups [][]DuplicateEntry, infos map[string]*ImageInfo) [][]map[string]interface{} {
+ var result [][]map[string]interface{}
+ for _, group := range groups {
+ // Sort by file size descending
+ sort.Slice(group, func(i, j int) bool {
+ ai := infos[group[i].Path]
+ aj := infos[group[j].Path]
+ if ai == nil || aj == nil {
+ return false
+ }
+ return ai.FileSize > aj.FileSize
+ })
+
+ var formatted []map[string]interface{}
+ for _, entry := range group {
+ info := infos[entry.Path]
+ if info == nil {
+ continue
+ }
+ formatted = append(formatted, map[string]interface{}{
+ "path": entry.Path,
+ "filename": filepath.Base(entry.Path),
+ "directory": filepath.Dir(entry.Path),
+ "width": info.Width,
+ "height": info.Height,
+ "resolution": fmt.Sprintf("%dx%d", info.Width, info.Height),
+ "format": info.Format,
+ "file_size": info.FileSize,
+ "file_size_formatted": shared.FormatFileSize(info.FileSize),
+ "similarity": float64(int(entry.Similarity*1000)) / 10,
+ "type": "image",
+ })
+ }
+ if len(formatted) > 1 {
+ result = append(result, formatted)
+ }
+ }
+ return result
+}
+
+func formatVideoGroups(groups [][]DuplicateEntry, infos map[string]*VideoInfo) [][]map[string]interface{} {
+ var result [][]map[string]interface{}
+ for _, group := range groups {
+ sort.Slice(group, func(i, j int) bool {
+ ai := infos[group[i].Path]
+ aj := infos[group[j].Path]
+ if ai == nil || aj == nil {
+ return false
+ }
+ return ai.FileSize > aj.FileSize
+ })
+
+ var formatted []map[string]interface{}
+ for _, entry := range group {
+ info := infos[entry.Path]
+ if info == nil {
+ continue
+ }
+ formatted = append(formatted, map[string]interface{}{
+ "path": entry.Path,
+ "filename": filepath.Base(entry.Path),
+ "directory": filepath.Dir(entry.Path),
+ "width": info.Width,
+ "height": info.Height,
+ "resolution": fmt.Sprintf("%dx%d", info.Width, info.Height),
+ "duration": info.Duration,
+ "duration_formatted": shared.FormatDuration(info.Duration),
+ "fps": float64(int(info.FPS*10)) / 10,
+ "file_size": info.FileSize,
+ "file_size_formatted": shared.FormatFileSize(info.FileSize),
+ "similarity": float64(int(entry.Similarity*1000)) / 10,
+ "type": "video",
+ })
+ }
+ if len(formatted) > 1 {
+ result = append(result, formatted)
+ }
+ }
+ return result
+}
+
+func calculateSpaceSavings(job *ScanJob) int64 {
+ var total int64
+ allGroups := append(job.ImageGroups, job.VideoGroups...)
+ for _, group := range allGroups {
+ var sizes []int64
+ for _, item := range group {
+ if s, ok := item["file_size"].(int64); ok {
+ sizes = append(sizes, s)
+ }
+ }
+ if len(sizes) > 1 {
+ sort.Slice(sizes, func(i, j int) bool { return sizes[i] < sizes[j] })
+ for _, s := range sizes[:len(sizes)-1] {
+ total += s
+ }
+ }
+ }
+ return total
+}
diff --git a/go/internal/dupfinder/videos.go b/go/internal/dupfinder/videos.go
new file mode 100644
index 0000000..b7d18a0
--- /dev/null
+++ b/go/internal/dupfinder/videos.go
@@ -0,0 +1,233 @@
+package dupfinder
+
+import (
+ "fmt"
+ "log"
+ "math/bits"
+ "os"
+ "os/exec"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/corona10/goimagehash"
+ "github.com/disintegration/imaging"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// VideoInfo stores information about a video file.
+type VideoInfo struct {
+ Path string `json:"path"`
+ Duration float64 `json:"duration"`
+ FPS float64 `json:"fps"`
+ FrameCount int `json:"frame_count"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ FileSize int64 `json:"file_size"`
+ FrameHashes []uint64 `json:"-"`
+ HasHash bool `json:"-"`
+}
+
+// ComputeVideoHashes extracts frames and computes perceptual hashes.
+func ComputeVideoHashes(path string, numFrames int) VideoInfo {
+ info := VideoInfo{Path: path}
+
+ st, err := os.Stat(path)
+ if err != nil {
+ return info
+ }
+ info.FileSize = st.Size()
+
+ // Get video metadata via ffprobe
+ probeOut, err := exec.Command("ffprobe",
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=width,height,duration,r_frame_rate,nb_frames",
+ "-of", "csv=p=0",
+ path,
+ ).Output()
+ if err != nil {
+ log.Printf("dupfinder: ffprobe failed for %s: %v", path, err)
+ return info
+ }
+
+ parts := strings.Split(strings.TrimSpace(string(probeOut)), ",")
+ if len(parts) >= 1 {
+ info.Width, _ = strconv.Atoi(parts[0])
+ }
+ if len(parts) >= 2 {
+ info.Height, _ = strconv.Atoi(parts[1])
+ }
+ if len(parts) >= 3 && parts[2] != "" && parts[2] != "N/A" {
+ info.Duration, _ = strconv.ParseFloat(parts[2], 64)
+ }
+ if len(parts) >= 4 {
+ info.FPS = parseFPS(parts[3])
+ }
+ if len(parts) >= 5 {
+ info.FrameCount, _ = strconv.Atoi(parts[4])
+ }
+
+ if info.Duration <= 0 && info.FrameCount > 0 && info.FPS > 0 {
+ info.Duration = float64(info.FrameCount) / info.FPS
+ }
+
+ if info.Duration <= 0 {
+ return info
+ }
+
+ // Extract frames at regular intervals using ffmpeg
+ frameHashes := extractAndHashFrames(path, info.Duration, numFrames)
+ info.FrameHashes = frameHashes
+ info.HasHash = len(frameHashes) > 0
+
+ return info
+}
+
+// extractAndHashFrames extracts frames at intervals and hashes them.
+func extractAndHashFrames(path string, duration float64, numFrames int) []uint64 {
+ startTime := duration * 0.05
+ endTime := duration * 0.95
+ if endTime <= startTime {
+ startTime = 0
+ endTime = duration
+ }
+
+ interval := (endTime - startTime) / float64(numFrames+1)
+ var hashes []uint64
+
+ for i := 0; i < numFrames; i++ {
+ ts := startTime + float64(i+1)*interval
+ frameFile := fmt.Sprintf("/tmp/morphic_frame_%d_%d.jpg", os.Getpid(), i)
+
+ cmd := exec.Command("ffmpeg", "-y",
+ "-ss", fmt.Sprintf("%.3f", ts),
+ "-i", path,
+ "-vframes", "1",
+ "-q:v", "2",
+ frameFile,
+ )
+ cmd.Stdout = nil
+ cmd.Stderr = nil
+
+ if err := cmd.Run(); err != nil {
+ continue
+ }
+
+ img, err := imaging.Open(frameFile)
+ os.Remove(frameFile)
+ if err != nil {
+ continue
+ }
+
+ ph, err := goimagehash.PerceptionHash(img)
+ if err != nil {
+ continue
+ }
+ hashes = append(hashes, ph.GetHash())
+ }
+
+ return hashes
+}
+
+func parseFPS(s string) float64 {
+ s = strings.TrimSpace(s)
+ if strings.Contains(s, "/") {
+ parts := strings.Split(s, "/")
+ if len(parts) == 2 {
+ num, err1 := strconv.ParseFloat(parts[0], 64)
+ den, err2 := strconv.ParseFloat(parts[1], 64)
+ if err1 == nil && err2 == nil && den != 0 {
+ return num / den
+ }
+ }
+ }
+ f, _ := strconv.ParseFloat(s, 64)
+ return f
+}
+
+// ProcessVideos hashes all videos concurrently and returns successful results.
+func ProcessVideos(files []shared.FileInfo, numFrames, numWorkers int) map[string]*VideoInfo {
+ result := make(map[string]*VideoInfo)
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, numWorkers)
+
+ for _, f := range files {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(fi shared.FileInfo) {
+ defer wg.Done()
+ defer func() { <-sem }()
+ info := ComputeVideoHashes(fi.Path, numFrames)
+ if info.HasHash {
+ mu.Lock()
+ result[fi.Path] = &info
+ mu.Unlock()
+ }
+ }(f)
+ }
+ wg.Wait()
+ return result
+}
+
+// ComputeVideoSimilarity computes similarity between two videos using
+// frame-level hash comparison.
+func ComputeVideoSimilarity(a, b *VideoInfo) float64 {
+ if len(a.FrameHashes) == 0 || len(b.FrameHashes) == 0 {
+ return 0
+ }
+
+ var total float64
+ for _, h1 := range a.FrameHashes {
+ bestSim := 0.0
+ for _, h2 := range b.FrameHashes {
+ dist := bits.OnesCount64(h1 ^ h2)
+ sim := 1.0 - float64(dist)/64.0
+ if sim > bestSim {
+ bestSim = sim
+ }
+ }
+ total += bestSim
+ }
+
+ return total / float64(len(a.FrameHashes))
+}
+
+// FindVideoDuplicates finds groups of duplicate videos.
+func FindVideoDuplicates(infos map[string]*VideoInfo, threshold float64) [][]DuplicateEntry {
+ paths := make([]string, 0, len(infos))
+ for p := range infos {
+ paths = append(paths, p)
+ }
+ sort.Strings(paths)
+
+ assigned := make(map[string]bool)
+ var groups [][]DuplicateEntry
+
+ for i := 0; i < len(paths); i++ {
+ if assigned[paths[i]] {
+ continue
+ }
+ group := []DuplicateEntry{{Path: paths[i], Similarity: 1.0}}
+
+ for j := i + 1; j < len(paths); j++ {
+ if assigned[paths[j]] {
+ continue
+ }
+ sim := ComputeVideoSimilarity(infos[paths[i]], infos[paths[j]])
+ if sim >= threshold {
+ group = append(group, DuplicateEntry{Path: paths[j], Similarity: sim})
+ assigned[paths[j]] = true
+ }
+ }
+
+ if len(group) > 1 {
+ assigned[paths[i]] = true
+ groups = append(groups, group)
+ }
+ }
+
+ return groups
+}
diff --git a/go/internal/inspector/exif.go b/go/internal/inspector/exif.go
new file mode 100644
index 0000000..bd70b1a
--- /dev/null
+++ b/go/internal/inspector/exif.go
@@ -0,0 +1,173 @@
+package inspector
+
+import (
+ "fmt"
+ "math"
+ "os"
+
+ exif "github.com/dsoprea/go-exif/v3"
+ exifcommon "github.com/dsoprea/go-exif/v3/common"
+)
+
+// ExifData is a flat map of human-readable tag names to values.
+type ExifData map[string]interface{}
+
+// ReadExif reads EXIF metadata from an image file and returns a flat
+// map of tag names to decoded values. Includes "_gps_lat" and "_gps_lng"
+// when GPS data is present.
+func ReadExif(path string) (ExifData, error) {
+ if _, err := os.Stat(path); err != nil {
+ return nil, fmt.Errorf("file not found: %s", path)
+ }
+
+ rawExif, err := exif.SearchFileAndExtractExif(path)
+ if err != nil {
+ // No EXIF data found — not an error, just empty
+ return ExifData{}, nil
+ }
+
+ tags, _, err := exif.GetFlatExifData(rawExif, nil)
+ if err != nil {
+ return ExifData{}, nil
+ }
+
+ result := make(ExifData, len(tags))
+ var latVal, lngVal []exifcommon.Rational
+ var latRef, lngRef string
+
+ for _, tag := range tags {
+ name := tag.TagName
+ result[name] = tag.FormattedFirst
+
+ // Collect GPS fields for decimal conversion
+ switch tag.TagName {
+ case "GPSLatitude":
+ if vals, ok := tag.Value.([]exifcommon.Rational); ok {
+ latVal = vals
+ }
+ case "GPSLatitudeRef":
+ latRef = tag.FormattedFirst
+ case "GPSLongitude":
+ if vals, ok := tag.Value.([]exifcommon.Rational); ok {
+ lngVal = vals
+ }
+ case "GPSLongitudeRef":
+ lngRef = tag.FormattedFirst
+ }
+ }
+
+ // Compute decimal GPS coordinates
+ if len(latVal) >= 3 && len(lngVal) >= 3 {
+ result["_gps_lat"] = gpsToDecimal(latVal, latRef)
+ result["_gps_lng"] = gpsToDecimal(lngVal, lngRef)
+ }
+
+ return result, nil
+}
+
+// gpsToDecimal converts DMS rational values to decimal degrees.
+func gpsToDecimal(coords []exifcommon.Rational, ref string) float64 {
+ deg := float64(coords[0].Numerator) / float64(coords[0].Denominator)
+ min := float64(coords[1].Numerator) / float64(coords[1].Denominator)
+ sec := float64(coords[2].Numerator) / float64(coords[2].Denominator)
+ decimal := deg + min/60 + sec/3600
+ if ref == "S" || ref == "W" {
+ decimal = -decimal
+ }
+ return math.Round(decimal*1e6) / 1e6
+}
+
+// StripExif removes all EXIF metadata from an image file by reading the
+// image data and rewriting it without the EXIF segment. For JPEG files this
+// uses piexif-style segment removal.
+func StripExif(path string) error {
+ if _, err := os.Stat(path); err != nil {
+ return fmt.Errorf("file not found: %s", path)
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ // Check if there's EXIF to strip
+ _, err = exif.SearchAndExtractExif(data)
+ if err != nil {
+ // No EXIF to strip
+ return nil
+ }
+
+ stripped, err := dropExifFromJPEG(data)
+ if err != nil {
+ return err
+ }
+
+ return os.WriteFile(path, stripped, 0644)
+}
+
+// dropExifFromJPEG removes APP1 (EXIF) segments from JPEG data.
+func dropExifFromJPEG(data []byte) ([]byte, error) {
+ if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
+ return nil, fmt.Errorf("not a JPEG file")
+ }
+
+ out := []byte{0xFF, 0xD8}
+ i := 2
+ for i < len(data)-1 {
+ if data[i] != 0xFF {
+ // Reached image data, copy the rest
+ out = append(out, data[i:]...)
+ break
+ }
+ marker := data[i+1]
+
+ // SOS (start of scan) — everything after this is image data
+ if marker == 0xDA {
+ out = append(out, data[i:]...)
+ break
+ }
+
+ // Markers without length
+ if marker == 0xD8 || marker == 0xD9 || (marker >= 0xD0 && marker <= 0xD7) {
+ out = append(out, data[i], data[i+1])
+ i += 2
+ continue
+ }
+
+ if i+3 >= len(data) {
+ out = append(out, data[i:]...)
+ break
+ }
+
+ segLen := int(data[i+2])<<8 | int(data[i+3])
+ segEnd := i + 2 + segLen
+ if segEnd > len(data) {
+ segEnd = len(data)
+ }
+
+ // APP1 marker (0xE1) is where EXIF lives — skip it
+ if marker == 0xE1 {
+ i = segEnd
+ continue
+ }
+
+ out = append(out, data[i:segEnd]...)
+ i = segEnd
+ }
+
+ return out, nil
+}
+
+// StripExifBatch strips EXIF from multiple files and returns per-file results.
+func StripExifBatch(paths []string) map[string]map[string]interface{} {
+ results := make(map[string]map[string]interface{}, len(paths))
+ for _, p := range paths {
+ err := StripExif(p)
+ if err != nil {
+ results[p] = map[string]interface{}{"success": false, "error": err.Error()}
+ } else {
+ results[p] = map[string]interface{}{"success": true}
+ }
+ }
+ return results
+}
diff --git a/go/internal/inspector/integrity.go b/go/internal/inspector/integrity.go
new file mode 100644
index 0000000..37848e7
--- /dev/null
+++ b/go/internal/inspector/integrity.go
@@ -0,0 +1,169 @@
+package inspector
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/disintegration/imaging"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// IntegrityResult holds the outcome of a single file check.
+type IntegrityResult struct {
+ Path string `json:"path"`
+ Valid bool `json:"valid"`
+ Error string `json:"error,omitempty"`
+ Size int64 `json:"size"`
+ SizeFormatted string `json:"size_formatted"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Format string `json:"format,omitempty"`
+ Duration float64 `json:"duration,omitempty"`
+ Codec string `json:"codec,omitempty"`
+ Type string `json:"type"`
+ Filename string `json:"filename,omitempty"`
+ Directory string `json:"directory,omitempty"`
+}
+
+// CheckImage validates an image file by opening and decoding it.
+func CheckImage(path string) IntegrityResult {
+ r := IntegrityResult{Path: path, Type: "image"}
+
+ info, err := os.Stat(path)
+ if err != nil {
+ r.Error = "File not found"
+ return r
+ }
+ r.Size = info.Size()
+ r.SizeFormatted = shared.FormatFileSize(r.Size)
+
+ if r.Size == 0 {
+ r.Error = "Zero-byte file"
+ return r
+ }
+
+ img, err := imaging.Open(path)
+ if err != nil {
+ r.Error = err.Error()
+ return r
+ }
+
+ bounds := img.Bounds()
+ r.Width = bounds.Dx()
+ r.Height = bounds.Dy()
+ r.Valid = true
+ return r
+}
+
+// CheckVideo validates a video file using ffprobe.
+func CheckVideo(path string) IntegrityResult {
+ r := IntegrityResult{Path: path, Type: "video"}
+
+ info, err := os.Stat(path)
+ if err != nil {
+ r.Error = "File not found"
+ return r
+ }
+ r.Size = info.Size()
+ r.SizeFormatted = shared.FormatFileSize(r.Size)
+
+ if r.Size == 0 {
+ r.Error = "Zero-byte file"
+ return r
+ }
+
+ cmd := exec.Command("ffprobe",
+ "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", "stream=codec_name,width,height,duration",
+ "-of", "csv=p=0",
+ path,
+ )
+
+ out, err := cmd.Output()
+ if err != nil {
+ if exitErr, ok := err.(*exec.ExitError); ok {
+ stderr := strings.TrimSpace(string(exitErr.Stderr))
+ if stderr != "" {
+ r.Error = stderr
+ } else {
+ r.Error = fmt.Sprintf("ffprobe exit code %d", exitErr.ExitCode())
+ }
+ } else {
+ r.Error = err.Error()
+ }
+ return r
+ }
+
+ output := strings.TrimSpace(string(out))
+ if output == "" {
+ r.Error = "No video stream found"
+ return r
+ }
+
+ parts := strings.Split(output, ",")
+ if len(parts) >= 1 {
+ r.Codec = parts[0]
+ }
+ if len(parts) >= 2 {
+ r.Width, _ = strconv.Atoi(parts[1])
+ }
+ if len(parts) >= 3 {
+ r.Height, _ = strconv.Atoi(parts[2])
+ }
+ if len(parts) >= 4 && parts[3] != "" {
+ r.Duration, _ = strconv.ParseFloat(parts[3], 64)
+ }
+
+ r.Valid = true
+ return r
+}
+
+// CheckFiles checks integrity of all media files in a folder using
+// concurrent goroutines.
+func CheckFiles(folder string, maxWorkers int) ([]IntegrityResult, error) {
+ allExts := make(map[string]struct{})
+ for k, v := range shared.ImageExtensions {
+ allExts[k] = v
+ }
+ for k, v := range shared.VideoExtensions {
+ allExts[k] = v
+ }
+
+ files, err := shared.FindFilesByExtension(folder, allExts, shared.ExcludedFolders)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]IntegrityResult, len(files))
+ var wg sync.WaitGroup
+ sem := make(chan struct{}, maxWorkers)
+
+ for i, fi := range files {
+ wg.Add(1)
+ sem <- struct{}{}
+ go func(idx int, f shared.FileInfo) {
+ defer wg.Done()
+ defer func() { <-sem }()
+
+ if shared.IsImage(f.Path) {
+ results[idx] = CheckImage(f.Path)
+ } else if shared.IsVideo(f.Path) {
+ results[idx] = CheckVideo(f.Path)
+ } else {
+ results[idx] = IntegrityResult{
+ Path: f.Path,
+ Type: "unknown",
+ Error: "Unknown file type",
+ }
+ }
+ }(i, fi)
+ }
+
+ wg.Wait()
+ return results, nil
+}
diff --git a/go/internal/inspector/scanner.go b/go/internal/inspector/scanner.go
new file mode 100644
index 0000000..dfef8a4
--- /dev/null
+++ b/go/internal/inspector/scanner.go
@@ -0,0 +1,213 @@
+package inspector
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ScanJob represents a running or completed inspector job.
+type ScanJob struct {
+ shared.Job
+ mu sync.Mutex
+
+ Folder string `json:"folder"`
+ Mode string `json:"mode"` // "exif" or "integrity"
+ Results []map[string]interface{} `json:"results,omitempty"`
+ Total int `json:"total"`
+ Processed int `json:"processed"`
+}
+
+var store = shared.NewJobStore[ScanJob]()
+
+func init() {
+ store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+// StartJob creates and launches a new inspector job.
+func StartJob(folder, mode string) string {
+ job := &ScanJob{
+ Job: shared.NewJob(),
+ Folder: folder,
+ Mode: mode,
+ }
+ job.Status = shared.JobStatusRunning
+ store.Set(job.ID, job)
+ go runScan(job)
+ return job.ID
+}
+
+// GetJob retrieves a job by ID.
+func GetJob(id string) (*ScanJob, bool) {
+ return store.Get(id)
+}
+
+func runScan(job *ScanJob) {
+ defer func() {
+ if r := recover(); r != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = fmt.Sprintf("%v", r)
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ }
+ }()
+
+ job.mu.Lock()
+ job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder)
+ job.mu.Unlock()
+
+ // Determine extensions to look for
+ var exts map[string]struct{}
+ if job.Mode == "exif" {
+ exts = shared.ImageExtensions
+ } else {
+ exts = make(map[string]struct{})
+ for k, v := range shared.ImageExtensions {
+ exts[k] = v
+ }
+ for k, v := range shared.VideoExtensions {
+ exts[k] = v
+ }
+ }
+
+ files, err := shared.FindFilesByExtension(job.Folder, exts, shared.ExcludedFolders)
+ if err != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = err.Error()
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ return
+ }
+
+ job.mu.Lock()
+ job.Total = len(files)
+ job.mu.Unlock()
+
+ if len(files) == 0 {
+ job.mu.Lock()
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ job.Message = "No files found."
+ job.mu.Unlock()
+ return
+ }
+
+ if job.Mode == "exif" {
+ scanExif(job, files)
+ } else {
+ scanIntegrity(job, files)
+ }
+
+ job.mu.Lock()
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ elapsed := job.DoneAt.Sub(job.StartedAt).Seconds()
+ job.Message = fmt.Sprintf("Done! Processed %d files in %s.",
+ job.Processed, shared.FormatDuration(elapsed))
+ job.mu.Unlock()
+}
+
+func scanExif(job *ScanJob, files []shared.FileInfo) {
+ for i, f := range files {
+ if !shared.IsImage(f.Path) {
+ continue
+ }
+
+ item := map[string]interface{}{
+ "path": f.Path,
+ "filename": filepath.Base(f.Path),
+ "directory": filepath.Dir(f.Path),
+ "has_exif": false,
+ "has_gps": false,
+ }
+
+ exifData, err := ReadExif(f.Path)
+ if err != nil {
+ item["exif"] = ExifData{}
+ item["error"] = err.Error()
+ } else {
+ item["exif"] = exifData
+ item["has_exif"] = len(exifData) > 0
+ _, hasGPS := exifData["_gps_lat"]
+ item["has_gps"] = hasGPS
+ }
+
+ job.mu.Lock()
+ job.Results = append(job.Results, item)
+ job.Processed = i + 1
+ job.Progress = float64(i+1) / float64(job.Total)
+ job.Message = fmt.Sprintf("Reading EXIF: %d/%d", i+1, job.Total)
+ job.mu.Unlock()
+ }
+}
+
+func scanIntegrity(job *ScanJob, files []shared.FileInfo) {
+ for i, f := range files {
+ var result IntegrityResult
+ if shared.IsImage(f.Path) {
+ result = CheckImage(f.Path)
+ } else if shared.IsVideo(f.Path) {
+ result = CheckVideo(f.Path)
+ } else {
+ result = IntegrityResult{
+ Path: f.Path,
+ Type: "unknown",
+ Error: "Unknown file type",
+ }
+ }
+ result.Filename = filepath.Base(f.Path)
+ result.Directory = filepath.Dir(f.Path)
+
+ item := map[string]interface{}{
+ "path": result.Path,
+ "valid": result.Valid,
+ "error": nilIfEmpty(result.Error),
+ "size": result.Size,
+ "size_formatted": result.SizeFormatted,
+ "width": result.Width,
+ "height": result.Height,
+ "type": result.Type,
+ "filename": result.Filename,
+ "directory": result.Directory,
+ }
+ if result.Format != "" {
+ item["format"] = result.Format
+ }
+ if result.Codec != "" {
+ item["codec"] = result.Codec
+ }
+ if result.Duration > 0 {
+ item["duration"] = result.Duration
+ }
+
+ job.mu.Lock()
+ job.Results = append(job.Results, item)
+ job.Processed = i + 1
+ job.Progress = float64(i+1) / float64(job.Total)
+ job.Message = fmt.Sprintf("Checking: %d/%d", i+1, job.Total)
+ job.mu.Unlock()
+ }
+}
+
+func nilIfEmpty(s string) interface{} {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+// Stat checks if a path exists.
+func fileExists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
diff --git a/go/internal/resizer/operations.go b/go/internal/resizer/operations.go
new file mode 100644
index 0000000..9cb6a20
--- /dev/null
+++ b/go/internal/resizer/operations.go
@@ -0,0 +1,128 @@
+package resizer
+
+import (
+ "fmt"
+ "image"
+ "image/color"
+ "image/draw"
+ "image/png"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/disintegration/imaging"
+)
+
+// ResizeModes lists the valid resize mode strings.
+var ResizeModes = []string{"fit", "fill", "stretch", "pad"}
+
+// IsValidMode returns true if mode is one of the accepted values.
+func IsValidMode(mode string) bool {
+ for _, m := range ResizeModes {
+ if m == mode {
+ return true
+ }
+ }
+ return false
+}
+
+// ResizeImage resizes a single image.
+//
+// Modes:
+// - "fit": resize to fit within width×height, preserving aspect ratio
+// - "fill": resize to cover width×height then center-crop
+// - "stretch": resize to exact width×height ignoring ratio
+// - "pad": fit + pad borders with bgColor
+//
+// Returns the path to the output file.
+func ResizeImage(
+ path string,
+ width, height int,
+ mode string,
+ outputFolder string,
+ bgColor string,
+ quality int,
+ outputFormat string,
+) (string, error) {
+ if _, err := os.Stat(path); err != nil {
+ return "", fmt.Errorf("file not found: %s", path)
+ }
+ if !IsValidMode(mode) {
+ return "", fmt.Errorf("invalid mode '%s'", mode)
+ }
+ if width <= 0 || height <= 0 {
+ return "", fmt.Errorf("width and height must be positive")
+ }
+
+ img, err := imaging.Open(path)
+ if err != nil {
+ return "", err
+ }
+
+ var result *image.NRGBA
+
+ switch mode {
+ case "fit":
+ result = imaging.Fit(img, width, height, imaging.Lanczos)
+ case "fill":
+ result = imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos)
+ case "stretch":
+ result = imaging.Resize(img, width, height, imaging.Lanczos)
+ case "pad":
+ fitted := imaging.Fit(img, width, height, imaging.Lanczos)
+ bg := parseHexColor(bgColor)
+ canvas := imaging.New(width, height, bg)
+ offsetX := (width - fitted.Bounds().Dx()) / 2
+ offsetY := (height - fitted.Bounds().Dy()) / 2
+ draw.Draw(canvas, image.Rect(offsetX, offsetY,
+ offsetX+fitted.Bounds().Dx(), offsetY+fitted.Bounds().Dy()),
+ fitted, image.Point{}, draw.Over)
+ result = canvas
+ }
+
+ // Determine output path
+ srcExt := filepath.Ext(path)
+ ext := outputFormat
+ if ext == "" {
+ ext = srcExt
+ }
+ if !strings.HasPrefix(ext, ".") {
+ ext = "." + ext
+ }
+
+ stem := strings.TrimSuffix(filepath.Base(path), srcExt)
+ var dest string
+ if outputFolder != "" {
+ os.MkdirAll(outputFolder, 0755)
+ dest = filepath.Join(outputFolder, stem+ext)
+ } else {
+ dest = filepath.Join(filepath.Dir(path), stem+ext)
+ }
+
+ // imaging.Save handles format detection from extension
+ opts := []imaging.EncodeOption{}
+ extLower := strings.ToLower(ext)
+ if extLower == ".jpg" || extLower == ".jpeg" || extLower == ".webp" {
+ opts = append(opts, imaging.JPEGQuality(quality))
+ }
+ if extLower == ".png" {
+ opts = append(opts, imaging.PNGCompressionLevel(png.DefaultCompression))
+ }
+
+ if err := imaging.Save(result, dest, opts...); err != nil {
+ return "", err
+ }
+
+ return dest, nil
+}
+
+// parseHexColor parses a CSS hex colour like "#FF0000" to a color.NRGBA.
+func parseHexColor(s string) color.NRGBA {
+ s = strings.TrimPrefix(s, "#")
+ if len(s) != 6 {
+ return color.NRGBA{A: 255} // fallback to black
+ }
+ var r, g, b uint8
+ fmt.Sscanf(s, "%02x%02x%02x", &r, &g, &b)
+ return color.NRGBA{R: r, G: g, B: b, A: 255}
+}
diff --git a/go/internal/resizer/scanner.go b/go/internal/resizer/scanner.go
new file mode 100644
index 0000000..97afc24
--- /dev/null
+++ b/go/internal/resizer/scanner.go
@@ -0,0 +1,153 @@
+package resizer
+
+import (
+ "fmt"
+ "os"
+ "sync"
+ "time"
+
+ "github.com/exterex/morphic/internal/shared"
+)
+
+// ScanJob represents a running or completed resize job.
+type ScanJob struct {
+ shared.Job
+ mu sync.Mutex
+
+ Folder string `json:"folder"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Mode string `json:"mode"`
+ OutputFolder string `json:"output_folder,omitempty"`
+ BgColor string `json:"bg_color"`
+ Quality int `json:"quality"`
+ Total int `json:"total"`
+ Processed int `json:"processed"`
+ Results []map[string]interface{} `json:"results,omitempty"`
+ Errors []map[string]interface{} `json:"errors,omitempty"`
+}
+
+var store = shared.NewJobStore[ScanJob]()
+
+func init() {
+ store.StartCleanup(30*time.Minute, func(j *ScanJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+// StartJob creates and launches a new resize job.
+func StartJob(folder string, width, height int, mode, outputFolder, bgColor string, quality int) string {
+ job := &ScanJob{
+ Job: shared.NewJob(),
+ Folder: folder,
+ Width: width,
+ Height: height,
+ Mode: mode,
+ OutputFolder: outputFolder,
+ BgColor: bgColor,
+ Quality: quality,
+ }
+ job.Status = shared.JobStatusRunning
+ store.Set(job.ID, job)
+ go runResize(job)
+ return job.ID
+}
+
+// GetJob retrieves a job by ID.
+func GetJob(id string) (*ScanJob, bool) {
+ return store.Get(id)
+}
+
+func runResize(job *ScanJob) {
+ defer func() {
+ if r := recover(); r != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = fmt.Sprintf("%v", r)
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ }
+ }()
+
+ job.mu.Lock()
+ job.Message = fmt.Sprintf("Scanning folder: %s", job.Folder)
+ job.mu.Unlock()
+
+ files, err := shared.FindImageFiles(job.Folder)
+ if err != nil {
+ job.mu.Lock()
+ job.Status = shared.JobStatusFailed
+ job.Error = err.Error()
+ job.DoneAt = time.Now()
+ job.mu.Unlock()
+ return
+ }
+
+ job.mu.Lock()
+ job.Total = len(files)
+ job.mu.Unlock()
+
+ if len(files) == 0 {
+ job.mu.Lock()
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ job.Message = "No image files found."
+ job.mu.Unlock()
+ return
+ }
+
+ errCount := 0
+ for i, f := range files {
+ origSize := f.Size
+ dest, resizeErr := ResizeImage(
+ f.Path, job.Width, job.Height, job.Mode,
+ job.OutputFolder, job.BgColor, job.Quality, "",
+ )
+
+ result := map[string]interface{}{
+ "source": f.Path,
+ }
+
+ if resizeErr != nil {
+ errCount++
+ result["destination"] = nil
+ result["status"] = "error"
+ result["error"] = resizeErr.Error()
+
+ job.mu.Lock()
+ job.Errors = append(job.Errors, map[string]interface{}{
+ "path": f.Path,
+ "error": resizeErr.Error(),
+ })
+ job.mu.Unlock()
+ } else {
+ var newSize int64
+ if info, err := os.Stat(dest); err == nil {
+ newSize = info.Size()
+ }
+ result["destination"] = dest
+ result["status"] = "ok"
+ result["original_size"] = origSize
+ result["new_size"] = newSize
+ result["original_size_fmt"] = shared.FormatFileSize(origSize)
+ result["new_size_fmt"] = shared.FormatFileSize(newSize)
+ }
+
+ job.mu.Lock()
+ job.Results = append(job.Results, result)
+ job.Processed = i + 1
+ job.Progress = float64(i+1) / float64(job.Total)
+ job.Message = fmt.Sprintf("Resizing: %d/%d (%d errors)", i+1, job.Total, errCount)
+ job.mu.Unlock()
+ }
+
+ job.mu.Lock()
+ job.Status = shared.JobStatusDone
+ job.Progress = 1.0
+ job.DoneAt = time.Now()
+ elapsed := job.DoneAt.Sub(job.StartedAt).Seconds()
+ job.Message = fmt.Sprintf("Done! Resized %d images in %s. %d error(s).",
+ job.Processed, shared.FormatDuration(elapsed), errCount)
+ job.mu.Unlock()
+}
diff --git a/go/internal/shared/utils.go b/go/internal/shared/utils.go
index 15662fd..7d129c0 100644
--- a/go/internal/shared/utils.go
+++ b/go/internal/shared/utils.go
@@ -126,6 +126,29 @@ func FormatFileSize(sizeBytes int64) string {
return fmt.Sprintf("%.2f TB", size)
}
+// NormaliseExt lowercases and resolves aliases (.jpeg → .jpg).
+func NormaliseExt(ext string) string {
+ ext = strings.ToLower(ext)
+ if alias, ok := Aliases[ext]; ok {
+ return alias
+ }
+ return ext
+}
+
+// IsImage returns true if the file extension is a known image type.
+func IsImage(path string) bool {
+ ext := NormaliseExt(strings.ToLower(filepath.Ext(path)))
+ _, ok := ImageExtensions[ext]
+ return ok
+}
+
+// IsVideo returns true if the file extension is a known video type.
+func IsVideo(path string) bool {
+ ext := NormaliseExt(strings.ToLower(filepath.Ext(path)))
+ _, ok := VideoExtensions[ext]
+ return ok
+}
+
// FormatDuration formats duration in human-readable format.
func FormatDuration(seconds float64) string {
hours := int(seconds) / 3600
diff --git a/go/web/routes_converter.go b/go/web/routes_converter.go
new file mode 100644
index 0000000..a6641ed
--- /dev/null
+++ b/go/web/routes_converter.go
@@ -0,0 +1,275 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/converter"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+var conversionStore = shared.NewJobStore[conversionJob]()
+
+type conversionJob struct {
+ shared.Job
+ Total int `json:"total"`
+ Completed int `json:"completed"`
+ CurrentFile string `json:"current_file"`
+ Results []map[string]interface{} `json:"results"`
+}
+
+func init() {
+ conversionStore.StartCleanup(30*time.Minute, func(j *conversionJob) time.Time {
+ return j.DoneAt
+ })
+}
+
+func registerConverterRoutes(r *gin.Engine) {
+ g := r.Group("/api/converter")
+ {
+ g.POST("/scan", handleConverterScan)
+ g.GET("/formats", handleConverterFormats)
+ g.POST("/convert", handleConverterConvert)
+ g.GET("/progress/:id", handleConverterProgress)
+ g.GET("/progress/:id/poll", handleConverterPoll)
+ g.POST("/delete", handleConverterDelete)
+ }
+}
+
+func handleConverterScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ IncludeSubfolders *bool `json:"include_subfolders"`
+ FilterType string `json:"filter_type"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ includeSub := true
+ if req.IncludeSubfolders != nil {
+ includeSub = *req.IncludeSubfolders
+ }
+ filterType := req.FilterType
+ if filterType != "images" && filterType != "videos" && filterType != "both" {
+ filterType = "both"
+ }
+
+ result, err := converter.ScanFolder(req.Folder, includeSub, filterType)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, result)
+}
+
+func handleConverterFormats(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "image": converter.ImageConversions,
+ "video": converter.VideoConversions,
+ })
+}
+
+func handleConverterConvert(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ TargetExt string `json:"target_ext"`
+ DeleteOriginal bool `json:"delete_original"`
+ AV1CRF *int `json:"av1_crf"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 || req.TargetExt == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "files and target_ext required"})
+ return
+ }
+
+ av1CRF := 0
+ if req.AV1CRF != nil {
+ av1CRF = *req.AV1CRF
+ }
+
+ job := &conversionJob{
+ Job: shared.NewJob(),
+ Total: len(req.Files),
+ }
+ job.Status = shared.JobStatusRunning
+ conversionStore.Set(job.ID, job)
+
+ go runConversion(job, req.Files, req.TargetExt, req.DeleteOriginal, av1CRF)
+
+ c.JSON(http.StatusAccepted, gin.H{"job_id": job.ID})
+}
+
+func runConversion(job *conversionJob, files []string, targetExt string, deleteOriginal bool, av1CRF int) {
+ for i, source := range files {
+ job.CurrentFile = source
+
+ result := map[string]interface{}{
+ "source": source,
+ "source_deleted": false,
+ }
+
+ origSize := int64(0)
+ if info, err := os.Stat(source); err == nil {
+ origSize = info.Size()
+ }
+
+ dest, err := converter.ConvertFile(source, targetExt, "", av1CRF)
+ if err != nil {
+ result["destination"] = nil
+ result["status"] = "error"
+ result["error"] = err.Error()
+ } else {
+ newSize := int64(0)
+ if info, err := os.Stat(dest); err == nil {
+ newSize = info.Size()
+ }
+
+ result["destination"] = dest
+ result["status"] = "ok"
+ result["original_size"] = origSize
+ result["new_size"] = newSize
+ result["original_size_fmt"] = shared.FormatFileSize(origSize)
+ result["new_size_fmt"] = shared.FormatFileSize(newSize)
+
+ // Delete original only if explicitly requested and safe
+ if deleteOriginal && dest != "" {
+ absSrc, _ := absPath(source)
+ absDest, _ := absPath(dest)
+ if absSrc != absDest && newSize > 0 {
+ if err := os.Remove(source); err == nil {
+ result["source_deleted"] = true
+ }
+ }
+ }
+ }
+
+ job.Results = append(job.Results, result)
+ job.Completed = i + 1
+ }
+
+ job.Status = shared.JobStatusDone
+ job.CurrentFile = ""
+ job.DoneAt = time.Now()
+}
+
+func handleConverterProgress(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := conversionStore.Get(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "total": job.Total,
+ "completed": job.Completed,
+ "current_file": job.CurrentFile,
+ "results": job.Results,
+ "error": job.Error,
+ })
+}
+
+func handleConverterPoll(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := conversionStore.Get(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ lastStr := c.Query("last")
+ last := -1
+ if lastStr != "" {
+ for i := 0; i < len(lastStr); i++ {
+ if lastStr[i] >= '0' && lastStr[i] <= '9' {
+ last = last*10 + int(lastStr[i]-'0')
+ }
+ }
+ }
+
+ deadline := time.Now().Add(10 * time.Second)
+ for time.Now().Before(deadline) {
+ if job.Completed != last || job.Status == shared.JobStatusDone {
+ break
+ }
+ time.Sleep(300 * time.Millisecond)
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "total": job.Total,
+ "completed": job.Completed,
+ "current_file": job.CurrentFile,
+ "results": job.Results,
+ "error": job.Error,
+ })
+}
+
+func handleConverterDelete(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"})
+ return
+ }
+
+ var results []map[string]interface{}
+ totalFreed := int64(0)
+
+ for _, fp := range req.Files {
+ info, err := os.Stat(fp)
+ if err != nil {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ if info.IsDir() {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ size := info.Size()
+ if err := os.Remove(fp); err != nil {
+ if os.IsPermission(err) {
+ results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"})
+ } else {
+ results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()})
+ }
+ } else {
+ totalFreed += size
+ results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size})
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": results,
+ "total_freed": totalFreed,
+ "total_freed_formatted": shared.FormatFileSize(totalFreed),
+ })
+}
+
+func absPath(p string) (string, error) {
+ abs, err := os.Getwd()
+ if err != nil {
+ return p, err
+ }
+ if len(p) > 0 && p[0] == '/' {
+ return p, nil
+ }
+ return abs + "/" + p, nil
+}
diff --git a/go/web/routes_dupfinder.go b/go/web/routes_dupfinder.go
new file mode 100644
index 0000000..d804539
--- /dev/null
+++ b/go/web/routes_dupfinder.go
@@ -0,0 +1,150 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/dupfinder"
+ "github.com/exterex/morphic/internal/shared"
+)
+
+func registerDupfinderRoutes(r *gin.Engine) {
+ g := r.Group("/api/dupfinder")
+ {
+ g.POST("/scan", handleDupfinderScan)
+ g.GET("/scan/:id/status", handleDupfinderStatus)
+ g.GET("/scan/:id/results", handleDupfinderResults)
+ g.POST("/delete", handleDupfinderDelete)
+ }
+}
+
+func handleDupfinderScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Type string `json:"type"`
+ ImageThreshold float64 `json:"image_threshold"`
+ VideoThreshold float64 `json:"video_threshold"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ if req.Type == "" {
+ req.Type = "both"
+ }
+ if req.Type != "images" && req.Type != "videos" && req.Type != "both" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "type must be images, videos, or both"})
+ return
+ }
+ if req.ImageThreshold == 0 {
+ req.ImageThreshold = shared.DefaultImageThreshold
+ }
+ if req.VideoThreshold == 0 {
+ req.VideoThreshold = shared.DefaultVideoThreshold
+ }
+
+ jobID := dupfinder.StartJob(req.Folder, req.Type, req.ImageThreshold, req.VideoThreshold)
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleDupfinderStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := dupfinder.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ elapsed := 0.0
+ if !job.StartedAt.IsZero() {
+ end := job.DoneAt
+ if end.IsZero() {
+ end = time.Now()
+ }
+ elapsed = end.Sub(job.StartedAt).Seconds()
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ "total_files_found": job.TotalFound,
+ "total_files_processed": job.TotalProcessed,
+ "elapsed_seconds": round1(elapsed),
+ })
+}
+
+func handleDupfinderResults(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := dupfinder.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ if job.Status != "done" && job.Status != "failed" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Scan not finished yet"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "image_groups": job.ImageGroups,
+ "video_groups": job.VideoGroups,
+ "space_savings": job.SpaceSavings,
+ "space_savings_formatted": shared.FormatFileSize(job.SpaceSavings),
+ })
+}
+
+func handleDupfinderDelete(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"})
+ return
+ }
+
+ var results []map[string]interface{}
+ totalFreed := int64(0)
+
+ for _, fp := range req.Files {
+ info, err := os.Stat(fp)
+ if err != nil {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ if info.IsDir() {
+ results = append(results, map[string]interface{}{"path": fp, "status": "not_found"})
+ continue
+ }
+ size := info.Size()
+ if err := os.Remove(fp); err != nil {
+ if os.IsPermission(err) {
+ results = append(results, map[string]interface{}{"path": fp, "status": "permission_denied"})
+ } else {
+ results = append(results, map[string]interface{}{"path": fp, "status": "error", "error": err.Error()})
+ }
+ } else {
+ totalFreed += size
+ results = append(results, map[string]interface{}{"path": fp, "status": "deleted", "size_freed": size})
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": results,
+ "total_freed": totalFreed,
+ "total_freed_formatted": shared.FormatFileSize(totalFreed),
+ })
+}
diff --git a/go/web/routes_inspector.go b/go/web/routes_inspector.go
new file mode 100644
index 0000000..fe129df
--- /dev/null
+++ b/go/web/routes_inspector.go
@@ -0,0 +1,163 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/inspector"
+)
+
+func registerInspectorRoutes(r *gin.Engine) {
+ g := r.Group("/api/inspector")
+ {
+ g.POST("/scan", handleInspectorScan)
+ g.GET("/scan/:id/status", handleInspectorStatus)
+ g.GET("/scan/:id/results", handleInspectorResults)
+ g.POST("/exif/edit", handleExifEdit)
+ g.POST("/exif/strip", handleExifStrip)
+ }
+}
+
+func handleInspectorScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Mode string `json:"mode"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ if req.Mode == "" {
+ req.Mode = "exif"
+ }
+ if req.Mode != "exif" && req.Mode != "integrity" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be 'exif' or 'integrity'"})
+ return
+ }
+
+ jobID := inspector.StartJob(req.Folder, req.Mode)
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleInspectorStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := inspector.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ elapsed := 0.0
+ if !job.StartedAt.IsZero() {
+ end := job.DoneAt
+ if end.IsZero() {
+ end = time.Now()
+ }
+ elapsed = end.Sub(job.StartedAt).Seconds()
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "mode": job.Mode,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ "total_files": job.Total,
+ "processed_files": job.Processed,
+ "elapsed_seconds": round1(elapsed),
+ })
+}
+
+func handleInspectorResults(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := inspector.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ if job.Status != "done" && job.Status != "failed" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Scan not finished yet"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "mode": job.Mode,
+ "results": job.Results,
+ "total_files": job.Total,
+ })
+}
+
+func handleExifEdit(c *gin.Context) {
+ var req struct {
+ File string `json:"file"`
+ Updates map[string]interface{} `json:"updates"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.File == "" || !isFile(req.File) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid file: " + req.File})
+ return
+ }
+ if len(req.Updates) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No updates provided"})
+ return
+ }
+
+ // EXIF editing with go-exif is read-only; for now return not-implemented
+ // to match the API contract. A future iteration could use a subprocess.
+ c.JSON(http.StatusNotImplemented, gin.H{
+ "error": "EXIF editing is not yet supported in the Go version",
+ })
+}
+
+func handleExifStrip(c *gin.Context) {
+ var req struct {
+ Files []string `json:"files"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if len(req.Files) == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "No files specified"})
+ return
+ }
+
+ results := inspector.StripExifBatch(req.Files)
+ successCount := 0
+ for _, r := range results {
+ if s, ok := r["success"].(bool); ok && s {
+ successCount++
+ }
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": results,
+ "total": len(req.Files),
+ "success_count": successCount,
+ })
+}
+
+func isFile(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+
+func isDir(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && info.IsDir()
+}
+
+func round1(f float64) float64 {
+ return float64(int(f*10+0.5)) / 10
+}
diff --git a/go/web/routes_resizer.go b/go/web/routes_resizer.go
new file mode 100644
index 0000000..6028f22
--- /dev/null
+++ b/go/web/routes_resizer.go
@@ -0,0 +1,111 @@
+package web
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/exterex/morphic/internal/resizer"
+)
+
+func registerResizerRoutes(r *gin.Engine) {
+ g := r.Group("/api/resizer")
+ {
+ g.POST("/scan", handleResizerScan)
+ g.GET("/scan/:id/status", handleResizerStatus)
+ g.GET("/scan/:id/results", handleResizerResults)
+ }
+}
+
+func handleResizerScan(c *gin.Context) {
+ var req struct {
+ Folder string `json:"folder"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ Mode string `json:"mode"`
+ BgColor string `json:"bg_color"`
+ Quality int `json:"quality"`
+ OutputFolder string `json:"output_folder"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Folder == "" || !isDir(req.Folder) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid folder: " + req.Folder})
+ return
+ }
+ if req.Width <= 0 || req.Height <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "width and height must be positive"})
+ return
+ }
+ if req.Mode == "" {
+ req.Mode = "fit"
+ }
+ if !resizer.IsValidMode(req.Mode) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be one of fit, fill, stretch, pad"})
+ return
+ }
+ if req.BgColor == "" {
+ req.BgColor = "#000000"
+ }
+ if req.Quality == 0 {
+ req.Quality = 90
+ }
+
+ jobID := resizer.StartJob(
+ req.Folder, req.Width, req.Height, req.Mode,
+ req.OutputFolder, req.BgColor, req.Quality,
+ )
+ c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
+}
+
+func handleResizerStatus(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := resizer.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ elapsed := 0.0
+ if !job.StartedAt.IsZero() {
+ end := job.DoneAt
+ if end.IsZero() {
+ end = time.Now()
+ }
+ elapsed = end.Sub(job.StartedAt).Seconds()
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ "total_files": job.Total,
+ "processed_files": job.Processed,
+ "elapsed_seconds": round1(elapsed),
+ })
+}
+
+func handleResizerResults(c *gin.Context) {
+ id := c.Param("id")
+ job, ok := resizer.GetJob(id)
+ if !ok {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Job not found"})
+ return
+ }
+
+ if job.Status != "done" && job.Status != "failed" {
+ c.JSON(http.StatusConflict, gin.H{"error": "Job not finished yet"})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "results": job.Results,
+ "errors": job.Errors,
+ "total_files": job.Total,
+ "processed_files": job.Processed,
+ })
+}
diff --git a/go/web/server.go b/go/web/server.go
index 5f34087..824a3e0 100644
--- a/go/web/server.go
+++ b/go/web/server.go
@@ -40,6 +40,10 @@ func SetupRouter() *gin.Engine {
// Register API route groups
registerSharedRoutes(r)
registerOrganizerRoutes(r)
+ registerInspectorRoutes(r)
+ registerResizerRoutes(r)
+ registerConverterRoutes(r)
+ registerDupfinderRoutes(r)
return r
}
From bbeda6bedd49b06a4067fb3e3131b243d1258885 Mon Sep 17 00:00:00 2001
From: exTerEX <30706568+exTerEX@users.noreply.github.com>
Date: Tue, 31 Mar 2026 18:26:57 +0200
Subject: [PATCH 03/16] fix: align shared & organizer routes with frontend JS
API
- Shared: add GET /api/browse (in-page dir browser), POST /api/browse/native
- Shared: rename /api/system-info to /api/system_info, add ffmpeg encoders
- Shared: add GET /api/media for full-size media preview
- Organizer: rename /api/organizer/scan to /plan
- Organizer: execute accepts {job_id} in body instead of URL param
- Organizer: status returns unified 'plan' array, plan_count, conflicts, execution
- Organizer: PlanRename accepts start_seq parameter
---
go/internal/organizer/renamer.go | 4 +-
go/internal/organizer/scanner.go | 95 ++++++++++++++++++----
go/web/routes_organizer.go | 72 ++++++++++++-----
go/web/routes_shared.go | 131 ++++++++++++++++++++++++++++++-
4 files changed, 261 insertions(+), 41 deletions(-)
diff --git a/go/internal/organizer/renamer.go b/go/internal/organizer/renamer.go
index 93e54ad..304ace7 100644
--- a/go/internal/organizer/renamer.go
+++ b/go/internal/organizer/renamer.go
@@ -55,7 +55,7 @@ type fileWithDate struct {
}
// PlanRename creates a rename plan for the given files.
-func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry {
+func PlanRename(files []string, tmpl string, operation string, startSeq int) []RenamePlanEntry {
// Sort by (date, path) for consistent sequencing
fwd := make([]fileWithDate, len(files))
for i, f := range files {
@@ -72,7 +72,7 @@ func PlanRename(files []string, tmpl string, operation string) []RenamePlanEntry
destSet := make(map[string]int) // destination -> first index
for i, f := range fwd {
- newName := RenderName(tmpl, f.Path, i+1)
+ newName := RenderName(tmpl, f.Path, startSeq+i)
destPath := filepath.Join(filepath.Dir(f.Path), newName)
entry := RenamePlanEntry{
diff --git a/go/internal/organizer/scanner.go b/go/internal/organizer/scanner.go
index ffea6d1..f4f4a32 100644
--- a/go/internal/organizer/scanner.go
+++ b/go/internal/organizer/scanner.go
@@ -14,11 +14,11 @@ type ScanJob struct {
Folder string `json:"folder"`
Phase string `json:"phase"`
- ScanType string `json:"scan_type"`
Template string `json:"template"`
Destination string `json:"destination"`
Operation string `json:"operation"`
Mode string `json:"mode"`
+ StartSeq int `json:"start_seq"`
Files []string `json:"files,omitempty"`
SortPlan []SortPlanEntry `json:"sort_plan,omitempty"`
RenamePlan []RenamePlanEntry `json:"rename_plan,omitempty"`
@@ -35,7 +35,7 @@ func init() {
}
// StartPlanJob starts a new planning job.
-func StartPlanJob(folder, mode, template, destination, operation, scanType string) string {
+func StartPlanJob(folder, mode, template, destination, operation string, startSeq int) string {
job := &ScanJob{
Job: shared.NewJob(),
Folder: folder,
@@ -44,7 +44,7 @@ func StartPlanJob(folder, mode, template, destination, operation, scanType strin
Template: template,
Destination: destination,
Operation: operation,
- ScanType: scanType,
+ StartSeq: startSeq,
}
job.Status = shared.JobStatusRunning
@@ -78,17 +78,7 @@ func ExecuteJob(id string) bool {
}
func runPlan(job *ScanJob) {
- var files []shared.FileInfo
- var err error
-
- switch job.ScanType {
- case "images":
- files, err = shared.FindImageFiles(job.Folder)
- case "videos":
- files, err = shared.FindVideoFiles(job.Folder)
- default:
- files, err = shared.FindAllMediaFiles(job.Folder)
- }
+ files, err := shared.FindAllMediaFiles(job.Folder)
if err != nil {
job.mu.Lock()
@@ -122,7 +112,7 @@ func runPlan(job *ScanJob) {
job.SortPlan = plan
job.mu.Unlock()
case "rename":
- plan := PlanRename(paths, job.Template, job.Operation)
+ plan := PlanRename(paths, job.Template, job.Operation, job.StartSeq)
job.mu.Lock()
job.RenamePlan = plan
job.mu.Unlock()
@@ -164,3 +154,78 @@ func runExecute(job *ScanJob) {
job.DoneAt = time.Now()
job.mu.Unlock()
}
+
+// GetUnifiedPlan returns the plan entries in a unified format matching the
+// Python API's response (each entry has "source", "destination", "conflict").
+func GetUnifiedPlan(job *ScanJob) []map[string]interface{} {
+ job.mu.Lock()
+ defer job.mu.Unlock()
+
+ if job.Mode == "sort" {
+ plan := make([]map[string]interface{}, len(job.SortPlan))
+ for i, e := range job.SortPlan {
+ entry := map[string]interface{}{
+ "source": e.Source,
+ "destination": e.Destination,
+ }
+ if e.Status == "conflict" {
+ entry["conflict"] = true
+ }
+ plan[i] = entry
+ }
+ return plan
+ }
+
+ plan := make([]map[string]interface{}, len(job.RenamePlan))
+ for i, e := range job.RenamePlan {
+ entry := map[string]interface{}{
+ "source": e.Source,
+ "destination": e.Destination,
+ }
+ if e.Status == "conflict" {
+ entry["conflict"] = true
+ }
+ plan[i] = entry
+ }
+ return plan
+}
+
+// GetExecutionResult returns execution stats matching the Python API format.
+func GetExecutionResult(job *ScanJob) map[string]interface{} {
+ job.mu.Lock()
+ defer job.mu.Unlock()
+
+ completed := 0
+ errors := 0
+ skipped := 0
+
+ if job.Mode == "sort" {
+ for _, e := range job.SortPlan {
+ switch e.Status {
+ case "done":
+ completed++
+ case "error":
+ errors++
+ case "conflict", "skipped":
+ skipped++
+ }
+ }
+ } else {
+ for _, e := range job.RenamePlan {
+ switch e.Status {
+ case "done":
+ completed++
+ case "error":
+ errors++
+ case "conflict", "skipped":
+ skipped++
+ }
+ }
+ }
+
+ return map[string]interface{}{
+ "completed": completed,
+ "errors": errors,
+ "skipped": skipped,
+ }
+}
diff --git a/go/web/routes_organizer.go b/go/web/routes_organizer.go
index 988f9b8..6ba02a4 100644
--- a/go/web/routes_organizer.go
+++ b/go/web/routes_organizer.go
@@ -10,20 +10,20 @@ import (
func registerOrganizerRoutes(r *gin.Engine) {
g := r.Group("/api/organizer")
{
- g.POST("/scan", handleOrganizerScan)
- g.POST("/execute/:id", handleOrganizerExecute)
+ g.POST("/plan", handleOrganizerPlan)
+ g.POST("/execute", handleOrganizerExecute)
g.GET("/status/:id", handleOrganizerStatus)
}
}
-func handleOrganizerScan(c *gin.Context) {
+func handleOrganizerPlan(c *gin.Context) {
var req struct {
Folder string `json:"folder"`
Mode string `json:"mode"`
Template string `json:"template"`
Destination string `json:"destination"`
Operation string `json:"operation"`
- ScanType string `json:"scan_type"`
+ StartSeq int `json:"start_seq"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -35,23 +35,33 @@ func handleOrganizerScan(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "folder is required"})
return
}
+ if req.StartSeq <= 0 {
+ req.StartSeq = 1
+ }
jobID := organizer.StartPlanJob(
req.Folder, req.Mode, req.Template,
- req.Destination, req.Operation, req.ScanType,
+ req.Destination, req.Operation, req.StartSeq,
)
c.JSON(http.StatusAccepted, gin.H{"job_id": jobID})
}
func handleOrganizerExecute(c *gin.Context) {
- id := c.Param("id")
- if !organizer.ExecuteJob(id) {
+ var req struct {
+ JobID string `json:"job_id"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.JobID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "job_id required"})
+ return
+ }
+
+ if !organizer.ExecuteJob(req.JobID) {
c.JSON(http.StatusNotFound, gin.H{"error": "job not found or not in planned state"})
return
}
- c.JSON(http.StatusAccepted, gin.H{"status": "executing"})
+ c.JSON(http.StatusAccepted, gin.H{"status": "executing", "job_id": req.JobID})
}
func handleOrganizerStatus(c *gin.Context) {
@@ -62,16 +72,38 @@ func handleOrganizerStatus(c *gin.Context) {
return
}
- c.JSON(http.StatusOK, gin.H{
- "id": job.ID,
- "status": job.Status,
- "phase": job.Phase,
- "progress": job.Progress,
- "message": job.Message,
- "error": job.Error,
- "total": job.Total,
- "processed": job.Processed,
- "sort_plan": job.SortPlan,
- "rename_plan": job.RenamePlan,
- })
+ resp := gin.H{
+ "id": job.ID,
+ "status": job.Status,
+ "phase": job.Phase,
+ "mode": job.Mode,
+ "operation": job.Operation,
+ "progress": job.Progress,
+ "message": job.Message,
+ "error": job.Error,
+ }
+
+ // Include plan when planning is done (matches Python's response)
+ if job.Phase == "planned" || job.Phase == "executing" || job.Phase == "done" {
+ plan := organizer.GetUnifiedPlan(job)
+ resp["plan"] = plan
+ resp["plan_count"] = len(plan)
+
+ conflicts := 0
+ for _, entry := range plan {
+ if _, ok := entry["conflict"]; ok {
+ if entry["conflict"] == true {
+ conflicts++
+ }
+ }
+ }
+ resp["conflicts"] = conflicts
+ }
+
+ // Include execution results when done
+ if job.Phase == "done" {
+ resp["execution"] = organizer.GetExecutionResult(job)
+ }
+
+ c.JSON(http.StatusOK, resp)
}
diff --git a/go/web/routes_shared.go b/go/web/routes_shared.go
index e417557..a4cf167 100644
--- a/go/web/routes_shared.go
+++ b/go/web/routes_shared.go
@@ -1,27 +1,90 @@
package web
import (
+ "mime"
"net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
"runtime"
+ "sort"
+ "strings"
"github.com/gin-gonic/gin"
"github.com/exterex/morphic/internal/shared"
)
func registerSharedRoutes(r *gin.Engine) {
- r.POST("/api/browse", handleBrowse)
+ r.GET("/api/browse", handleBrowseDirectory)
+ r.POST("/api/browse/native", handleBrowseNative)
r.GET("/api/thumbnail", handleThumbnail)
- r.GET("/api/system-info", handleSystemInfo)
+ r.GET("/api/system_info", handleSystemInfo)
+ r.GET("/api/media", handleMedia)
}
-func handleBrowse(c *gin.Context) {
+// handleBrowseDirectory lists directories for the in-page folder browser.
+func handleBrowseDirectory(c *gin.Context) {
+ path := c.Query("path")
+ if path == "" {
+ home, _ := os.UserHomeDir()
+ path = home
+ }
+
+ path = filepath.Clean(path)
+ info, err := os.Stat(path)
+ if err != nil || !info.IsDir() {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Not a directory"})
+ return
+ }
+
+ entries, _ := os.ReadDir(path)
+ type dirEntry struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+ Type string `json:"type"`
+ }
+ var dirs []dirEntry
+ for _, e := range entries {
+ if strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if e.IsDir() {
+ dirs = append(dirs, dirEntry{
+ Name: e.Name(),
+ Path: filepath.Join(path, e.Name()),
+ Type: "directory",
+ })
+ }
+ }
+ sort.Slice(dirs, func(i, j int) bool {
+ return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
+ })
+
+ parent := filepath.Dir(path)
+ var parentPtr interface{} = parent
+ if parent == path {
+ parentPtr = nil
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "current": path,
+ "parent": parentPtr,
+ "entries": dirs,
+ })
+}
+
+// handleBrowseNative opens the OS-native folder picker dialog.
+func handleBrowseNative(c *gin.Context) {
folder, err := shared.OpenNativeFolderDialog()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if folder == "" {
- c.JSON(http.StatusOK, gin.H{"folder": nil})
+ c.JSON(http.StatusOK, gin.H{
+ "folder": nil,
+ "message": "Dialog cancelled or unavailable",
+ })
return
}
c.JSON(http.StatusOK, gin.H{"folder": folder})
@@ -52,11 +115,71 @@ func handleThumbnail(c *gin.Context) {
}
func handleSystemInfo(c *gin.Context) {
+ ffmpegInfo := gin.H{
+ "installed": false,
+ "hwaccels": []string{},
+ "encoders": []string{},
+ "nvenc_available": false,
+ }
+
+ if _, err := exec.LookPath("ffmpeg"); err == nil {
+ ffmpegInfo["installed"] = true
+
+ if out, err := exec.Command("ffmpeg", "-hide_banner", "-encoders").
+ CombinedOutput(); err == nil {
+ var encoders []string
+ for _, line := range strings.Split(string(out), "\n") {
+ line = strings.TrimSpace(line)
+ if len(line) > 0 && (line[0] == 'V' || line[0] == 'A') {
+ encoders = append(encoders, line)
+ }
+ }
+ ffmpegInfo["encoders"] = encoders
+ for _, e := range encoders {
+ if strings.Contains(e, "nvenc") {
+ ffmpegInfo["nvenc_available"] = true
+ break
+ }
+ }
+ }
+ }
+
c.JSON(http.StatusOK, gin.H{
"version": shared.Version,
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"go": runtime.Version(),
"cpus": runtime.NumCPU(),
+ "ffmpeg": ffmpegInfo,
})
}
+
+// handleMedia serves a media file for full-size preview.
+func handleMedia(c *gin.Context) {
+ filePath := c.Query("path")
+ if filePath == "" {
+ c.Status(http.StatusBadRequest)
+ return
+ }
+
+ filePath = filepath.Clean(filePath)
+ info, err := os.Stat(filePath)
+ if err != nil || info.IsDir() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+
+ ext := shared.NormaliseExt(filepath.Ext(filePath))
+ _, isImg := shared.ImageExtensions[ext]
+ _, isVid := shared.VideoExtensions[ext]
+ if !isImg && !isVid {
+ c.Status(http.StatusForbidden)
+ return
+ }
+
+ contentType := mime.TypeByExtension(filepath.Ext(filePath))
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+ c.File(filePath)
+}
From 6fc067dc822f2f9a5130fe5c4c7df69479475c52 Mon Sep 17 00:00:00 2001
From: exTerEX <30706568+exTerEX@users.noreply.github.com>
Date: Tue, 31 Mar 2026 18:48:05 +0200
Subject: [PATCH 04/16] feat: add AVIF thumbnail/image support + lazy thumbnail
loading
---
Makefile | 8 ++--
{go/cmd => cmd}/morphic/main.go | 0
go/go.mod => go.mod | 0
go/go.sum => go.sum | 0
.../converter/constants.go | 0
.../converter/converter.go | 43 ++++++++++++++++++-
.../converter/scanner.go | 0
{go/internal => internal}/dupfinder/images.go | 0
.../dupfinder/scanner.go | 0
{go/internal => internal}/dupfinder/videos.go | 0
{go/internal => internal}/inspector/exif.go | 0
.../inspector/integrity.go | 28 ++++++++++++
.../inspector/scanner.go | 0
.../organizer/date_sorter.go | 0
.../organizer/renamer.go | 0
.../organizer/scanner.go | 0
.../resizer/operations.go | 0
{go/internal => internal}/resizer/scanner.go | 0
{go/internal => internal}/shared/constants.go | 0
.../shared/file_browser.go | 0
{go/internal => internal}/shared/jobs.go | 0
.../shared/thumbnails.go | 36 ++++++++++++++--
{go/internal => internal}/shared/utils.go | 0
{go/web => web}/routes_converter.go | 0
{go/web => web}/routes_dupfinder.go | 0
{go/web => web}/routes_inspector.go | 0
{go/web => web}/routes_organizer.go | 0
{go/web => web}/routes_resizer.go | 0
{go/web => web}/routes_shared.go | 0
{go/web => web}/server.go | 3 +-
{go/web => web}/static/app.js | 2 +-
{go/web => web}/static/style.css | 0
{go/web => web}/templates/index.html | 10 ++---
33 files changed, 115 insertions(+), 15 deletions(-)
rename {go/cmd => cmd}/morphic/main.go (100%)
rename go/go.mod => go.mod (100%)
rename go/go.sum => go.sum (100%)
rename {go/internal => internal}/converter/constants.go (100%)
rename {go/internal => internal}/converter/converter.go (78%)
rename {go/internal => internal}/converter/scanner.go (100%)
rename {go/internal => internal}/dupfinder/images.go (100%)
rename {go/internal => internal}/dupfinder/scanner.go (100%)
rename {go/internal => internal}/dupfinder/videos.go (100%)
rename {go/internal => internal}/inspector/exif.go (100%)
rename {go/internal => internal}/inspector/integrity.go (83%)
rename {go/internal => internal}/inspector/scanner.go (100%)
rename {go/internal => internal}/organizer/date_sorter.go (100%)
rename {go/internal => internal}/organizer/renamer.go (100%)
rename {go/internal => internal}/organizer/scanner.go (100%)
rename {go/internal => internal}/resizer/operations.go (100%)
rename {go/internal => internal}/resizer/scanner.go (100%)
rename {go/internal => internal}/shared/constants.go (100%)
rename {go/internal => internal}/shared/file_browser.go (100%)
rename {go/internal => internal}/shared/jobs.go (100%)
rename {go/internal => internal}/shared/thumbnails.go (76%)
rename {go/internal => internal}/shared/utils.go (100%)
rename {go/web => web}/routes_converter.go (100%)
rename {go/web => web}/routes_dupfinder.go (100%)
rename {go/web => web}/routes_inspector.go (100%)
rename {go/web => web}/routes_organizer.go (100%)
rename {go/web => web}/routes_resizer.go (100%)
rename {go/web => web}/routes_shared.go (100%)
rename {go/web => web}/server.go (91%)
rename {go/web => web}/static/app.js (99%)
rename {go/web => web}/static/style.css (100%)
rename {go/web => web}/templates/index.html (98%)
diff --git a/Makefile b/Makefile
index db0e089..01f10b7 100644
--- a/Makefile
+++ b/Makefile
@@ -50,16 +50,16 @@ clean:
.PHONY: go-build go-test go-run go-vet go-tidy
go-tidy:
- cd go && go mod tidy
+ go mod tidy
go-build: go-tidy
- cd go && go build -o ../bin/morphic ./cmd/morphic
+ go build -o ./bin/morphic ./cmd/morphic
go-test:
- cd go && go test ./...
+ go test ./...
go-vet:
- cd go && go vet ./...
+ go vet ./...
go-run: go-build
./bin/morphic
diff --git a/go/cmd/morphic/main.go b/cmd/morphic/main.go
similarity index 100%
rename from go/cmd/morphic/main.go
rename to cmd/morphic/main.go
diff --git a/go/go.mod b/go.mod
similarity index 100%
rename from go/go.mod
rename to go.mod
diff --git a/go/go.sum b/go.sum
similarity index 100%
rename from go/go.sum
rename to go.sum
diff --git a/go/internal/converter/constants.go b/internal/converter/constants.go
similarity index 100%
rename from go/internal/converter/constants.go
rename to internal/converter/constants.go
diff --git a/go/internal/converter/converter.go b/internal/converter/converter.go
similarity index 78%
rename from go/internal/converter/converter.go
rename to internal/converter/converter.go
index 6477cf5..ac3a201 100644
--- a/go/internal/converter/converter.go
+++ b/internal/converter/converter.go
@@ -86,9 +86,15 @@ func ConvertImage(source, targetExt, outputDir string) (string, error) {
strings.TrimSuffix(filepath.Base(dest), ext)+"_converted"+ext)
}
+ sourceExt := shared.NormaliseExt(strings.ToLower(filepath.Ext(source)))
+ if sourceExt == ".avif" || ext == ".avif" {
+ return convertImageByFFmpeg(source, dest, ext)
+ }
+
img, err := imaging.Open(source)
if err != nil {
- return "", err
+ // Relax: fallback to ffmpeg conversion for special unsupported formats
+ return convertImageByFFmpeg(source, dest, ext)
}
opts := []imaging.EncodeOption{}
@@ -104,6 +110,41 @@ func ConvertImage(source, targetExt, outputDir string) (string, error) {
return dest, nil
}
+func convertImageByFFmpeg(source, dest, ext string) (string, error) {
+ if !ffmpegAvailable() {
+ return "", fmt.Errorf("ffmpeg is not installed or not on PATH")
+ }
+
+ cmd := []string{"ffmpeg", "-y", "-i", source}
+
+ extLower := strings.ToLower(ext)
+ if extLower == ".avif" {
+ if ffmpegHasEncoder("libsvtav1") {
+ cmd = append(cmd, "-c:v", "libsvtav1", "-crf", "28", "-preset", "fast")
+ } else if ffmpegHasEncoder("libaom-av1") {
+ cmd = append(cmd, "-c:v", "libaom-av1", "-crf", "28", "-preset", "fast")
+ } else {
+ cmd = append(cmd, "-c:v", "libx264")
+ }
+ } else if extLower == ".webp" {
+ cmd = append(cmd, "-c:v", "libwebp")
+ } else if extLower == ".png" || extLower == ".jpg" || extLower == ".jpeg" || extLower == ".bmp" || extLower == ".gif" {
+ // no explicit codec required
+ } else {
+ // generic fallback for unknown image extensions
+ }
+
+ cmd = append(cmd, dest)
+
+ out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("ffmpeg image conversion failed: %s", strings.TrimSpace(string(out)))
+ }
+
+ return dest, nil
+}
+
+
// ConvertVideo converts a video file using ffmpeg.
func ConvertVideo(source, targetExt, outputDir string, av1CRF int) (string, error) {
if !ffmpegAvailable() {
diff --git a/go/internal/converter/scanner.go b/internal/converter/scanner.go
similarity index 100%
rename from go/internal/converter/scanner.go
rename to internal/converter/scanner.go
diff --git a/go/internal/dupfinder/images.go b/internal/dupfinder/images.go
similarity index 100%
rename from go/internal/dupfinder/images.go
rename to internal/dupfinder/images.go
diff --git a/go/internal/dupfinder/scanner.go b/internal/dupfinder/scanner.go
similarity index 100%
rename from go/internal/dupfinder/scanner.go
rename to internal/dupfinder/scanner.go
diff --git a/go/internal/dupfinder/videos.go b/internal/dupfinder/videos.go
similarity index 100%
rename from go/internal/dupfinder/videos.go
rename to internal/dupfinder/videos.go
diff --git a/go/internal/inspector/exif.go b/internal/inspector/exif.go
similarity index 100%
rename from go/internal/inspector/exif.go
rename to internal/inspector/exif.go
diff --git a/go/internal/inspector/integrity.go b/internal/inspector/integrity.go
similarity index 83%
rename from go/internal/inspector/integrity.go
rename to internal/inspector/integrity.go
index 37848e7..40482e3 100644
--- a/go/internal/inspector/integrity.go
+++ b/internal/inspector/integrity.go
@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"os/exec"
+ "path/filepath"
"strconv"
"strings"
"sync"
@@ -46,6 +47,33 @@ func CheckImage(path string) IntegrityResult {
return r
}
+ ext := strings.ToLower(filepath.Ext(path))
+ if alias, ok := shared.Aliases[ext]; ok {
+ ext = alias
+ }
+
+ if ext == ".avif" {
+ // Use ffprobe for AVIF validation because imaging may not decode it.
+ cmd := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height", "-of", "csv=p=0", path)
+ out, err := cmd.Output()
+ if err != nil {
+ r.Error = err.Error()
+ return r
+ }
+ parts := strings.Split(strings.TrimSpace(string(out)), ",")
+ if len(parts) >= 2 {
+ r.Width, _ = strconv.Atoi(parts[1])
+ }
+ if len(parts) >= 3 {
+ r.Height, _ = strconv.Atoi(parts[2])
+ }
+ if len(parts) >= 1 {
+ r.Format = parts[0]
+ }
+ r.Valid = true
+ return r
+ }
+
img, err := imaging.Open(path)
if err != nil {
r.Error = err.Error()
diff --git a/go/internal/inspector/scanner.go b/internal/inspector/scanner.go
similarity index 100%
rename from go/internal/inspector/scanner.go
rename to internal/inspector/scanner.go
diff --git a/go/internal/organizer/date_sorter.go b/internal/organizer/date_sorter.go
similarity index 100%
rename from go/internal/organizer/date_sorter.go
rename to internal/organizer/date_sorter.go
diff --git a/go/internal/organizer/renamer.go b/internal/organizer/renamer.go
similarity index 100%
rename from go/internal/organizer/renamer.go
rename to internal/organizer/renamer.go
diff --git a/go/internal/organizer/scanner.go b/internal/organizer/scanner.go
similarity index 100%
rename from go/internal/organizer/scanner.go
rename to internal/organizer/scanner.go
diff --git a/go/internal/resizer/operations.go b/internal/resizer/operations.go
similarity index 100%
rename from go/internal/resizer/operations.go
rename to internal/resizer/operations.go
diff --git a/go/internal/resizer/scanner.go b/internal/resizer/scanner.go
similarity index 100%
rename from go/internal/resizer/scanner.go
rename to internal/resizer/scanner.go
diff --git a/go/internal/shared/constants.go b/internal/shared/constants.go
similarity index 100%
rename from go/internal/shared/constants.go
rename to internal/shared/constants.go
diff --git a/go/internal/shared/file_browser.go b/internal/shared/file_browser.go
similarity index 100%
rename from go/internal/shared/file_browser.go
rename to internal/shared/file_browser.go
diff --git a/go/internal/shared/jobs.go b/internal/shared/jobs.go
similarity index 100%
rename from go/internal/shared/jobs.go
rename to internal/shared/jobs.go
diff --git a/go/internal/shared/thumbnails.go b/internal/shared/thumbnails.go
similarity index 76%
rename from go/internal/shared/thumbnails.go
rename to internal/shared/thumbnails.go
index c8a1220..52dcd16 100644
--- a/go/internal/shared/thumbnails.go
+++ b/internal/shared/thumbnails.go
@@ -41,9 +41,35 @@ func GenerateImageThumbnail(path string, size int) ([]byte, error) {
}
thumbnailCache.mu.RUnlock()
+ ext := strings.ToLower(filepath.Ext(path))
+ if alias, ok := Aliases[ext]; ok {
+ ext = alias
+ }
+
+ if ext == ".avif" {
+ data, err := extractImageFrame(path, "00:00:00", size)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate AVIF thumbnail %s: %w", path, err)
+ }
+
+ thumbnailCache.mu.Lock()
+ thumbnailCache.store[cacheKey] = data
+ thumbnailCache.mu.Unlock()
+
+ return data, nil
+ }
+
img, err := imaging.Open(path, imaging.AutoOrientation(true))
if err != nil {
- return nil, fmt.Errorf("failed to open image %s: %w", path, err)
+ // Fallback to ffmpeg for formats that imaging can't decode.
+ ffData, ffErr := extractImageFrame(path, "00:00:00", size)
+ if ffErr == nil {
+ thumbnailCache.mu.Lock()
+ thumbnailCache.store[cacheKey] = ffData
+ thumbnailCache.mu.Unlock()
+ return ffData, nil
+ }
+ return nil, fmt.Errorf("failed to open image %s: %w (ffmpeg fallback: %v)", path, err, ffErr)
}
thumb := imaging.Fit(img, size, size, imaging.Lanczos)
@@ -92,10 +118,14 @@ func GenerateVideoThumbnail(path string, size int) ([]byte, error) {
}
func extractVideoFrame(videoPath, seekTime string, size int) ([]byte, error) {
+ return extractImageFrame(videoPath, seekTime, size)
+}
+
+func extractImageFrame(imagePath, seekTime string, size int) ([]byte, error) {
cmd := exec.Command("ffmpeg",
"-ss", seekTime,
- "-i", videoPath,
- "-vframes", "1",
+ "-i", imagePath,
+ "-frames:v", "1",
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease", size, size),
"-f", "image2pipe",
"-vcodec", "mjpeg",
diff --git a/go/internal/shared/utils.go b/internal/shared/utils.go
similarity index 100%
rename from go/internal/shared/utils.go
rename to internal/shared/utils.go
diff --git a/go/web/routes_converter.go b/web/routes_converter.go
similarity index 100%
rename from go/web/routes_converter.go
rename to web/routes_converter.go
diff --git a/go/web/routes_dupfinder.go b/web/routes_dupfinder.go
similarity index 100%
rename from go/web/routes_dupfinder.go
rename to web/routes_dupfinder.go
diff --git a/go/web/routes_inspector.go b/web/routes_inspector.go
similarity index 100%
rename from go/web/routes_inspector.go
rename to web/routes_inspector.go
diff --git a/go/web/routes_organizer.go b/web/routes_organizer.go
similarity index 100%
rename from go/web/routes_organizer.go
rename to web/routes_organizer.go
diff --git a/go/web/routes_resizer.go b/web/routes_resizer.go
similarity index 100%
rename from go/web/routes_resizer.go
rename to web/routes_resizer.go
diff --git a/go/web/routes_shared.go b/web/routes_shared.go
similarity index 100%
rename from go/web/routes_shared.go
rename to web/routes_shared.go
diff --git a/go/web/server.go b/web/server.go
similarity index 91%
rename from go/web/server.go
rename to web/server.go
index 824a3e0..7bbcaa3 100644
--- a/go/web/server.go
+++ b/web/server.go
@@ -34,7 +34,8 @@ func SetupRouter() *gin.Engine {
// Index route
r.GET("/", func(c *gin.Context) {
- c.HTML(http.StatusOK, "index.html", nil)
+ initialFolder := ""
+ c.HTML(http.StatusOK, "index.html", gin.H{"initial_folder": initialFolder})
})
// Register API route groups
diff --git a/go/web/static/app.js b/web/static/app.js
similarity index 99%
rename from go/web/static/app.js
rename to web/static/app.js
index 5261a06..dd6b7e4 100644
--- a/go/web/static/app.js
+++ b/web/static/app.js
@@ -373,7 +373,7 @@ function renderConvResults() {
const failed = convLastFailedFiles.has(f.path);
thtml += `
-
+
diff --git a/go/web/static/style.css b/web/static/style.css
similarity index 100%
rename from go/web/static/style.css
rename to web/static/style.css
diff --git a/go/web/templates/index.html b/web/templates/index.html
similarity index 98%
rename from go/web/templates/index.html
rename to web/templates/index.html
index b885376..5c893d6 100644
--- a/go/web/templates/index.html
+++ b/web/templates/index.html
@@ -37,7 +37,7 @@ ⚡ Morphic