diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c1217..a8148e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ 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.4 + +Doctor release. + +- Add `doctor` to check Photos access and optional backup health. +- Add `doctor --out ` to report backup directory, manifest entries, and failure count. +- Add `doctor --json` for scriptable diagnostics. + ## v0.9.3 Cleanup release. diff --git a/Makefile b/Makefile index 9bf4c13..92cb673 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.9.3 +VERSION := 0.9.4 RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_NOTES := RELEASE_NOTES.md BRIDGE_DIR := bridge diff --git a/README.md b/README.md index 5f95381..b54dc90 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Verify a backup later: ./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 +./bin/photoscli doctor --out ./photos-backup ``` ## Commands diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 99b9e2c..696ae36 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,18 +1,18 @@ -# v0.9.3 +# v0.9.4 -This release adds safe cleanup for manifest-backed backups. +This release adds `doctor` diagnostics for Photos access and backups. ## Highlights -- Add `cleanup --out ` 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. -- Cleanup works with JSONL and SQLite manifests. +- Add `doctor` to check Photos access. +- Add `doctor --out ` to report backup directory, manifest entries, and failure count. +- Add `doctor --json` for scriptable diagnostics. +- `doctor` is read-only and does not modify backup data. ## Assets - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). -- `photoscli-0.9.3-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. +- `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. - `USERGUIDE.md`: standalone user guide. Intel Macs are not currently a supported release target. diff --git a/USERGUIDE.md b/USERGUIDE.md index ec7f12d..5548da4 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -856,6 +856,16 @@ Preview and remove files not referenced by the manifest: Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media. +Run read-only diagnostics: + +```bash +./bin/photoscli doctor +./bin/photoscli doctor --out ./PhotosBackup +./bin/photoscli doctor --out ./PhotosBackup --json +``` + +Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count. + ## Safe Operating Practices - Run `--dry-run` before the first large backup. diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index f5ac17f..90e3454 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -97,6 +97,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { return cmdManifest(args[1:], stdout, stderr) case "cleanup": return cmdCleanup(args[1:], stdout, stderr) + case "doctor": + return cmdDoctor(args[1:], stdout, stderr, bridge) case "retry-failed": return cmdRetryFailed(args[1:], stdout, stderr, bridge) case "failures": @@ -144,6 +146,7 @@ USAGE photoscli verify --out [--manifest jsonl|sqlite] photoscli manifest repair --out [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run] photoscli cleanup --out [--manifest jsonl|sqlite] [--dry-run] + photoscli doctor [--out ] [--manifest jsonl|sqlite] [--json] photoscli retry-failed --out photoscli retry-failed --out --clear-on-success photoscli failures list --out @@ -195,6 +198,9 @@ COMMANDS cleanup --out [--manifest jsonl|sqlite] [--dry-run] Remove files not referenced by the manifest. Use --dry-run first. + doctor [--out ] [--manifest jsonl|sqlite] [--json] + Check Photos access and optional backup/manifest health without changing data. + retry-failed --out Retry assets previously written to failures.jsonl. @@ -2396,6 +2402,64 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool) return 0 } +func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int { + result := map[string]any{"photos_access": "ok"} + problems := 0 + if rc := mustAuth(stderr, bridge); rc != exitOK { + result["photos_access"] = "denied" + problems++ + } + outDir := flagVal(args, "--out") + if outDir != "" { + result["out"] = outDir + if info, err := statFunc(outDir); err != nil || !info.IsDir() { + result["backup_dir"] = "missing" + problems++ + } else { + result["backup_dir"] = "ok" + 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 { + result["manifest"] = "error" + problems++ + } else { + result["manifest"] = string(mf) + result["entries"] = len(entries) + } + failures := loadFailures(outDir) + result["failures"] = len(failures) + if len(failures) > 0 { + problems++ + } + } + } + if hasFlag(args, "--json") { + result["problems"] = problems + if err := json.NewEncoder(stdout).Encode(result); err != nil { + fmt.Fprintf(stderr, "error: %v\n", err) + return exitErr + } + } else { + keys := make([]string, 0, len(result)) + for k := range result { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Fprintf(stdout, "%s\t%v\n", k, result[k]) + } + fmt.Fprintf(stdout, "problems\t%d\n", problems) + } + if problems > 0 { + return exitPartial + } + return exitOK +} + func cmdCleanup(args []string, stdout, stderr io.Writer) int { outDir := flagVal(args, "--out") if outDir == "" { diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index bf77ef4..ce442ee 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -4506,6 +4506,53 @@ func TestCleanupErrors(t *testing.T) { } } +func TestDoctor(t *testing.T) { + dir := t.TempDir() + 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: 4, Cloud: "local", Exported: time.Now().Unix()}) + m.Close() + out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{}) + if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") { + t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr) + } + out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{}) + if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) { + t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr) + } +} + +func TestDoctorProblems(t *testing.T) { + dir := t.TempDir() + appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed")) + out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")}) + if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") { + t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr) + } + _, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{}) + if rc != exitErr || !strings.Contains(stderr, "manifest") { + t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr) + } + out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{}) + if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") { + t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr) + } + badDBDir := t.TempDir() + if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil { + t.Fatal(err) + } + out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{}) + if rc != exitPartial || !strings.Contains(out, "manifest\terror") { + t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr) + } + stderrBuf := &bytes.Buffer{} + if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") { + t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String()) + } +} + func TestVerifySidecarBranches(t *testing.T) { dir := t.TempDir() subdir := filepath.Join(dir, "sub")