Compare commits

2 Commits

Author SHA1 Message Date
Ein Anderssono 98320c8235 v0.9.3: add cleanup command
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:40:12 +02:00
Ein Anderssono 84e70bda76 v0.9.2: add manifest repair
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:32:20 +02:00
7 changed files with 411 additions and 8 deletions
+16
View File
@@ -2,6 +2,22 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page. This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.9.3
Cleanup release.
- Add `cleanup --out <dir>` to remove files not referenced by the manifest.
- Add `cleanup --dry-run` to preview orphaned files before deletion.
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
## v0.9.2
Manifest repair release.
- Add `manifest repair --out <dir>` to fill missing manifest size metadata from files on disk.
- Add `manifest repair --checksum sha256` to fill missing manifest checksums.
- Add `manifest repair --dry-run` to preview repairs without writing manifest updates.
## v0.9.1 ## v0.9.1
Deep checksum verification release. Deep checksum verification release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.9.1 VERSION := 0.9.3
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge BRIDGE_DIR := bridge
+2
View File
@@ -118,6 +118,8 @@ Verify a backup later:
```bash ```bash
./bin/photoscli verify --out ./photos-backup --manifest sqlite ./bin/photoscli verify --out ./photos-backup --manifest sqlite
./bin/photoscli verify --out ./photos-backup --deep ./bin/photoscli verify --out ./photos-backup --deep
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
./bin/photoscli cleanup --out ./photos-backup --dry-run
``` ```
## Commands ## Commands
+7 -7
View File
@@ -1,18 +1,18 @@
# v0.9.1 # v0.9.3
This release adds deep checksum verification for manifest-backed backups. This release adds safe cleanup for manifest-backed backups.
## Highlights ## Highlights
- Add `verify --deep` to recompute SHA-256 checksums when manifest entries include checksums. - Add `cleanup --out <dir>` to remove files not referenced by the manifest.
- Report `checksum-mismatch` when file contents differ from the manifest checksum. - Add `cleanup --dry-run` to preview orphaned files before deletion.
- Keep normal `verify` unchanged unless `--deep` is selected. - Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
- `--deep` works with JSONL and SQLite manifests. - Cleanup works with JSONL and SQLite manifests.
## Assets ## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.9.1-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `photoscli-0.9.3-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide. - `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target. Intel Macs are not currently a supported release target.
+18
View File
@@ -838,6 +838,24 @@ Deep-verify checksum-backed manifests:
`--deep` recomputes SHA-256 only for manifest entries that already have checksum metadata. `--deep` recomputes SHA-256 only for manifest entries that already have checksum metadata.
Repair missing manifest metadata from files already present on disk:
```bash
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256 --dry-run
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256
```
Repair fills missing size/checksum metadata and skips missing, zero-byte, or unreadable files.
Preview and remove files not referenced by the manifest:
```bash
./bin/photoscli cleanup --out ./PhotosBackup --dry-run
./bin/photoscli cleanup --out ./PhotosBackup
```
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
## Safe Operating Practices ## Safe Operating Practices
- Run `--dry-run` before the first large backup. - Run `--dry-run` before the first large backup.
+167
View File
@@ -8,6 +8,7 @@ import (
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -92,6 +93,10 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdDiff(args[1:], stdout, stderr, bridge) return cmdDiff(args[1:], stdout, stderr, bridge)
case "verify": case "verify":
return cmdVerify(args[1:], stdout, stderr) return cmdVerify(args[1:], stdout, stderr)
case "manifest":
return cmdManifest(args[1:], stdout, stderr)
case "cleanup":
return cmdCleanup(args[1:], stdout, stderr)
case "retry-failed": case "retry-failed":
return cmdRetryFailed(args[1:], stdout, stderr, bridge) return cmdRetryFailed(args[1:], stdout, stderr, bridge)
case "failures": case "failures":
@@ -137,6 +142,8 @@ USAGE
photoscli report --out <dir> [--manifest jsonl|sqlite] photoscli report --out <dir> [--manifest jsonl|sqlite]
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite] photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
photoscli verify --out <dir> [--manifest jsonl|sqlite] photoscli verify --out <dir> [--manifest jsonl|sqlite]
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
photoscli retry-failed --out <dir> photoscli retry-failed --out <dir>
photoscli retry-failed --out <dir> --clear-on-success photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir> photoscli failures list --out <dir>
@@ -182,6 +189,12 @@ COMMANDS
--sidecar to require photoscli schema/generator and exported filename metadata. --sidecar to require photoscli schema/generator and exported filename metadata.
Add --deep to verify manifest SHA-256 checksums when present. Add --deep to verify manifest SHA-256 checksums when present.
manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
Fill missing manifest size/checksum metadata from files that exist on disk.
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
Remove files not referenced by the manifest. Use --dry-run first.
retry-failed --out <dir> retry-failed --out <dir>
Retry assets previously written to failures.jsonl. Retry assets previously written to failures.jsonl.
@@ -2383,6 +2396,160 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
return 0 return 0
} }
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
keep := map[string]bool{
"downloads.jsonl": true,
"downloads.db": true,
"export.log": true,
"failures.jsonl": true,
}
for _, e := range entries {
checkPath := e.Path
if checkPath == "" {
checkPath = e.Filename
}
if checkPath == "" {
continue
}
clean := filepath.Clean(checkPath)
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
continue
}
keep[clean] = true
keep[filepath.Clean(sidecarPath(clean))] = true
keep[filepath.Clean(jsonSidecarPath(clean))] = true
}
dryRun := hasFlag(args, "--dry-run")
removed := 0
skipped := 0
_ = filepath.WalkDir(outDir, func(path string, d fs.DirEntry, _ error) error {
if path == outDir {
return nil
}
rel, _ := filepath.Rel(outDir, path)
rel = filepath.Clean(rel)
if d.IsDir() {
if rel == ".photoscli" || strings.HasPrefix(rel, ".photoscli"+string(os.PathSeparator)) {
return filepath.SkipDir
}
return nil
}
if keep[rel] {
return nil
}
fmt.Fprintf(stdout, "%s\torphan\n", rel)
removed++
if !dryRun {
if err := removeFunc(path); err != nil {
skipped++
}
}
return nil
})
fmt.Fprintf(stdout, "removed\t%d\nskipped\t%d\n", removed, skipped)
return exitOK
}
func cmdManifest(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "repair" {
fmt.Fprintln(stderr, "error: expected manifest repair")
return exitErr
}
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
checksumMode := flagValWithDefault(args, "--checksum", "none")
if checksumMode != "none" && checksumMode != "sha256" {
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
m, err := manifest.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
defer m.Close()
reader := m.(manifest.EntryReader)
if !hasFlag(args, "--dry-run") {
if err := m.OpenAppend(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
}
repaired := 0
skipped := 0
for id, entry := range reader.Entries() {
checkPath := entry.Path
if checkPath == "" {
checkPath = entry.Filename
}
if checkPath == "" {
skipped++
continue
}
fullPath := filepath.Join(outDir, checkPath)
info, err := statFunc(fullPath)
if err != nil || info.IsDir() || info.Size() == 0 {
skipped++
continue
}
updated := entry
updated.ID = id
changed := false
if updated.Size != info.Size() {
updated.Size = info.Size()
changed = true
}
if checksumMode == "sha256" && updated.Checksum == "" {
checksum, err := fileSHA256(fullPath)
if err != nil {
skipped++
continue
}
updated.Checksum = checksum
changed = true
}
if !changed {
continue
}
repaired++
fmt.Fprintf(stdout, "%s\t%s\trepaired\n", id, checkPath)
if !hasFlag(args, "--dry-run") {
m.AddEntry(updated)
}
}
if !hasFlag(args, "--dry-run") {
if err := m.Save(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
}
fmt.Fprintf(stdout, "repaired\t%d\nskipped\t%d\n", repaired, skipped)
return exitOK
}
func cmdSidecar(args []string, stdout, stderr io.Writer) int { func cmdSidecar(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "inspect" { if len(args) < 1 || args[0] != "inspect" {
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>") fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
+200
View File
@@ -39,6 +39,15 @@ type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") } func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type testFileInfo struct{ size int64 }
func (t testFileInfo) Name() string { return "test" }
func (t testFileInfo) Size() int64 { return t.size }
func (t testFileInfo) Mode() os.FileMode { return 0644 }
func (t testFileInfo) ModTime() time.Time { return time.Time{} }
func (t testFileInfo) IsDir() bool { return false }
func (t testFileInfo) Sys() any { return nil }
type noEntryManifest struct{} type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false } func (noEntryManifest) Has(string) bool { return false }
@@ -4306,6 +4315,197 @@ func TestVerifyDeepChecksums(t *testing.T) {
} }
} }
func TestManifestRepair(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "missing", Filename: "missing.jpg", Path: "missing.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "x1\tphoto.jpg\trepaired") || !strings.Contains(out, "skipped\t1") {
t.Fatalf("dry repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
if got := manifest.LoadJSONL(dir).Entries()["x1"].Checksum; got != "" {
t.Fatalf("dry run wrote checksum %q", got)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t1") {
t.Fatalf("repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
entry := manifest.LoadJSONL(dir).Entries()["x1"]
if entry.Size != 3 || entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
t.Fatalf("entry not repaired: %+v", entry)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t0") {
t.Fatalf("second repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestManifestRepairErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected manifest repair") {
t.Fatalf("manifest missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("manifest repair missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--checksum") {
t.Fatalf("manifest repair bad checksum rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("manifest repair bad manifest rc=%d stderr=%q", rc, stderr)
}
fileOut := filepath.Join(dir, "file-out")
if err := os.WriteFile(fileOut, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", fileOut}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open append rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", badDBDir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open rc=%d stderr=%q", rc, stderr)
}
saveDir := t.TempDir()
sm := manifest.LoadJSONL(saveDir)
if err := sm.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(saveDir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
sm.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Exported: time.Now().Unix()})
sm.Close()
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("save") })
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", saveDir}, &mockBridge{})
manifest.SetJSONLSaveHook(oldHook)
if rc != exitErr || !strings.Contains(stderr, "save") {
t.Fatalf("manifest repair save rc=%d stderr=%q", rc, stderr)
}
}
func TestManifestRepairBranches(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "empty"})
m.AddEntry(manifest.Entry{ID: "stat-only", Filename: "missing.jpg", Path: "missing.jpg", Checksum: "", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "hash-fail", Filename: "hash.jpg", Path: "hash.jpg", Checksum: "", Exported: time.Now().Unix()})
m.Close()
oldStat := statFunc
statFunc = func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, "hash.jpg") {
return testFileInfo{size: 3}, nil
}
return oldStat(path)
}
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
statFunc = oldStat
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t3") {
t.Fatalf("manifest repair branches rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestCleanup(t *testing.T) {
dir := t.TempDir()
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil {
t.Fatalf("dry-run removed orphan: %v", err)
}
out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) {
t.Fatalf("orphan still exists or bad error: %v", err)
}
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
t.Fatalf("kept file missing %s: %v", path, err)
}
}
}
func TestCleanupErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
if rc != exitOK || stderr != "" {
t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr)
}
dir = t.TempDir()
oldRemove := removeFunc
removeFunc = func(string) error { return fmt.Errorf("remove") }
if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
removeFunc = oldRemove
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") {
t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestVerifySidecarBranches(t *testing.T) { func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
subdir := filepath.Join(dir, "sub") subdir := filepath.Join(dir, "sub")