···11node_modules/
22+33+# User config (contains endpoint, bucket, keychain service names)
44+~/.attic/
+11-7
CLAUDE.md
···11# Attic
2233-Deno/TypeScript CLI for backing up iCloud Photos to Scaleway S3. Part of the photo-cloud system (companion: [ladder](https://github.com/tijs/ladder)).
33+Deno/TypeScript CLI for backing up iCloud Photos to S3-compatible storage. Part
44+of the photo-cloud system (companion: [ladder](https://github.com/tijs/ladder)).
4556## Commands
6778```bash
89deno task check # Type check
99-deno task test # Run tests (44 tests)
1010+deno task test # Run tests (54 tests)
1011deno task lint # Lint
1112deno task fmt # Format
1213deno task fmt:check # Check formatting
···16171718```
1819shared/ # @attic/shared — PhotoAsset type, S3 path helpers
1919-cli/ # @attic/cli — commands, storage, manifest, export
2020- src/commands/ # scan, status, backup, verify, rebuild
2121- src/storage/ # S3 client + Keychain credential loading
2020+cli/ # @attic/cli — commands, config, storage, manifest, export
2121+ src/commands/ # init, scan, status, backup, verify, rebuild
2222+ src/config/ # Config file (load, validate, write)
2323+ src/storage/ # Generic S3 client + Keychain credential loading
2224 src/manifest/ # Local JSON manifest with atomic writes
2325 src/export/ # Exporter interface + ladder subprocess integration
2426```
25272628## Reference Docs
27292828-- [Architecture](docs/architecture.md) — pipeline, reader, ladder protocol, manifest, interfaces, design boundaries
3030+- [Architecture](docs/architecture.md) — pipeline, reader, ladder protocol,
3131+ manifest, interfaces, design boundaries
2932- [Asset Metadata](docs/metadata.md) — per-asset JSON schema uploaded to S3
30333134## Conventions
32353336- Files should stay under 500 lines
3437- Use `AssetKind.PHOTO` / `AssetKind.VIDEO` constants, not magic numbers
3535-- S3 keys and UUIDs are validated with regex before interpolation (path traversal prevention)
3838+- S3 keys and UUIDs are validated with regex before interpolation (path
3939+ traversal prevention)
3640- `removeStagedFile()` constrains deletion to the staging directory
+82-35
README.md
···4455# Attic
6677-Back up your iCloud Photos library to Scaleway Object Storage (S3-compatible).
77+Back up your iCloud Photos library to S3-compatible storage.
8899-Attic reads the Photos.sqlite database directly, exports originals via a companion Swift tool called [ladder](https://github.com/tijs/ladder), and uploads them to a Scaleway S3 bucket. A local manifest tracks what has already been backed up so subsequent runs only upload new assets.
99+Attic reads the Photos.sqlite database directly, exports originals via a
1010+companion Swift tool called [ladder](https://github.com/tijs/ladder), and
1111+uploads them to an S3-compatible bucket. A local manifest tracks what has
1212+already been backed up so subsequent runs only upload new assets.
1313+1414+Works with any S3-compatible provider. EU-friendly options include
1515+[Scaleway](https://www.scaleway.com/en/object-storage/),
1616+[Hetzner](https://www.hetzner.com/storage/object-storage), and
1717+[OVH](https://www.ovhcloud.com/en/public-cloud/object-storage/).
10181119## Prerequisites
12201321- [Deno](https://deno.land/) (v2+)
1414-- The [ladder](https://github.com/tijs/ladder) binary. Ladder is a separate Swift tool that uses PhotoKit to export original photo/video files from the Photos library.
1515-- A Scaleway Object Storage bucket and API credentials
2222+- The [ladder](https://github.com/tijs/ladder) binary. Ladder is a separate
2323+ Swift tool that uses PhotoKit to export original photo/video files from the
2424+ Photos library.
2525+- An S3-compatible storage bucket and API credentials
1626- macOS (Photos.sqlite access and Keychain are macOS-only)
17271828## Setup
19292020-Store your Scaleway S3 credentials in the macOS Keychain:
3030+Run the interactive setup:
21312232```bash
2323-security add-generic-password -s attic-s3-access-key -a attic -w "<your-access-key>"
2424-security add-generic-password -s attic-s3-secret-key -a attic -w "<your-secret-key>"
3333+deno task init
2534```
26352727-Build the ladder binary (see [ladder](https://github.com/tijs/ladder) for details):
3636+This prompts for your S3 endpoint, region, bucket name, and credentials. Config
3737+is saved to `~/.attic/config.json` and credentials are stored in the macOS
3838+Keychain.
3939+4040+Build the ladder binary (see [ladder](https://github.com/tijs/ladder) for
4141+details):
28422943```bash
3044git clone https://github.com/tijs/ladder.git
···36503751All commands are run via `deno task`:
38523939-### scan
5353+### init
40544141-Scan the Photos library and print statistics (asset counts, sizes, types, local vs iCloud-only).
5555+Interactive setup — configure S3 connection and store credentials.
42564357```bash
4444-deno task scan
5858+deno task init
4559```
46604747-Optionally pass a custom database path:
6161+### scan
6262+6363+Scan the Photos library and print statistics (asset counts, sizes, types, local
6464+vs iCloud-only).
48654966```bash
5050-deno task scan /path/to/Photos.sqlite
6767+deno task scan
5168```
52695370### status
54715555-Compare the Photos database against the local backup manifest to show how many assets are backed up vs pending.
7272+Compare the Photos database against the local backup manifest to show how many
7373+assets are backed up vs pending.
56745775```bash
5876deno task status
···6684deno task backup
6785```
68866969-Flags (append after `--`):
7070-7171-| Flag | Description |
7272-|---|---|
7373-| `--dry-run` | Show what would be uploaded without uploading |
7474-| `--limit N` | Back up at most N assets |
7575-| `--batch-size N` | Assets per ladder export batch (default: 50) |
7676-| `--type photo\|video` | Only back up photos or videos |
7777-| `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) |
7878-| `--ladder PATH` | Path to the ladder binary (or set `LADDER_PATH` env var) |
7979-| `--db PATH` | Path to Photos.sqlite |
8787+| Flag | Description |
8888+| --------------------- | -------------------------------------------------------- |
8989+| `--dry-run` | Show what would be uploaded without uploading |
9090+| `--limit N` | Back up at most N assets |
9191+| `--batch-size N` | Assets per ladder export batch (default: 50) |
9292+| `--type photo\|video` | Only back up photos or videos |
9393+| `--bucket NAME` | Override bucket from config |
9494+| `--ladder PATH` | Path to the ladder binary (or set `LADDER_PATH` env var) |
9595+| `--db PATH` | Path to Photos.sqlite |
80968197### verify
8298···86102deno task verify
87103```
881048989-| Flag | Description |
9090-|---|---|
9191-| `--deep` | Download each object and re-verify SHA-256 checksum (slow) |
9292-| `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files |
9393-| `--bucket NAME` | S3 bucket name (default: `photo-cloud-storage`) |
105105+| Flag | Description |
106106+| -------------------- | ---------------------------------------------------------- |
107107+| `--deep` | Download each object and re-verify SHA-256 checksum (slow) |
108108+| `--rebuild-manifest` | Reconstruct the local manifest from S3 metadata files |
109109+| `--bucket NAME` | Override bucket from config |
110110+111111+## Configuration
112112+113113+Attic stores its configuration at `~/.attic/config.json`:
114114+115115+```json
116116+{
117117+ "endpoint": "https://s3.fr-par.scw.cloud",
118118+ "region": "fr-par",
119119+ "bucket": "my-photo-backup",
120120+ "pathStyle": true,
121121+ "keychain": {
122122+ "accessKeyService": "attic-s3-access-key",
123123+ "secretKeyService": "attic-s3-secret-key"
124124+ }
125125+}
126126+```
127127+128128+The `keychain` section is optional and defaults to the service names shown
129129+above. Credentials are always stored in the macOS Keychain, never in config
130130+files or environment variables.
131131+132132+`scan` and `status` work without config (they only read Photos.sqlite). `backup`
133133+and `verify` require config and will tell you to run `attic init` if it's
134134+missing.
9413595136## Testing
96137···98139deno task test
99140```
100141101101-Tests use dependency injection with mock implementations for the S3 client and exporter, so no external services or credentials are needed.
142142+Tests use dependency injection with mock implementations for the S3 client and
143143+exporter, so no external services or credentials are needed.
102144103145## Documentation
104146105105-- [Architecture](docs/architecture.md) -- How attic works: the backup pipeline, Photos.sqlite reader, ladder protocol, manifest lifecycle, and design boundaries
106106-- [Asset Metadata](docs/metadata.md) -- Schema reference for the per-asset JSON uploaded to S3
147147+- [Architecture](docs/architecture.md) -- How attic works: the backup pipeline,
148148+ Photos.sqlite reader, ladder protocol, manifest lifecycle, and design
149149+ boundaries
150150+- [Asset Metadata](docs/metadata.md) -- Schema reference for the per-asset JSON
151151+ uploaded to S3
107152108153## Future Plans
109154110110-- **Scheduled backups via launchd** -- A LaunchAgent plist to run backups daily on a dedicated Mac
111111-- **Rendered edit backup** -- Detect and upload edited versions alongside originals (see `docs/plans/`)
155155+- **Scheduled backups via launchd** -- A LaunchAgent plist to run backups daily
156156+ on a dedicated Mac
157157+- **Rendered edit backup** -- Detect and upload edited versions alongside
158158+ originals (see `docs/plans/`)
···11# Architecture
2233-Attic reads the macOS Photos library, exports original files via a companion Swift tool, and uploads them to S3 with rich metadata. A local manifest tracks progress so runs are incremental.
33+Attic reads the macOS Photos library, exports original files via a companion
44+Swift tool, and uploads them to S3 with rich metadata. A local manifest tracks
55+progress so runs are incremental.
4657## System overview
68···21232224`reader.ts` queries Photos.sqlite in two stages:
23252424-**Main query** — a single SELECT joining `ZASSET` and `ZADDITIONALASSETATTRIBUTES` returns core fields: UUID, filename, date, dimensions, GPS, file size, UTI, favorite status, cloud state. Trashed assets are excluded.
2626+**Main query** — a single SELECT joining `ZASSET` and
2727+`ZADDITIONALASSETATTRIBUTES` returns core fields: UUID, filename, date,
2828+dimensions, GPS, file size, UTI, favorite status, cloud state. Trashed assets
2929+are excluded.
25302626-**Enrichment queries** — seven independent queries each build a `Map` keyed by asset primary key (Z_PK). During row mapping, each asset is enriched from these maps with a default of `null` or `[]` if no match exists.
3131+**Enrichment queries** — seven independent queries each build a `Map` keyed by
3232+asset primary key (Z_PK). During row mapping, each asset is enriched from these
3333+maps with a default of `null` or `[]` if no match exists.
27342828-| Query | Source tables | Returns |
2929-|-------|--------------|---------|
3030-| Descriptions | `ZASSETDESCRIPTION` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string>` |
3131-| Albums | `ZGENERICALBUM` → `Z_33ASSETS` | `Map<number, AlbumRef[]>` |
3232-| Keywords | `ZKEYWORD` → `Z_1KEYWORDS` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string[]>` |
3333-| People | `ZPERSON` → `ZDETECTEDFACE` | `Map<number, PersonRef[]>` |
3434-| Edits | `ZUNMANAGEDADJUSTMENT` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, EditInfo>` |
3535-| Rendered resources | `ZINTERNALRESOURCE` (resource type 1) | `Set<number>` |
3535+| Query | Source tables | Returns |
3636+| ------------------ | --------------------------------------------------------- | -------------------------- |
3737+| Descriptions | `ZASSETDESCRIPTION` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string>` |
3838+| Albums | `ZGENERICALBUM` → `Z_33ASSETS` | `Map<number, AlbumRef[]>` |
3939+| Keywords | `ZKEYWORD` → `Z_1KEYWORDS` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, string[]>` |
4040+| People | `ZPERSON` → `ZDETECTEDFACE` | `Map<number, PersonRef[]>` |
4141+| Edits | `ZUNMANAGEDADJUSTMENT` → `ZADDITIONALASSETATTRIBUTES` | `Map<number, EditInfo>` |
4242+| Rendered resources | `ZINTERNALRESOURCE` (resource type 1) | `Set<number>` |
36433737-All enrichment queries go through `safeQuery()`, which catches "no such table" errors silently and logs other failures. This makes the reader resilient across macOS versions where table schemas may differ.
4444+All enrichment queries go through `safeQuery()`, which catches "no such table"
4545+errors silently and logs other failures. This makes the reader resilient across
4646+macOS versions where table schemas may differ.
38473939-People are deduplicated per asset — a person appears at most once even if detected in multiple face regions.
4848+People are deduplicated per asset — a person appears at most once even if
4949+detected in multiple face regions.
40504151### Edit detection
42524343-An asset is considered edited (`hasEdit: true`) only when two conditions are met:
5353+An asset is considered edited (`hasEdit: true`) only when two conditions are
5454+met:
445545561. An entry exists in `ZUNMANAGEDADJUSTMENT` (an edit was performed)
4646-2. An entry exists in `ZINTERNALRESOURCE` with resource type 1 (a rendered file was produced)
5757+2. An entry exists in `ZINTERNALRESOURCE` with resource type 1 (a rendered file
5858+ was produced)
47594848-This distinguishes visual edits from metadata-only adjustments that don't produce a visible render. When `hasEdit` is false, `editedAt` and `editor` are both null.
6060+This distinguishes visual edits from metadata-only adjustments that don't
6161+produce a visible render. When `hasEdit` is false, `editedAt` and `editor` are
6262+both null.
49635064## The backup pipeline
51655252-`backup.ts` orchestrates the full flow: filter → batch → export → upload → manifest.
6666+`backup.ts` orchestrates the full flow: filter → batch → export → upload →
6767+manifest.
53685469### 1. Filter
55705656-Assets are filtered against the manifest to find pending work. Optional filters narrow by type (`--type photo|video`) or count (`--limit N`). Dry run mode stops here.
7171+Assets are filtered against the manifest to find pending work. Optional filters
7272+narrow by type (`--type photo|video`) or count (`--limit N`). Dry run mode stops
7373+here.
57745875### 2. Batch and export
59766060-Pending assets are processed in batches (default 50). Each batch is sent to **ladder**, a companion Swift binary that uses PhotoKit to export original files. Communication is via JSON over stdin/stdout:
7777+Pending assets are processed in batches (default 50). Each batch is sent to
7878+**ladder**, a companion Swift binary that uses PhotoKit to export original
7979+files. Communication is via JSON over stdin/stdout:
61806281```
6382attic → stdin: { "uuids": ["UUID/L0/001", ...], "stagingDir": "/path" }
6483ladder → stdout: { "results": [...], "errors": [...] }
6584```
66856767-Each result includes the file path, size, and SHA-256 hash. PhotoKit identifiers use the `UUID/L0/001` format; attic strips the suffix before further processing. Ladder output is validated at the trust boundary with `assertExportBatchResult()`.
8686+Each result includes the file path, size, and SHA-256 hash. PhotoKit identifiers
8787+use the `UUID/L0/001` format; attic strips the suffix before further processing.
8888+Ladder output is validated at the trust boundary with
8989+`assertExportBatchResult()`.
68906991### 3. Upload
7092···729473951. Reads the staged file from disk
74962. Uploads the original to `originals/{year}/{month}/{uuid}.{ext}`
7575-3. Builds and uploads a metadata JSON to `metadata/assets/{uuid}.json` (see `docs/metadata.md`)
9797+3. Builds and uploads a metadata JSON to `metadata/assets/{uuid}.json` (see
9898+ `docs/metadata.md`)
76994. Updates the in-memory manifest
771005. Cleans up the staged file
781017979-S3 keys are built from UUID and extension, both validated with regex (`/^[A-Za-z0-9._-]+$/` and `/^[a-z0-9]+$/`) to prevent path traversal. Extensions are resolved from the asset's UTI via a lookup table, falling back to the filename extension.
102102+S3 keys are built from UUID and extension, both validated with regex
103103+(`/^[A-Za-z0-9._-]+$/` and `/^[a-z0-9]+$/`) to prevent path traversal.
104104+Extensions are resolved from the asset's UTI via a lookup table, falling back to
105105+the filename extension.
8010681107### 4. Manifest
821088383-The manifest is a JSON file at `~/.attic/manifest.json` mapping UUID to `{ s3Key, checksum, backedUpAt }`. It's saved periodically during backup (every 50 assets by default) and always at the end. Writes are atomic: write to `.tmp`, then rename.
109109+The manifest is a JSON file at `~/.attic/manifest.json` mapping UUID to
110110+`{ s3Key, checksum, backedUpAt }`. It's saved periodically during backup (every
111111+50 assets by default) and always at the end. Writes are atomic: write to `.tmp`,
112112+then rename.
841138585-The manifest can be reconstructed from S3 via `verify --rebuild-manifest`, which reads every `metadata/assets/*.json` file and validates UUID format, S3 key pattern, and checksum format before accepting an entry.
114114+The manifest can be reconstructed from S3 via `verify --rebuild-manifest`, which
115115+reads every `metadata/assets/*.json` file and validates UUID format, S3 key
116116+pattern, and checksum format before accepting an entry.
8611787118## Verification
8811989120`verify.ts` checks backup integrity in two modes:
9012191122- **Quick** (default) — HEAD each S3 key in the manifest, confirm it exists
9292-- **Deep** — download each object, compute SHA-256, compare to the manifest checksum
123123+- **Deep** — download each object, compute SHA-256, compare to the manifest
124124+ checksum
125125+126126+Both modes use a bounded concurrency pool (default 50 workers). Errors are
127127+capped at 1,000 to prevent unbounded memory growth.
128128+129129+## Configuration
931309494-Both modes use a bounded concurrency pool (default 50 workers). Errors are capped at 1,000 to prevent unbounded memory growth.
131131+Attic reads its configuration from `~/.attic/config.json`. The config file
132132+specifies the S3 endpoint, region, bucket, path-style preference, and Keychain
133133+service names. It's created by `attic init` or manually.
134134+135135+`scan` and `status` work without config (they only read Photos.sqlite). `backup`
136136+and `verify` require config and fail fast with a clear message if it's missing
137137+or invalid.
9513896139## Credentials
971409898-S3 credentials are stored in the macOS Keychain under service names `attic-s3-access-key` and `attic-s3-secret-key`. They are read at runtime via `security find-generic-password` — never stored in env vars, config files, or code.
141141+S3 credentials are stored in the macOS Keychain under configurable service names
142142+(defaults: `attic-s3-access-key` and `attic-s3-secret-key`). They are read at
143143+runtime via `security find-generic-password` — never stored in env vars, config
144144+files, or code.
99145100146## Interfaces and testability
101147102148All external dependencies are behind interfaces:
103149104104-| Interface | Real implementation | Mock |
105105-|-----------|-------------------|------|
106106-| `S3Provider` | AWS SDK client for Scaleway | In-memory `Map<string, Uint8Array>` |
107107-| `Exporter` | Ladder subprocess | Returns pre-configured assets from a `Map` |
108108-| `ManifestStore` | File-based JSON with atomic writes | Same implementation, pointed at a temp dir |
109109-| `PhotosDbReader` | SQLite reader for Photos.sqlite | In-memory SQLite with test fixtures |
150150+| Interface | Real implementation | Mock |
151151+| ---------------- | --------------------------------------------- | ------------------------------------------ |
152152+| `S3Provider` | AWS SDK client for any S3-compatible endpoint | In-memory `Map<string, Uint8Array>` |
153153+| `Exporter` | Ladder subprocess | Returns pre-configured assets from a `Map` |
154154+| `ManifestStore` | File-based JSON with atomic writes | Same implementation, pointed at a temp dir |
155155+| `PhotosDbReader` | SQLite reader for Photos.sqlite | In-memory SQLite with test fixtures |
110156111157Tests never hit external services, credentials, or the real Photos library.
112158···114160115161- **Modify Photos.sqlite** — read-only access, always
116162- **Download from iCloud** — relies on Photos having local copies of originals
117117-- **Delete from S3** — the backup is append-only; there is no prune or cleanup command
163163+- **Delete from S3** — the backup is append-only; there is no prune or cleanup
164164+ command
118165- **Back up thumbnails** — only original files and metadata
119166- **Back up adjustment plists** — Apple's edit recipes are not portable
120120-- **Back up rendered edits** — detecting edits is implemented (Phase 1); exporting and uploading rendered versions is planned (Phase 2/3, see `docs/plans/2026-03-13-feat-backup-rendered-edits-plan.md`)
121121-- **Handle slo-mo or Live Photos specially** — these have unique resource types that need dedicated investigation
122122-- **Run on non-macOS** — depends on Photos.sqlite, Keychain, and PhotoKit via ladder
167167+- **Back up rendered edits** — detecting edits is implemented (Phase 1);
168168+ exporting and uploading rendered versions is planned (Phase 2/3, see
169169+ `docs/plans/2026-03-13-feat-backup-rendered-edits-plan.md`)
170170+- **Handle slo-mo or Live Photos specially** — these have unique resource types
171171+ that need dedicated investigation
172172+- **Run on non-macOS** — depends on Photos.sqlite, Keychain, and PhotoKit via
173173+ ladder
···11+# Back Up Rendered Edits Alongside Originals
22+33+**Date:** 2026-03-13 **Status:** Ready for planning
44+55+## What We're Building
66+77+Extend the backup pipeline to detect edited photos/videos and upload the
88+rendered (fullsize JPEG) version alongside the original. Also detect edits made
99+to already-backed-up assets and upload their rendered versions retroactively.
1010+1111+## Why This Approach
1212+1313+The backup should be self-contained and viewable without Apple Photos. Currently
1414+only originals are backed up. Apple Photos edits are non-destructive (the
1515+original is always preserved), but the "finished" version a user actually wants
1616+to see requires either Apple Photos or the adjustment plist to re-render.
1717+Backing up the rendered version makes the backup independently useful.
1818+1919+## Current State
2020+2121+| Metric | Value |
2222+| -------------------------------------------------- | --------------- |
2323+| Total assets | 37,289 |
2424+| Edited assets | 1,312 (3.5%) |
2525+| Rendered versions (fullsize JPEG, resource type 1) | 1,672 resources |
2626+| Locally available renders | 1,480 |
2727+| Original size (edited subset) | ~6.2 GB |
2828+| Rendered size (edited subset) | ~13.7 GB |
2929+3030+Edit sources: Apple Photos (1,181), slo-mo (47), Google Photos (42), Markup
3131+(11), Adobe Lens (9), Snapseed (3).
3232+3333+## Key Decisions
3434+3535+1. **Back up rendered versions** (not just adjustment plists). The fullsize JPEG
3636+ is what users actually see. Plists are Apple-internal and not portable.
3737+3838+2. **Sibling key with `_edited` suffix** in S3:
3939+ ```
4040+ originals/2024/01/{uuid}.heic # original
4141+ originals/2024/01/{uuid}_edited.jpg # rendered edit
4242+ metadata/assets/{uuid}.json # includes edit metadata
4343+ ```
4444+4545+3. **Same pass as originals**. When processing a batch, detect edits and upload
4646+ both files together. No separate command needed.
4747+4848+4. **Re-scan already-backed-up assets for new edits**. Compare adjustment
4949+ timestamps against the manifest's `backedUpAt` to detect photos edited after
5050+ their initial backup.
5151+5252+## Data Sources in Photos.sqlite
5353+5454+### Edit detection
5555+5656+- `ZUNMANAGEDADJUSTMENT` joined via `ZADDITIONALASSETATTRIBUTES` tells us an
5757+ asset has been edited
5858+- `ZADJUSTMENTTIMESTAMP` tells us when the edit happened
5959+- `ZADJUSTMENTFORMATIDENTIFIER` tells us which editor (com.apple.photo,
6060+ com.adobe.lens, etc.)
6161+6262+### Rendered file location
6363+6464+- `ZINTERNALRESOURCE` with `ZRESOURCETYPE = 1` (fullsize JPEG) points to the
6565+ rendered version
6666+- `ZLOCALAVAILABILITY = 1` means the file is on disk
6767+- `ZDATALENGTH` gives the file size
6868+- The actual file lives in the Photos Library package, path derivable from
6969+ `ZDATASTORECLASSID` + fingerprint
7070+7171+### Export via ladder
7272+7373+- The current exporter uses PhotoKit ID `{uuid}/L0/001` for originals
7474+- Rendered versions may need a different resource variant or direct file copy
7575+ from the library package
7676+7777+## Scope
7878+7979+### In scope
8080+8181+- Detect which assets have edits (via ZUNMANAGEDADJUSTMENT)
8282+- Add edit metadata to PhotoAsset and the S3 metadata JSON (hasEdit, editedAt,
8383+ editor)
8484+- Export and upload rendered fullsize JPEG alongside original
8585+- Re-scan manifest for assets edited after backup
8686+- Track edit backup state in manifest (so renders are not re-uploaded)
8787+8888+### Out of scope
8989+9090+- Backing up adjustment plists (edit recipes)
9191+- Handling slo-mo video rendering (complex, different pipeline)
9292+- Re-rendering from adjustment data outside Apple Photos
9393+- Backing up thumbnails or other resource types
9494+9595+## Resolved Questions
9696+9797+1. **Manifest schema**: Extend the existing manifest entry with optional
9898+ `editS3Key`, `editChecksum`, `editBackedUpAt` fields. No separate entries, no
9999+ schema break.
100100+101101+2. **Re-edit handling**: Always upload the latest render. Compare adjustment
102102+ timestamp against `editBackedUpAt` to detect re-edits. The backup should
103103+ reflect the current state of the edit.
104104+105105+## Open Questions
106106+107107+1. **How does ladder/PhotoKit export the rendered version?** The current
108108+ `/L0/001` suffix gets the original. Need to investigate what identifier or
109109+ API call retrieves the fullsize rendered JPEG. May need a ladder change.
110110+111111+2. **What about iCloud-only rendered versions?** 1,480 of 1,672 renders are
112112+ local. The remaining ~200 may need to be downloaded first, same as
113113+ iCloud-only originals. Is there an existing mechanism for this?
···11+# UX and Open-Source Readiness
22+33+**Date:** 2026-03-13 **Status:** Ready for planning
44+55+## What We're Building
66+77+Make attic friendly to use for technical Mac users and ready to open source.
88+Replace hardcoded Scaleway configuration with a generic S3-compatible config
99+layer, add an interactive `attic init` command, adopt Cliffy for polished CLI
1010+output, and improve error messages throughout.
1111+1212+## Why This Approach
1313+1414+Attic currently works well as a personal tool but has Scaleway details baked
1515+into the code (endpoint, region, keychain service names, type names). To open
1616+source it, the tool needs to work with any S3-compatible provider out of the
1717+box. The UX should feel polished — good help text, colored output, and clear
1818+error messages that tell you what to do next.
1919+2020+## Current State
2121+2222+| Area | Current | Target |
2323+| ------------------ | --------------------------------------- | ---------------------------------------------- |
2424+| S3 endpoint/region | Hardcoded Scaleway constants | Config file, any S3-compatible provider |
2525+| Bucket name | Hardcoded default, `--bucket` flag | Config file, CLI override |
2626+| Credentials | Keychain with hardcoded service names | Keychain with configurable service names |
2727+| Config file | None | `~/.attic/config.json` |
2828+| First-run setup | Manual (read README, set keychain, run) | `attic init` interactive prompts |
2929+| CLI framework | Hand-rolled arg parsing | Cliffy (subcommands, typed flags, help, color) |
3030+| Error messages | Raw exceptions in some paths | Friendly messages with suggested fixes |
3131+| path style | Hardcoded `true` | Config option, default `true` |
3232+| Provider docs | Scaleway-specific | Provider-neutral with EU-focused examples |
3333+3434+## Key Decisions
3535+3636+1. **Config file at `~/.attic/config.json`** — primary configuration source. CLI
3737+ flags override. No env var fallback (keep it simple, macOS-only tool).
3838+3939+2. **Interactive `attic init`** — asks for S3 endpoint, region, bucket, and
4040+ keychain service names step by step. Writes config.json. Can offer provider
4141+ suggestions (Scaleway, Hetzner, OVH as EU options).
4242+4343+3. **Keychain with configurable service names** — stay macOS Keychain-only
4444+ (security principle from CLAUDE.md), but let config.json specify the service
4545+ names instead of hardcoding `attic-s3-access-key` / `attic-s3-secret-key`.
4646+4747+4. **Cliffy for CLI** — replace hand-rolled arg parsing with Cliffy. Gets us
4848+ subcommands, typed flags, auto-generated help, colored output, and shell
4949+ completions.
5050+5151+5. **`forcePathStyle` as config option** — default `true` (works with most
5252+ S3-compatible providers). AWS users can set to `false`.
5353+5454+6. **EU-focused provider examples** — highlight Scaleway, Hetzner, OVH as EU
5555+ data sovereignty options in docs and init prompts. Mention AWS/Backblaze as
5656+ alternatives. Position attic as a good choice for keeping your photos in the
5757+ EU.
5858+5959+7. **Top-level error boundary** — catch unhandled errors in mod.ts, present
6060+ friendly messages instead of stack traces. Pattern: detect known error types
6161+ (keychain missing, network timeout, S3 access denied) and print actionable
6262+ guidance.
6363+6464+## Config File Schema
6565+6666+```json
6767+{
6868+ "endpoint": "https://s3.fr-par.scw.cloud",
6969+ "region": "fr-par",
7070+ "bucket": "my-photo-backup",
7171+ "pathStyle": true,
7272+ "keychain": {
7373+ "accessKeyService": "attic-s3-access-key",
7474+ "secretKeyService": "attic-s3-secret-key"
7575+ }
7676+}
7777+```
7878+7979+## Scope
8080+8181+### In scope
8282+8383+- Config file (`~/.attic/config.json`) with validation
8484+- `attic init` interactive setup command
8585+- Cliffy migration for all commands (scan, status, backup, verify)
8686+- Rename `ScalewayCredentials` to `S3Credentials`, remove `SCALEWAY_*` constants
8787+- `createS3Provider()` accepts endpoint, region, pathStyle as parameters
8888+- Top-level error boundary with friendly messages for known failure modes
8989+- Updated README, CLAUDE.md, and architecture docs
9090+- EU-focused provider examples in docs and init
9191+9292+### Out of scope
9393+9494+- Env var credential fallback (keep Keychain-only)
9595+- Non-macOS support
9696+- Provider presets in init (just ask for endpoint/region directly, with
9797+ examples)
9898+- Web UI or GUI
9999+- Auto-detection of Photos.sqlite path across macOS versions
100100+101101+## Resolved Questions
102102+103103+1. **Audience**: Technical Mac users comfortable with terminal and S3 setup.
104104+2. **Config approach**: Config file at `~/.attic/config.json`, CLI flags
105105+ override.
106106+3. **Init style**: Interactive prompts, writes config at the end.
107107+4. **Credentials**: Keychain-only with configurable service names in config.
108108+5. **Provider presentation**: EU-focused examples (Scaleway, Hetzner, OVH),
109109+ others mentioned as alternatives.
110110+6. **Path style**: Config option `pathStyle`, default `true`.
111111+7. **CLI framework**: Cliffy.
112112+8. **Init stores credentials directly**: `attic init` prompts for access key and
113113+ secret key and runs `security add-generic-password` automatically.
114114+9. **Validate config when S3 is needed**: scan/status only need Photos.sqlite —
115115+ they work without config. backup/verify validate config and fail fast with a
116116+ clear message if missing or incomplete.
+52-39
docs/metadata.md
···11# Asset Metadata
2233-Each backed-up asset gets a companion JSON file uploaded to S3 at `metadata/assets/{uuid}.json`. This file makes the backup browsable and searchable without access to Apple Photos or the original Photos.sqlite database.
33+Each backed-up asset gets a companion JSON file uploaded to S3 at
44+`metadata/assets/{uuid}.json`. This file makes the backup browsable and
55+searchable without access to Apple Photos or the original Photos.sqlite
66+database.
4758## Example
69···39424043### Asset identification
41444242-| Field | Type | Description |
4343-|-------|------|-------------|
4444-| `uuid` | string | Photos library UUID, unique per asset |
4545+| Field | Type | Description |
4646+| ------------------ | ------ | ------------------------------------------- |
4747+| `uuid` | string | Photos library UUID, unique per asset |
4548| `originalFilename` | string | Filename as imported (e.g. `IMG_4231.HEIC`) |
46494750### Date and dimensions
48514949-| Field | Type | Description |
5050-|-------|------|-------------|
5252+| Field | Type | Description |
5353+| ------------- | -------------- | ---------------------------------------------------------------- |
5154| `dateCreated` | string \| null | ISO 8601 timestamp from Photos.sqlite (CoreData epoch converted) |
5252-| `width` | number | Pixel width |
5353-| `height` | number | Pixel height |
5555+| `width` | number | Pixel width |
5656+| `height` | number | Pixel height |
54575558### Location
56595757-| Field | Type | Description |
5858-|-------|------|-------------|
5959-| `latitude` | number \| null | GPS latitude, null if no location data |
6060+| Field | Type | Description |
6161+| ----------- | -------------- | --------------------------------------- |
6262+| `latitude` | number \| null | GPS latitude, null if no location data |
6063| `longitude` | number \| null | GPS longitude, null if no location data |
61646265### File info
63666464-| Field | Type | Description |
6565-|-------|------|-------------|
6666-| `fileSize` | number \| null | Original file size in bytes |
6767-| `type` | string \| null | Uniform Type Identifier (e.g. `public.heic`, `com.apple.quicktime-movie`) |
6868-| `favorite` | boolean | Whether the asset is marked as a favorite in Photos |
6767+| Field | Type | Description |
6868+| ---------- | -------------- | ------------------------------------------------------------------------- |
6969+| `fileSize` | number \| null | Original file size in bytes |
7070+| `type` | string \| null | Uniform Type Identifier (e.g. `public.heic`, `com.apple.quicktime-movie`) |
7171+| `favorite` | boolean | Whether the asset is marked as a favorite in Photos |
69727073### Enrichment
71747272-These fields come from auxiliary tables in Photos.sqlite via separate enrichment queries. All degrade gracefully — if the source table is missing (older macOS versions), the field returns its empty default.
7575+These fields come from auxiliary tables in Photos.sqlite via separate enrichment
7676+queries. All degrade gracefully — if the source table is missing (older macOS
7777+versions), the field returns its empty default.
73787474-| Field | Type | Default | Source table |
7575-|-------|------|---------|--------------|
7676-| `title` | string \| null | null | `ZADDITIONALASSETATTRIBUTES.ZTITLE` |
7777-| `description` | string \| null | null | `ZASSETDESCRIPTION.ZLONGDESCRIPTION` |
7878-| `albums` | AlbumRef[] | [] | `ZGENERICALBUM` via `Z_33ASSETS` join |
7979-| `keywords` | string[] | [] | `ZKEYWORD` via `Z_1KEYWORDS` join |
8080-| `people` | PersonRef[] | [] | `ZPERSON` via `ZDETECTEDFACE` join |
7979+| Field | Type | Default | Source table |
8080+| ------------- | -------------- | ------- | ------------------------------------- |
8181+| `title` | string \| null | null | `ZADDITIONALASSETATTRIBUTES.ZTITLE` |
8282+| `description` | string \| null | null | `ZASSETDESCRIPTION.ZLONGDESCRIPTION` |
8383+| `albums` | AlbumRef[] | [] | `ZGENERICALBUM` via `Z_33ASSETS` join |
8484+| `keywords` | string[] | [] | `ZKEYWORD` via `Z_1KEYWORDS` join |
8585+| `people` | PersonRef[] | [] | `ZPERSON` via `ZDETECTEDFACE` join |
81868282-An `AlbumRef` contains `uuid` and `title`. A `PersonRef` contains `uuid` and `displayName`. People are deduplicated per asset (a person appears at most once even if detected in multiple face regions).
8787+An `AlbumRef` contains `uuid` and `title`. A `PersonRef` contains `uuid` and
8888+`displayName`. People are deduplicated per asset (a person appears at most once
8989+even if detected in multiple face regions).
83908491### Edit detection
85928686-| Field | Type | Description |
8787-|-------|------|-------------|
8888-| `hasEdit` | boolean | True only when both an adjustment record and a rendered resource exist |
8989-| `editedAt` | string \| null | ISO 8601 timestamp of the edit, null when `hasEdit` is false |
9090-| `editor` | string \| null | Bundle ID of the editing app (e.g. `com.apple.photo`, `com.pixelmator.photomator`), null when `hasEdit` is false |
9393+| Field | Type | Description |
9494+| ---------- | -------------- | ---------------------------------------------------------------------------------------------------------------- |
9595+| `hasEdit` | boolean | True only when both an adjustment record and a rendered resource exist |
9696+| `editedAt` | string \| null | ISO 8601 timestamp of the edit, null when `hasEdit` is false |
9797+| `editor` | string \| null | Bundle ID of the editing app (e.g. `com.apple.photo`, `com.pixelmator.photomator`), null when `hasEdit` is false |
91989292-`hasEdit` requires two conditions: an entry in `ZUNMANAGEDADJUSTMENT` (the edit happened) AND an entry in `ZINTERNALRESOURCE` with resource type 1 (a rendered file exists). Metadata-only adjustments that don't produce a visible render are excluded.
9999+`hasEdit` requires two conditions: an entry in `ZUNMANAGEDADJUSTMENT` (the edit
100100+happened) AND an entry in `ZINTERNALRESOURCE` with resource type 1 (a rendered
101101+file exists). Metadata-only adjustments that don't produce a visible render are
102102+excluded.
9310394104### Backup tracking
951059696-| Field | Type | Description |
9797-|-------|------|-------------|
9898-| `s3Key` | string | S3 object key where the original file is stored (e.g. `originals/2024/07/{uuid}.heic`) |
9999-| `checksum` | string | SHA-256 hash of the uploaded file, prefixed with `sha256:` |
100100-| `backedUpAt` | string | ISO 8601 timestamp of when this asset was uploaded |
106106+| Field | Type | Description |
107107+| ------------ | ------ | -------------------------------------------------------------------------------------- |
108108+| `s3Key` | string | S3 object key where the original file is stored (e.g. `originals/2024/07/{uuid}.heic`) |
109109+| `checksum` | string | SHA-256 hash of the uploaded file, prefixed with `sha256:` |
110110+| `backedUpAt` | string | ISO 8601 timestamp of when this asset was uploaded |
101111102112## What's not included
103113104104-- **Adjustment plists** — Apple's non-destructive edit recipes are not portable outside Photos
114114+- **Adjustment plists** — Apple's non-destructive edit recipes are not portable
115115+ outside Photos
105116- **Thumbnail data** — Not useful for a full backup
106106-- **iCloud sync state** — Only relevant at backup time, not for the archived copy
107107-- **Face region coordinates** — Only person identity is stored, not bounding boxes
117117+- **iCloud sync state** — Only relevant at backup time, not for the archived
118118+ copy
119119+- **Face region coordinates** — Only person identity is stored, not bounding
120120+ boxes
108121- **Slo-mo / Live Photo markers** — Deferred to a future phase