# Формат файла `.myrecipe` — версия 1

Файл `.myrecipe` — один рецепт MyRecipes в одном переносимом файле: UTF-8
JSON плюс необязательная фотография JPEG в Base64. Формат предназначен для
того, чтобы рецепт можно было сформировать вне приложения (например, в
ChatGPT) и открыть на iPhone в MyRecipes.

Документ описывает **единственную поддерживаемую версию — 1**. Знание
исходного кода приложения не требуется: всё, что нужно для создания
корректного файла, есть ниже.

## Кратко

- Кодировка — UTF-8. BOM в начале допускается.
- Корень — JSON-объект (не массив).
- Обязательные поля: `format`, `version`, `recipe`.
- `format` всегда `"myrecipe"`, `version` всегда `1` (целое число).
- Название, ингредиенты и шаги обязательны; категория, порции, примечания,
  источник и фотография — необязательны.
- Количества — **строки** с точным десятичным числом, например `"0.5"`,
  `"400"`, `"1.125"`. Не числа JSON и не дроби.
- Фотография — JPEG в Base64; без EXIF/GPS на выходе (приложение
  перекодирует изображение). Можно не указывать.
- Файл не содержит исполняемых инструкций и внешних ресурсов. Приложение
  ничего не загружает по ссылкам из файла.
- Открытие файла **не сохраняет рецепт**: сначала показывается предпросмотр,
  рецепт записывается только после «Сохранить» в предпросмотре или
  в редакторе.

Ограничения одного файла:

| Что | Предел |
| --- | --- |
| Размер файла | 12 МиБ |
| Фотография (после Base64) | 6 МиБ |
| Ингредиентов | 200 |
| Шагов | 200 |
| Длина названия | 200 символов |
| Длина названия ингредиента | 300 символов |
| Длина одного шага | 5000 символов |
| Категория | 100 символов |
| Источник | 2048 символов |
| Примечания | 5000 символов |
| Длина строки количества | 40 символов |
| Знаков после запятой в количестве | 18 |

## Схема

```json
{
  "format": "myrecipe",
  "version": 1,
  "recipe": {
    "id": "2f1c9c5e-6a1e-4f0b-9f4a-3c2d1b0a9e81",
    "title": "Сырники",
    "category": "Завтрак",
    "servings": "4",
    "notes": "Подавать тёплыми, со сметаной.",
    "source": "https://example.com/recipes/syrniki",
    "photo": {
      "mediaType": "image/jpeg",
      "base64": "/9j/4AAQSkZJRgABAQAAAQABAAD..."
    },
    "ingredients": [
      { "name": "Творог 5%", "amount": "500", "unit": "gram" },
      { "name": "Яйцо", "amount": "1", "unit": "piece" },
      { "name": "Мука", "amount": "70.5", "unit": "gram" },
      { "name": "Сахар", "amount": "1.5", "unit": "tablespoon" },
      { "name": "Соль", "amount": "0.5", "unit": "teaspoon" }
    ],
    "steps": [
      "Протереть творог через сито.",
      "Добавить яйцо, сахар и соль, перемешать.",
      "Всыпать муку и замесить мягкое тесто.",
      "Сформовать сырники и обжарить по 3–4 минуты с каждой стороны."
    ]
  }
}
```

### Поля

| Поле | Обязательно | Тип | Правила |
| --- | --- | --- | --- |
| `format` | да | строка | Ровно `"myrecipe"`. Другое значение — файл отклоняется. |
| `version` | да | целое число | Ровно `1`. Другие версии отклоняются целиком. |
| `recipe` | да | объект | Данные одного рецепта. |
| `recipe.id` | да | строка | UUID (например, `550e8400-e29b-41d4-a716-446655440000`). Идентификатор **документа**, а не сохраняемого рецепта: приложение всегда создаёт новый рецепт и никогда не перезаписывает существующий по этому ID. |
| `recipe.title` | да | строка | Непустое название после удаления пробелов по краям, до 200 символов. |
| `recipe.category` | нет | строка | Одна категория; в приложении становится тегом (его можно изменить). Пустая строка = категории нет. |
| `recipe.servings` | нет | строка | Точное десятичное число больше нуля (см. «Количества»). Количество порций. |
| `recipe.notes` | нет | строка | Примечания; в приложении — описание рецепта. Пустая строка = примечаний нет. |
| `recipe.source` | нет | строка | Источник: адрес или текст. Показывается в предпросмотре и на экране рецепта; **не загружается** приложением. |
| `recipe.photo` | нет | объект | Фотография: `{ "mediaType": "image/jpeg", "base64": "…" }`. |
| `recipe.photo.mediaType` | да (если есть `photo`) | строка | Только `"image/jpeg"` (регистр не важен). Другие типы не поддерживаются. |
| `recipe.photo.base64` | да (если есть `photo`) | строка | Base64 JPEG. Переносы строк внутри строки допустимы, прочие посторонние символы — нет. |
| `recipe.ingredients` | да | массив | От 1 до 200 объектов ингредиентов, порядок сохраняется. |
| `recipe.ingredients[].name` | да | строка | Непустое название, до 300 символов. |
| `recipe.ingredients[].amount` | да | строка | Точное десятичное число больше нуля. |
| `recipe.ingredients[].unit` | да | строка | Единица измерения из таблицы ниже или однозначное сокращение. Неизвестная строка не подменяется: рецепт открывается, но сохранение блокируется до выбора единицы. |
| `recipe.steps` | да | массив строк | От 1 до 200 шагов в порядке приготовления; каждый — непустая строка до 5000 символов. |

Неизвестные дополнительные поля игнорируются — это позволяет добавлять
необязательные данные без поломки файла. Обязательные поля при этом
проверяются строго.

### Количества

`amount` и `servings` — **строки**, а не числа JSON. Причина: JSON-числа
теряют точность при разборе через двоичную запятую, а количества должны
сохраниться точно.

Допустимо:

- `"400"`, `"0.5"`, `"60.5"`, `"0.000001"`, `"1.125"`;
- до 18 значащих цифр в целой части и до 18 — в дробной;
- ведущие нули (`"007"`) допускаются и не меняют значение.

Недопустимо (файл отклоняется с сообщением о количестве):

- число JSON: `400`, `0.5` (нужна строка `"400"`);
- запятая как разделитель: `"0,5"` — используйте точку;
- знак: `"+1"`, `"-1"`;
- экспонента: `"1e3"`;
- пустая целая или дробная часть: `".5"`, `"1."`;
- ноль и отрицательные значения;
- более 18 знаков после запятой.

Приложение не округляет количества, не переводит граммы в килограммы и не
меняет единицы. `"1.5"` столовой ложки останется `1.5` столовой ложки.

### Единицы измерения

Основные идентификаторы (рекомендуются для генерации):

| Идентификатор | Единица |
| --- | --- |
| `piece` | штука |
| `gram` | грамм |
| `kilogram` | килограмм |
| `milliliter` | миллилитр |
| `liter` | литр |
| `teaspoon` | чайная ложка |
| `tablespoon` | столовая ложка |
| `cup` | стакан |

Дополнительно принимаются однозначные сокращения и названия без учёта
регистра: `pcs`, `шт`, `г`, `гр`, `gram`, `kg`, `кг`, `ml`, `мл`, `l`, `л`,
`tsp`, `ч. л.`, `ч.л.`, `tbsp`, `ст. л.`, `ст.л.`, `cup`, `cups`, `стакан`,
`стакана`, `стаканов`.

**Неизвестная единица не подменяется.** Например, `"unit": "pinch"` —
рецепт откроется, ингредиент будет помечен предупреждением, а сохранение
станет доступно только после того, как пользователь выберет единицу в
предпросмотре или исключит строку.

### Шаги

Только массив строк. Нумерация не нужна: порядок массива — порядок шагов.
Не добавляйте в шаги инструкции для приложения, ссылки или JSON — они
сохранятся как обычный текст шага.

### Фотография

- Только JPEG (`mediaType: "image/jpeg"`), один файл на рецепт.
- После Base64 данные должны начинаться с JPEG-маркера `FF D8 FF` и
  заканчиваться `FF D9`. Файл без конца JPEG считается повреждённым.
- Размер фотографии до Base64 — не больше 6 МиБ. Рекомендуется сторона до
  2048 px и качество 80–85: этого достаточно для экрана iPhone, а файл
  остаётся небольшим.
- Приложение проверяет и перекодирует изображение существующим обработчиком:
  EXIF, GPS и прочие метаданные источника не сохраняются, ориентация
  применяется к пикселям, строится миниатюра для каталога.
- Если фотография отсутствует — рецепт сохраняется без неё.
- Если фотография повреждена или не JPEG — показывается ошибка, рецепт
  можно сохранить без фотографии (или выбрать другое фото в редакторе).
- Внешние URL изображений в формате не предусмотрены: только встроенный
  Base64.

### Идентификатор и дубликаты

`recipe.id` нужен для идентичности документа, но не становится
идентификатором записи. Один и тот же файл можно импортировать несколько
раз: каждый раз создаётся новый рецепт. Существующие рецепты при импорте
не изменяются никогда.

Если в каталоге уже есть рецепт с тем же названием (без учёта регистра и
диакритики), приложение показывает предупреждение; сохранение возможно
только после явного подтверждения, и создаётся новый рецепт.

## JSON Schema

Схема предназначена для генерации и валидации канонических файлов. Ограничения
размера всего файла, реальных байтов JPEG и непустого текста после удаления
пробелов проверяет приложение. Для совместимости импортёр также допускает
`null` в необязательных полях; генератору следует просто пропускать эти поля.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://myrecipes.local/schema/myrecipe-1.json",
  "title": "MyRecipes recipe file",
  "type": "object",
  "required": ["format", "version", "recipe"],
  "properties": {
    "format": { "const": "myrecipe" },
    "version": { "const": 1 },
    "recipe": { "$ref": "#/$defs/recipe" }
  },
  "additionalProperties": true,
  "$defs": {
    "decimalString": {
      "type": "string",
      "maxLength": 40,
      "pattern": "^(?=.*[1-9])0*[0-9]{1,18}(\\.[0-9]{1,18})?$",
      "description": "Точное десятичное число больше нуля. Только точка. Не число JSON."
    },
    "unit": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64,
      "description": "piece | gram | kilogram | milliliter | liter | teaspoon | tablespoon | cup (или однозначное сокращение)."
    },
    "ingredient": {
      "type": "object",
      "required": ["name", "amount", "unit"],
      "properties": {
        "name": { "type": "string", "minLength": 1, "maxLength": 300 },
        "amount": { "$ref": "#/$defs/decimalString" },
        "unit": { "$ref": "#/$defs/unit" }
      },
      "additionalProperties": true
    },
    "photo": {
      "type": "object",
      "required": ["mediaType", "base64"],
      "properties": {
        "mediaType": { "const": "image/jpeg" },
        "base64": { "type": "string", "minLength": 8 }
      },
      "additionalProperties": true
    },
    "recipe": {
      "type": "object",
      "required": ["id", "title", "ingredients", "steps"],
      "properties": {
        "id": { "type": "string", "format": "uuid" },
        "title": { "type": "string", "minLength": 1, "maxLength": 200 },
        "category": { "type": "string", "maxLength": 100 },
        "servings": { "$ref": "#/$defs/decimalString" },
        "notes": { "type": "string", "maxLength": 5000 },
        "source": { "type": "string", "maxLength": 2048 },
        "photo": { "$ref": "#/$defs/photo" },
        "ingredients": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": { "$ref": "#/$defs/ingredient" }
        },
        "steps": {
          "type": "array",
          "minItems": 1,
          "maxItems": 200,
          "items": { "type": "string", "minLength": 1, "maxLength": 5000 }
        }
      },
      "additionalProperties": true
    }
  }
}
```

## Полный пример без фотографии

Файл `Examples/oatmeal-without-photo.myrecipe`:

```json
{
  "format": "myrecipe",
  "version": 1,
  "recipe": {
    "id": "6c0f5b1e-2d4a-5b3a-8e7f-90a1b2c3d4e5",
    "title": "Овсяная каша с яблоком",
    "category": "Завтрак",
    "servings": "2",
    "notes": "Если каша кажется густой, добавьте немного молока.",
    "source": "Семейный рецепт",
    "ingredients": [
      { "name": "Овсяные хлопья", "amount": "80", "unit": "gram" },
      { "name": "Молоко", "amount": "400", "unit": "milliliter" },
      { "name": "Яблоко", "amount": "1", "unit": "piece" },
      { "name": "Соль", "amount": "0.5", "unit": "teaspoon" }
    ],
    "steps": [
      "Довести молоко до кипения.",
      "Всыпать хлопья, добавить соль и варить 7 минут.",
      "Яблоко натереть и добавить за минуту до готовности."
    ]
  }
}
```

Пример с фотографией: `Examples/syrniki-with-photo.myrecipe` (Base64
опущен здесь, но полностью присутствует в файле).

## Пример создания в ChatGPT

1. Приложите фотографию блюда к сообщению (если она нужна).
2. Отправьте инструкцию (для файла с фотографией нужен режим ChatGPT,
   умеющий создавать файлы и кодировать приложенные изображения):

```text
Сформируй ОДИН файл рецепта в формате MyRecipes .myrecipe версии 1.

Формат: UTF-8 JSON, корень — объект с полями format ("myrecipe"),
version (1) и recipe. Поля recipe: id (новый UUID), title (строка),
category (строка, необязательно), servings (строка с десятичным числом,
необязательно), notes (строка, необязательно), source (строка,
необязательно), photo (объект {"mediaType":"image/jpeg","base64":"…"},
необязательно), ingredients (массив объектов {name, amount, unit}),
steps (массив строк).

Количества — строки с точным десятичным числом и точкой: "400", "1.5",
"0.333". Не числа JSON, не дроби, без запятой. Единицы: piece, gram,
kilogram, milliliter, liter, teaspoon, tablespoon, cup.

Если приложена фотография, вставь её как JPEG в Base64 в recipe.photo.
Если фотографии нет — не добавляй поле photo. Никаких внешних URL для
изображения.

Создай скачиваемый файл с расширением .myrecipe. Запиши JSON в UTF-8,
а реальные JPEG-байты фотографии закодируй программно в Base64. Не вставляй
многоточия, заглушки или выдуманные данные. Если создание файлов недоступно,
верни JSON без фотографии, без пояснений и без markdown.
```

3. Скачайте созданный файл `.myrecipe`. Если получен текстовый ответ без
   фотографии, сохраните его как `syrniki.myrecipe` — целиком, от `{` до `}`.

Если фотография в ответе получилась не JPEG или файл слишком большой
(больше 12 МиБ), уберите поле `photo` и сохраните рецепт без фотографии.

## Как открыть файл на iPhone

### Способ 1 — «Файлы» → «Поделиться» → MyRecipes

1. Скачайте `.myrecipe` в «Файлы» (Safari, Почта, iCloud Drive, AirDrop).
2. Откройте приложение «Файлы», найдите файл.
3. Нажмите на файл (или удерживайте → «Поделиться»).
4. В меню «Поделиться» выберите **MyRecipes**. Если приложения не видно,
   прокрутите список приложений и выберите «Ещё».
5. Откроется предпросмотр рецепта. Проверьте данные, при необходимости
   нажмите «Редактировать», затем «Сохранить». «Отмена» ничего не сохраняет.

После регистрации типа iOS может открывать MyRecipes нажатием на файл,
включая холодный запуск. Конкретное меню зависит от источника файла и
приложений, установленных на iPhone. Если MyRecipes не предложен в меню,
используйте резервный способ внутри приложения ниже.

### Способ 2 — внутри MyRecipes (резервный)

1. Откройте MyRecipes → «Рецепты» → кнопка «Импортировать» (карандаш).
2. Выберите **«Из файла»**.
3. Нажмите «Выбрать файл .myrecipe» и укажите файл.
4. Проверьте предпросмотр и сохраните рецепт.

Оба способа используют один и тот же разбор и один и тот же редактор;
рецепт попадает в каталог с фотографией (если она была) и остаётся после
перезапуска приложения.

## Что приложение проверяет

- Файл не больше 12 МиБ; чтение ограничено по памяти.
- UTF-8 и корректный JSON.
- `format` и `version` поддерживаются.
- Обязательные поля и типы; лимиты количества элементов и длин строк.
- Количества — точные десятичные строки больше нуля.
- Единица: каноническая или однозначное сокращение; иначе — предупреждение
  и исправление пользователем, без подмены.
- JPEG: MIME-тип, Base64, маркеры начала и конца, размер, реальная
  декодируемость (обработчик изображений). Ошибка фото не уничтожает
  рецепт: его можно сохранить без фотографии.
- Дубликат названия — предупреждение перед созданием нового рецепта.
- Ничего не загружается из сети и не выполняется как код.

## Совместимость и версии

- Версия 1 — единственная поддерживаемая. Файл с другой версией
  отклоняется с понятным сообщением; данные не читаются частично.
- Будущие версии формата будут объявляться новым числом `version`.
- Необязательные поля можно не указывать. Неизвестные дополнительные поля
  игнорируются.
- `MYRECIPE_FORMAT.md` — источник истины для формата; генератор должен
  опираться на него, а не на внутренние типы приложения.
