# migrate — legacy mule-image → PhotoPrism

Two-phase migration that lifts user metadata + heap memberships out of the
legacy mule-image Postgres into PhotoPrism without touching originals.

```text
┌────────────────────┐    legacy_export.mjs    ┌─────────────────────┐
│ mule-image Postgres├───────────────────────►│  snapshot.json      │
└────────────────────┘                         └──────────┬──────────┘
                                                          │ run.mjs
                                                          ▼
                                          ┌─────────────────────────────┐
                                          │ PUT /api/v1/photos/:uid     │  (Phase A)
                                          │ POST /api/v1/albums + adds  │  (Phase B)
                                          └─────────────────────────────┘
```

## Why two phases

- **Phase A — metadata via PUT.** For each legacy photo, the migrator
  hashes the file on disk, resolves the PhotoPrism `UID` via
  `q=hash:<sha1>`, and PUTs the merged metadata onto `/api/v1/photos/:uid`.
  We tried writing YAML sidecars directly first but PhotoPrism's reindex
  rewrites them from its DB state — PUT is the only authoritative path.
- **Phase B — heap memberships.** Once every legacy photo has a stable
  UID, the heap → Album conversion creates manual Albums and bulk-adds
  the resolved UIDs via `/albums/:uid/photos`.

Both phases are idempotent. Phase A's PUT is deep-merged (top-level
spread plus `Details` shallow-merge), so re-running pulls in any new
fields from the snapshot without clobbering server-side state. Phase B
looks up heaps by title and only creates if absent.

## Files

- `legacy_export.mjs` — reads from legacy mule-image Postgres
  (`DATABASE_URL` env), emits `snapshot.json`. Runs on the **homecloud server**
  where the legacy backend lives.
- `run.mjs` — reads a snapshot file and walks both phases against the
  PhotoPrism API at `PHOTOPRISM_BASE_URL`. Hashes files on disk under
  `ORIGINALS_ROOT` to resolve UIDs. Runs **wherever the new PhotoPrism is
  reachable and originals are mounted** (homecloud first, dev workstation
  for the smoke test).
- `example-snapshot.json` — synthetic input that exercises Phase A + B
  against the local sample library. Smoke test:

  ```sh
  PHOTOPRISM_BASE_URL=http://localhost:2342 \
  PHOTOPRISM_USER=admin \
  PHOTOPRISM_PASSWORD=... \
  ORIGINALS_ROOT=$(pwd)/photos-sample \
  node migrate/run.mjs --snapshot migrate/example-snapshot.json
  ```

  Add `--phase=sidecars` or `--phase=heaps` to run just one phase, or
  `--dry-run` to log without mutating.

## Snapshot schema

```json
{
  "photos": [
    {
      "filepath": "Screenshot_20260510_110411.png",
      "user_title": null,
      "user_notes": "Trip to Paris",
      "rating": 4,
      "is_picked": true,
      "is_discarded": false,
      "is_hidden": false,
      "taken_at": "2024-06-15T14:30:00Z",
      "tags": ["paris", "trip"]
    }
  ],
  "heaps": [
    {
      "name": "Summer 2024",
      "photo_filepaths": ["Screenshot_20260510_110411.png"]
    }
  ]
}
```

- `filepath` is relative to `ORIGINALS_ROOT`.
- `is_hidden` is **dropped** by the migrator per the merge plan.
- `tags` lands in `Details.Keywords` (comma-separated, `KeywordsSrc=manual`).
- Heaps reference photos by `filepath`; the migrator resolves these to
  PhotoPrism UIDs after Phase A's reindex finishes.

## What's NOT migrated

- mule-image's `is_hidden` (per the planning round).
- Heap+folder sharing state (planned for the Go sidecar service).
- Undo history.
- Nextcloud per-user roots.
- The 5-star user rating (PhotoPrism's `Rating` field is server-managed in
  this build; we map mule-image's `is_picked` boolean → `Favorite`).
