# `.dish` File Format Specification

A `.dish` file is BrightDish's portable recipe-exchange format. This document describes the JSON schema, every field, and the validation/sanitization rules BrightDish applies on import, so that third-party tools can produce `.dish` files BrightDish accepts.

This file documents the latest, current version of the BrightDish file spec. Legacy versions are not documented. BrightDish ensures backwards compatibility with older `*.dish` files, but you should always adopt the latest version of the spec to ensure that your recipes import as expected.

---

## 1. File Container

A `.dish` file is a single JSON document encoded as UTF-8.

| Property               | Value                                                                 |
|------------------------|-----------------------------------------------------------------------|
| Extension              | `.dish`                                                               |
| MIME type              | `application/x-brightdishrecipe`                                      |
| UTI                    | `com.incident57.BrightDish.recipeFile`                                |
| Top-level structure    | A single JSON object representing one Recipe                          |
| Compression            | Optional gzip (RFC 1952). Uncompressed JSON is also accepted.         |
| Decompression cap      | **50 MB** maximum decompressed size                                   |
| Per file               | One recipe per file                                                   |

The importer auto-detects gzip via the two-byte magic number `0x1F 0x8B`. If gzip-compressed data declares an `ISIZE` greater than 50 MB the file is rejected before decompression begins.

---

## 2. Top-Level Object: `Recipe`

The root JSON object represents a single recipe. All fields are optional unless marked **required**. Unknown keys are ignored. The importer is intentionally tolerant — invalid or out-of-range values are clamped or replaced with safe defaults rather than rejecting the file.

### 2.1 String fields

| Key            | Type     | Required | Max length | Default if empty / missing             | Notes                                                                |
|----------------|----------|----------|------------|----------------------------------------|----------------------------------------------------------------------|
| `title`        | String   | No       | 500        | `"Untitled Recipe"`                    | Trimmed of leading/trailing whitespace.                              |
| `summary`      | String   | No       | 1000       | `"No summary provided."`               | Trimmed.                                                             |
| `notes`        | String   | No       | 10,000     | `""`                                   | Trimmed. Freeform user notes.                                        |
| `author`       | String   | No       | 250        | `""`                                   | Person/organization who created the recipe.                          |
| `source`       | String   | No       | 100        | `""`                                   | Publication or origin (e.g. `"Grandma"`, `"NYT Cooking"`).           |
| `yield`        | String   | No       | 100        | `""`                                   | Freeform (e.g. `"24 cookies"`, `"2 loaves"`).                  |
| `servingSize`  | String   | No       | 100        | `""`                                   | Freeform (e.g. `"1 cup"`, `"2 slices"`).                             |
| `website`      | String   | No       | —          | `null`                                 | URL string. See sanitization below.                                  |

#### `website` URL sanitization

The importer normalizes website URLs aggressively:

1. Trims whitespace.
2. If the string contains no `://`, prepends `https://`.
3. Forces scheme to `https`.
4. Strips `query`, `user`, and `password` components.
5. Rejects (sets to `null`) if the result has no host.

```json
"website": "https://www.example.com/recipe"
```

### 2.2 Numeric fields

All out-of-range values are clamped to the allowed range.

| Key                  | Type    | Required | Allowed range  | Default if missing | Notes                                                       |
|----------------------|---------|----------|----------------|--------------------|-------------------------------------------------------------|
| `rating`             | Double  | No       | `0.0 ... 5.0`  | `0.0`              |                                                             |
| `difficulty`         | Int     | No       | `0 ... 10`     | `0`                | `0` means "unknown".                                        |
| `prepMinutes`        | Int     | No       | `0 ... 999`    | `0`                |                                                             |
| `cookMinutes`        | Int     | No       | `0 ... 999`    | `0`                |                                                             |
| `additionalMinutes`  | Int     | No       | `0 ... 99,999` | `0`                | Resting, marinating, chilling, etc.                         |
| `servingsCount`      | Int     | No       | `1 ... 99`     | `1`                |                                                             |
| `calorieCount`       | Int     | No       | `0 ... 9,999`  | `0`                | Calories per serving.                                       |

### 2.3 Enum fields

#### `origin` (Int)

How the recipe was originally created. Invalid values are ignored.

| Value | Meaning                  |
|-------|--------------------------|
| `0`   | Unknown                  |
| `1`   | AI — Single Cuisine      |
| `2`   | AI — Fused Cuisines      |
| `3`   | AI — By Ingredients      |
| `4`   | AI — Scanned Photos      |
| `5`   | AI — Freeform            |
| `6`   | Manual entry             |
| `7`   | Imported from Paprika    |
| `8`   | Imported from a website  |

Third-party producers should typically use `8` (Website). Users can search for recipes from a particular origin.

#### `meal` (Int)

The temporal meal/event the recipe is associated with. Users can filter for recipes from a particular meal. Invalid values are ignored.

| Value | Meaning   |
|-------|-----------|
| `0`   | Breakfast |
| `1`   | Lunch     |
| `2`   | Dinner    |
| `3`   | Snack     |
| `4`   | Dessert   |
| `5`   | Side dish |
| `6`   | Appetizer |
| `7`   | Soup      |
| `999` | Undefined / catch-all |

#### `cuisines` (Array of String)

An array of cuisine identifiers associated with the recipe. Supports fusion recipes with multiple cuisines. An empty or missing array means no cuisine has been assigned. Users can filter recipes based on their assigned cuisine(s).

Each entry is **lowercased** on import and then validated against the known cuisine values below. Strings that do not match a known value are **silently dropped** from the array. Duplicate entries are collapsed into a single entry.

Valid values: `american`, `argentinian`, `australian`, `brazilian`, `british`, `caribbean`, `chinese`, `colombian`, `creole`, `cuban`, `ethiopian`, `filipino`, `french`, `german`, `greek`, `indian`, `indonesian`, `italian`, `jamaican`, `japanese`, `korean`, `lebanese`, `mexican`, `moroccan`, `nigerian`, `pakistani`, `peruvian`, `polish`, `portuguese`, `russian`, `spanish`, `thai`, `turkish`, `vietnamese`, `other`.

```json
"cuisines": ["italian", "french"]
```

### 2.4 Identification

| Key    | Type   | Required | Notes                                                            |
|--------|--------|----------|------------------------------------------------------------------|
| `uuid` | String | No       | RFC 4122 UUID (any version). If missing, a fresh UUID is assigned during import. Used for duplicate detection — see Section 6. |

### 2.5 Nested object fields

| Key                  | Type                | Cap         | Notes                                                                        |
|----------------------|---------------------|-------------|------------------------------------------------------------------------------|
| `ingredientSections` | [IngredientSection] | **10**      | Preferred container for ingredients. See §3.                                 |
| `ingredients`        | [Ingredient]        | 100         | Alternative flat-list form. See §3 for precedence rules.                     |
| `utensils`           | [Utensil]           | 99          | See §4.                                                                      |
| `steps`              | [Step]              | 99          | See §5.                                                                      |
| `nutritionInfo`      | NutritionInfo       | n/a         | Single object. See §6.                                                       |
| `images`             | [RecipeImage]       | **20**      | See §7.                                                                      |
| `tags`               | [Tag]               | 50          | See §8.                                                                      |

### 2.6 Producer-only field

| Key        | Type | Required | Notes                                                                                                    |
|------------|------|----------|----------------------------------------------------------------------------------------------------------|
| `appBuild` | Int  | No       | The BrightDish build that produced this file. Written on export. Ignored on import (informational only). |

---

## 3. Ingredients

A recipe organizes ingredients into one or more named **sections** (e.g. *"For the Crust"*, *"For the Filling"*). The format supports two ways of providing ingredients:

1. **Sectioned** — set `ingredientSections`. This is what BrightDish itself exports.
2. **Flat** — set `ingredients` instead. The importer wraps the entire flat list in a single auto-created section titled `"Other Ingredients"`.

If a recipe contains only one `ingredientSection`, BrightDish presents the ingredients as a flat list (it does not display any sectioning UI). Sections will appear only if more than one exists.

If both keys are present, `ingredientSections` wins and `ingredients` is ignored.

### 3.1 `IngredientSection` object

| Key           | Type           | Required | Max length / cap     | Default              | Notes                                            |
|---------------|----------------|----------|----------------------|----------------------|--------------------------------------------------|
| `title`       | String         | No       | 200 chars            | `"Other Ingredients"`| Trimmed.                                         |
| `sortIndex`   | Int            | No       | —                    | `0`                  | Ordering hint. Renumbered to `0..N` on import.   |
| `ingredients` | [Ingredient]   | No       | **100** items        | `[]`                 | Anything past 100 is dropped.                    |

### 3.2 `Ingredient` object

| Key         | Type    | Required | Max length / range  | Default                  | Notes                                                                                          |
|-------------|---------|----------|---------------------|--------------------------|------------------------------------------------------------------------------------------------|
| `name`      | String  | No       | 150 chars           | `"[Unnamed Ingredient]"` | Trimmed. Should be the noun only — quantity and unit go in their own fields.                   |
| `details`   | String  | No       | 150 chars           | `""`                     | Qualifiers shown beneath the name (`"Granny Smith or Honeycrisp"`, `"peeled and diced"`).      |
| `quantity`  | Double  | No       | `0.0 ... 999.9`     | `0.0`                    | Clamped if out of range.                                                                       |
| `unit`      | String  | No       | 50 chars            | `""`                     | Freeform (e.g. `"tbsp"`, `"g"`, `"cup"`). BrightDish does not interpret the unit semantically. |
| `sortIndex` | Int     | No       | —                   | `0`                      | Renumbered `0..N` per section on import.                                                       |

### 3.3 Example

```json
{
    "ingredientSections": [
        {
            "title": "For the Crust",
            "sortIndex": 0,
            "ingredients": [
                { "name": "All-purpose flour", "quantity": 2.0, "unit": "cups", "sortIndex": 0 },
                { "name": "Butter", "details": "cold, cubed", "quantity": 0.5, "unit": "cup", "sortIndex": 1 }
            ]
        },
        {
            "title": "For the Filling",
            "sortIndex": 1,
            "ingredients": [
                { "name": "Apples", "details": "peeled and sliced", "quantity": 6.0, "unit": "", "sortIndex": 0 }
            ]
        }
    ]
}
```

---

## 4. Utensils

### 4.1 `Utensil` object

| Key         | Type   | Required | Max length | Default              | Notes                                       |
|-------------|--------|----------|------------|----------------------|---------------------------------------------|
| `name`      | String | No       | 150 chars  | `"[Unnamed Utensil]"`| Trimmed.                                    |
| `sortIndex` | Int    | No       | —          | `0`                  | Renumbered `0..N` on import.                |

Cap: 99 utensils per recipe.

### 4.2 Example

```json
"utensils": [
    { "name": "9-inch pie pan", "sortIndex": 0 },
    { "name": "Rolling pin", "sortIndex": 1 }
]
```

---

## 5. Steps

A step is a single cooking instruction OR a section header that visually divides the directions list. A recipe may contain a maximum of 99 steps.

### 5.1 `Step` object

| Key         | Type    | Required          | Max length | Default          | Notes                                                                                                                                       |
|-------------|---------|-------------------|------------|------------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| `number`    | Int     | **Yes**           | —          | —                | Used as the sort key. The importer renumbers all steps `1..N` after sorting; the absolute value is irrelevant, only relative order matters. |
| `title`     | String? | No                | 250 chars  | `null`           | Optional bold heading shown above `text`. Required when `kind` is `"sectionHeader"`.                                                        |
| `text`      | String  | No                | 2000 chars | `"[Empty Step]"` | The instruction body.                                                                                                                       |
| `kind`      | String  | No                | —          | `"step"`         | Either `"step"` or `"sectionHeader"`. See §5.2.                                                                                             |
| `imageUUID` | String? | No                | —          | `null`           | Optional UUID of an image in the recipe's `images` array. See §7.                                                                           |

A missing `number` causes the entire `.dish` file to be rejected. All other fields are recoverable.

### 5.2 `kind` values

| Value             | Meaning                                                                                              |
|-------------------|------------------------------------------------------------------------------------------------------|
| `"step"`          | A normal cooking instruction. `text` is shown. `number` displays.                                    |
| `"sectionHeader"` | A visual divider grouping nearby steps. Only `title` is shown. `text` is ignored                     |

### 5.3 Example

```json
"steps": [
    { "number": 1, "kind": "sectionHeader", "title": "Make the dough" },
    { "number": 2, "text": "In a large bowl, whisk together the flour and salt." },
    { "number": 3, "title": "Cut in the butter", "text": "Work the butter into the flour with your fingertips until it resembles coarse crumbs.", "imageUUID": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234" }
]
```

---

## 6. NutritionInfo

A single optional object containing per-serving nutritional values. Every field is an optional `Double`. A `null` or missing value means *"unknown"* and is shown dimmed in the UI; an explicit `0` is treated as a real value (zero of that nutrient).

All values are clamped to `0.0 ... 9999.0`. Units are fixed by field semantics — the value itself carries no unit.

### 6.1 Field reference

| Group         | Key               | Unit  |
|---------------|-------------------|-------|
| Fats          | `fat`             | g     |
|               | `saturatedFat`    | g     |
|               | `transFat`        | g     |
|               | `unsaturatedFat`  | g     |
| Core          | `cholesterol`     | mg    |
|               | `sodium`          | mg    |
| Carbohydrates | `carbohydrates`   | g     |
|               | `fiber`           | g     |
|               | `sugar`           | g     |
|               | `addedSugar`      | g     |
| Protein       | `protein`         | g     |
| Vitamins      | `vitaminA`        | mcg   |
|               | `vitaminB6`       | mg    |
|               | `vitaminB12`      | mcg   |
|               | `vitaminC`        | mg    |
|               | `vitaminD`        | mcg   |
|               | `vitaminE`        | mg    |
|               | `vitaminK`        | mcg   |
|               | `biotin`          | mcg   |
|               | `choline`         | mg    |
|               | `folate`          | mcg   |
|               | `niacin`          | mg    |
|               | `pantothenicAcid` | mg    |
|               | `riboflavin`      | mg    |
|               | `thiamin`         | mg    |
| Minerals      | `calcium`         | mg    |
|               | `chloride`        | mg    |
|               | `chromium`        | mcg   |
|               | `copper`          | mg    |
|               | `iodine`          | mcg   |
|               | `iron`            | mg    |
|               | `magnesium`       | mg    |
|               | `manganese`       | mg    |
|               | `molybdenum`      | mcg   |
|               | `phosphorus`      | mg    |
|               | `potassium`       | mg    |
|               | `selenium`        | mcg   |
|               | `zinc`            | mg    |

### 6.2 Example

```json
"nutritionInfo": {
    "fat": 12.5,
    "saturatedFat": 4.2,
    "carbohydrates": 38,
    "fiber": 3,
    "sugar": 18,
    "protein": 4,
    "sodium": 220,
    "calcium": 40
}
```

---

## 7. Images

### 7.1 `RecipeImage` object

| Key                | Type    | Required | Max length / cap | Default       | Notes                                                                                                  |
|--------------------|---------|----------|------------------|---------------|--------------------------------------------------------------------------------------------------------|
| `uuid`             | String  | No       | —                | fresh UUID    | RFC 4122 UUID. Should be stable per image so `Step.imageUUID` references resolve.                      |
| `sortIndex`        | Int     | No       | —                | `0`           | Renumbered `0..N` on import. The image with the lowest `sortIndex` becomes the recipe's primary image. |
| `caption`          | String  | No       | 500 chars        | `""`          | Trimmed.                                                                                               |
| `origin`           | Int     | No       | —                | `0`           | See §7.2.                                                                                              |
| `imageData`        | String? | No       | —                | `null`        | Base64-encoded image bytes. See §7.3.                                                                  |

### 7.2 `origin` values

An integer that describes where an image originated. Invalid values become `0`.

| Value | Meaning            |
|-------|--------------------|
| `0`   | Unknown            |
| `1`   | AI-generated       |
| `2`   | User-supplied      |
| `3`   | Web-imported       |

Third-party producers should use `0` or `3`.

### 7.3 Embedded image bytes (`imageData`)

`imageData` is a base64-encoded image payload. JPEG is the canonical and recommended format. PNG and other formats supported by Apple's `ImageIO` are also accepted on import.

**Per-image limits enforced on import:**

| Limit                  | Value                                              |
|------------------------|----------------------------------------------------|
| Maximum pixel count    | 100 megapixels (10,000 × 10,000)                   |
| Maximum dimension      | 3,072 px on the longest side (auto-downsampled)    |
| Encoded payload size   | Bounded by the 50 MB whole-file decompression cap  |

Images exceeding the pixel-count cap are rejected. Images larger than 3,072 px on the longest side are downsampled at decode time.

**Per-recipe cap:** the importer keeps the first **20** images sorted by `sortIndex` and discards any extras. Any `Step.imageUUID` that pointed to a discarded image is cleared so no dangling references survive.

### 7.4 Image-Step linking

A `Step` may attach an image by setting `imageUUID` to the UUID of a `RecipeImage` listed in `images`. The reference must resolve to a `uuid` that survives the per-recipe cap; otherwise the importer clears it.

### 7.5 Example

```json
"images": [
    {
        "uuid": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234",
        "sortIndex": 0,
        "caption": "Finished pie, fresh out of the oven",
        "origin": 2,
        "imageData": "/9j/4AAQSkZJRgABAQEASABIAAD/...<truncated base64>..."
    }
]
```

---

## 8. Tags

User-defined freeform labels (e.g. `"weeknight"`, `"gluten-free"`). A recipe may assign up to 50 tags.

### 8.1 `Tag` object

| Key        | Type    | Required | Max length | Default | Notes                                                                                                                                       |
|------------|---------|----------|------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------|
| `name`     | String  | **Yes**  | 35 chars   | —       | Trimmed. Empty (post-trim) names are skipped.                                                                                               |
| `colorHex` | String? | No       | 9 chars    | `null`  | 8-character RGBA hex in Display P3 color space, with optional leading `#`. Examples: `"5A9463FF"`, `"#5A9463FF"`. Invalid → fallback color. |

Note: The UI shows the tag's name in white atop a rectangle of `colorHex` color. Avoid values that have poor contrast with white text.

### 8.2 Tag deduplication on import

BrightDish performs **fuzzy matching** when importing tags:

- Names are normalized by replacing dashes with spaces and lowercasing.
- An incoming `"gluten-free"` matches an existing `"Gluten Free"` and the recipe attaches to the existing tag rather than creating a duplicate.
- Likewise, an incoming `"Gluten Free"` matches an existing `"gluten-free"` and the recipe attaches to the existing tag rather than creating a duplicate.
- If no match exists, the tag is created fresh.

### 8.3 Example

```json
"tags": [
    { "name": "weeknight" },
    { "name": "gluten-free", "colorHex": "5A9463FF" }
]
```

---

## 9. Sanitization Summary

Every string field is sanitized by the importer, which:

1. Discards Leading/trailing whitespace and newlines.
2. Truncates to the field's maximum length (UTF-8 character count).
3. Substitutes a default if the result is empty (where a default is documented).

Numeric fields outside their allowed range are **clamped** to the nearest valid value, never rejected.

Array fields exceeding their cap have the excess **dropped** (the first N items are kept, sorted by `sortIndex`/`number` first where applicable).

The importer is intentionally lenient — almost any malformed-but-decodable input produces a valid (if defaulted) recipe rather than a hard rejection. The exceptions:

- Non-JSON / unparseable file bytes
- Gzip stream declaring more than 50 MB uncompressed
- Decompressed bytes exceeding 50 MB
- Any `Step` missing the required `number` field

These produce a user-facing import-failure error.

---

## 10. Duplicate handling

If the importer finds an existing recipe in the user's library with a matching `uuid`, it prompts the user to choose:

- **Replace existing** — the existing recipe (and all its images) is deleted; the imported recipe takes its place.
- **Keep both** — the imported recipe is assigned a fresh UUID and added alongside the existing one.
- **Cancel** — the import aborts.

If you want your `.dish` files to always import as fresh recipes, generate a fresh UUID for `uuid` each time you produce the file.

---

## 11. Minimal Example

The smallest viable `.dish` file. Everything except `steps[].number` may be omitted.

```json
{
    "title": "Toast",
    "summary": "Bread, made warm.",
    "ingredients": [
        { "name": "Bread", "quantity": 1, "unit": "slice" }
    ],
    "steps": [
        { "number": 1, "text": "Toast the bread in a toaster until golden brown." }
    ]
}
```

---

## 12. Full Example

```json
{
    "uuid": "8B1C44A2-3F22-49DF-9E3D-1EBD5C7AAB10",
    "origin": 6,
    "title": "Apple Crumble",
    "summary": "A warm autumn dessert with cinnamon-spiced apples under a buttery oat topping.",
    "meal": 4,
    "cuisines": ["american"],
    "rating": 4.5,
    "difficulty": 3,
    "prepMinutes": 20,
    "cookMinutes": 40,
    "additionalMinutes": 0,
    "servingsCount": 6,
    "yield": "1 9-inch crumble",
    "servingSize": "1 cup",
    "calorieCount": 380,
    "notes": "Best served warm with vanilla ice cream.",
    "author": "Bryan Jones",
    "source": "Family Recipe",
    "website": "https://example.com/apple-crumble",

    "ingredientSections": [
        {
            "title": "For the Filling",
            "sortIndex": 0,
            "ingredients": [
                { "name": "Apples", "details": "peeled, cored, sliced", "quantity": 6, "unit": "", "sortIndex": 0 },
                { "name": "Brown sugar", "quantity": 0.5, "unit": "cup", "sortIndex": 1 },
                { "name": "Cinnamon", "quantity": 1, "unit": "tsp", "sortIndex": 2 },
                { "name": "Lemon juice", "quantity": 1, "unit": "tbsp", "sortIndex": 3 }
            ]
        },
        {
            "title": "For the Topping",
            "sortIndex": 1,
            "ingredients": [
                { "name": "Rolled oats", "quantity": 1, "unit": "cup", "sortIndex": 0 },
                { "name": "All-purpose flour", "quantity": 0.5, "unit": "cup", "sortIndex": 1 },
                { "name": "Brown sugar", "quantity": 0.5, "unit": "cup", "sortIndex": 2 },
                { "name": "Butter", "details": "cold, cubed", "quantity": 0.5, "unit": "cup", "sortIndex": 3 }
            ]
        }
    ],

    "utensils": [
        { "name": "9-inch baking dish", "sortIndex": 0 },
        { "name": "Mixing bowl", "sortIndex": 1 }
    ],

    "steps": [
        { "number": 1, "text": "Preheat the oven to 350°F (175°C)." },
        { "number": 2, "kind": "sectionHeader", "title": "Make the filling" },
        { "number": 3, "text": "Toss the sliced apples with brown sugar, cinnamon, and lemon juice in a large bowl." },
        { "number": 4, "text": "Transfer the apple mixture to the baking dish." },
        { "number": 5, "kind": "sectionHeader", "title": "Make the topping" },
        { "number": 6, "text": "Combine the oats, flour, and brown sugar. Cut in the cold butter until the mixture resembles coarse crumbs." },
        { "number": 7, "text": "Sprinkle the topping evenly over the apples." },
        { "number": 8, "text": "Bake for 40 minutes, until the topping is golden and the filling is bubbling." }
    ],

    "nutritionInfo": {
        "fat": 14,
        "saturatedFat": 8,
        "carbohydrates": 62,
        "fiber": 5,
        "sugar": 38,
        "protein": 3,
        "sodium": 95
    },

    "images": [
        {
            "uuid": "9F3D2B7A-8C42-4E11-A6B0-5D8EFC0A1234",
            "sortIndex": 0,
            "caption": "Fresh out of the oven",
            "originRawValue": 2,
            "imageData": "/9j/4AAQSkZJRgABAQEASABIAAD/...<truncated>..."
        }
    ],

    "tags": [
        { "name": "dessert" },
        { "name": "weeknight", "colorHex": "5A9463FF" }
    ]
}
```

---

## 13. Producer Checklist

Before publishing `.dish` files for distribution:

- [ ] Use UTF-8 encoding throughout.
- [ ] Either gzip-compress the JSON (recommended for files with embedded images) or leave it uncompressed.
- [ ] Stay under 50 MB after decompression.
- [ ] Provide a fresh `uuid` per published file unless you intend duplicate detection to fire.
- [ ] Provide every `Step.number` (the only truly required field).
- [ ] Encode embedded `imageData` as base64-encoded JPEG; keep each image under 100 megapixels.
- [ ] Cap your `images` array at 20 — anything beyond is silently dropped.
- [ ] Stay within array caps: 10 ingredient sections, 100 ingredients per section, 99 utensils, 99 steps, 50 tags.
- [ ] Set `Step.imageUUID` to the `uuid` of an entry in `images`, never to an arbitrary UUID.
