Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd048d9f3 |
@@ -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.
|
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 <dir>` to report backup directory, manifest entries, and failure count.
|
||||||
|
- Add `doctor --json` for scriptable diagnostics.
|
||||||
|
|
||||||
## v0.9.3
|
## v0.9.3
|
||||||
|
|
||||||
Cleanup release.
|
Cleanup release.
|
||||||
|
|||||||
@@ -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.3
|
VERSION := 0.9.4
|
||||||
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
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ Verify a backup later:
|
|||||||
./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 manifest repair --out ./photos-backup --checksum sha256 --dry-run
|
||||||
./bin/photoscli cleanup --out ./photos-backup --dry-run
|
./bin/photoscli cleanup --out ./photos-backup --dry-run
|
||||||
|
./bin/photoscli doctor --out ./photos-backup
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|||||||
+7
-7
@@ -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
|
## Highlights
|
||||||
|
|
||||||
- Add `cleanup --out <dir>` to remove files not referenced by the manifest.
|
- Add `doctor` to check Photos access.
|
||||||
- Add `cleanup --dry-run` to preview orphaned files before deletion.
|
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
|
||||||
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
|
- Add `doctor --json` for scriptable diagnostics.
|
||||||
- Cleanup works with JSONL and SQLite manifests.
|
- `doctor` is read-only and does not modify backup data.
|
||||||
|
|
||||||
## Assets
|
## Assets
|
||||||
|
|
||||||
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
|
- `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.
|
- `USERGUIDE.md`: standalone user guide.
|
||||||
|
|
||||||
Intel Macs are not currently a supported release target.
|
Intel Macs are not currently a supported release target.
|
||||||
|
|||||||
@@ -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.
|
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
|
## Safe Operating Practices
|
||||||
|
|
||||||
- Run `--dry-run` before the first large backup.
|
- Run `--dry-run` before the first large backup.
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
|
|||||||
return cmdManifest(args[1:], stdout, stderr)
|
return cmdManifest(args[1:], stdout, stderr)
|
||||||
case "cleanup":
|
case "cleanup":
|
||||||
return cmdCleanup(args[1:], stdout, stderr)
|
return cmdCleanup(args[1:], stdout, stderr)
|
||||||
|
case "doctor":
|
||||||
|
return cmdDoctor(args[1:], stdout, stderr, bridge)
|
||||||
case "retry-failed":
|
case "retry-failed":
|
||||||
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
|
||||||
case "failures":
|
case "failures":
|
||||||
@@ -144,6 +146,7 @@ USAGE
|
|||||||
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 manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
|
||||||
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
|
photoscli doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
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>
|
||||||
@@ -195,6 +198,9 @@ COMMANDS
|
|||||||
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
|
||||||
Remove files not referenced by the manifest. Use --dry-run first.
|
Remove files not referenced by the manifest. Use --dry-run first.
|
||||||
|
|
||||||
|
doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
|
||||||
|
Check Photos access and optional backup/manifest health without changing data.
|
||||||
|
|
||||||
retry-failed --out <dir>
|
retry-failed --out <dir>
|
||||||
Retry assets previously written to failures.jsonl.
|
Retry assets previously written to failures.jsonl.
|
||||||
|
|
||||||
@@ -2396,6 +2402,64 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
|
|||||||
return 0
|
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 {
|
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
|
||||||
outDir := flagVal(args, "--out")
|
outDir := flagVal(args, "--out")
|
||||||
if outDir == "" {
|
if outDir == "" {
|
||||||
|
|||||||
@@ -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) {
|
func TestVerifySidecarBranches(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
subdir := filepath.Join(dir, "sub")
|
subdir := filepath.Join(dir, "sub")
|
||||||
|
|||||||
Reference in New Issue
Block a user