openapi: 3.1.0
info:
  title: ical-forge
  version: 1.0.0
  description: |
    Генерация и проверка iCalendar (RFC 5545). Текст .ics всегда в UTF-8, строки разделены CRLF и сложены до 75 октетов.
    Календари для подписки хранятся в SQLite 90 дней после последнего изменения. Чтение ленты открыто по ссылке (идентификатор
    24 hex-символа), запись требует заголовок X-Edit-Key, ключ выдаётся один раз при создании.
  contact: {name: Ewsir, url: "https://github.com/ewsir247"}
servers:
  - url: http://localhost:8118
paths:
  /api/generate:
    post:
      summary: Собрать .ics из списка событий (без сохранения)
      parameters:
        - {name: raw, in: query, schema: {type: boolean, default: false}, description: "true: вернуть text/calendar, иначе JSON с полем ics"}
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [events]
              properties:
                name: {type: string, maxLength: 100}
                tz: {type: string, default: UTC}
                events: {type: array, minItems: 1, maxItems: 100, items: {$ref: "#/components/schemas/EventIn"}}
      responses:
        "200":
          description: Календарь
          content:
            application/json:
              schema: {type: object, properties: {ics: {type: string}, events: {type: integer}, bytes: {type: integer}}}
            text/calendar: {schema: {type: string}}
        "422": {$ref: "#/components/responses/Error"}
        "429": {$ref: "#/components/responses/Error"}
  /api/validate:
    post:
      summary: Проверить .ics (тело запроса сам файл, до 1 МиБ, UTF-8)
      requestBody:
        required: true
        content:
          text/calendar: {schema: {type: string}}
          text/plain: {schema: {type: string}}
      responses:
        "200":
          description: Отчёт. Ошибки и замечания с номерами строк
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: {type: boolean}
                  summary: {type: object, properties: {errors: {type: integer}, warnings: {type: integer}}}
                  components: {type: object, additionalProperties: {type: integer}}
                  issues:
                    type: array
                    items:
                      type: object
                      properties:
                        line: {type: integer}
                        severity: {type: string, enum: [error, warning]}
                        code: {type: string}
                        message: {type: string}
                  events: {type: array, items: {$ref: "#/components/schemas/Event"}}
        "413": {$ref: "#/components/responses/Error"}
        "422": {$ref: "#/components/responses/Error"}
  /api/expand:
    post:
      summary: Развернуть RRULE (с EXDATE) в список вхождений
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [start, rrule]
              properties:
                start: {type: string, example: "2026-03-02T10:00"}
                tz: {type: string, default: UTC}
                all_day: {type: boolean}
                rrule: {type: string, example: "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=12"}
                exdate: {type: array, items: {type: string}}
                limit: {type: integer, minimum: 1, maximum: 500, default: 50}
                after: {type: string, description: Не возвращать вхождения раньше этого момента}
      responses:
        "200":
          description: Вхождения
          content:
            application/json:
              schema:
                type: object
                properties:
                  rrule: {type: string, description: Каноническая запись}
                  count: {type: integer}
                  limited: {type: boolean}
                  occurrences: {type: array, items: {type: string}}
        "422": {$ref: "#/components/responses/Error"}
  /api/demo:
    get:
      summary: Демо-файл .ics (помечен «демо»)
      parameters:
        - {name: kind, in: query, schema: {type: string, enum: [ok, broken], default: ok}}
      responses:
        "200":
          description: .ics
          content:
            text/calendar: {schema: {type: string}}
  /api/calendars:
    post:
      summary: Создать календарь для подписки
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties: {name: {type: string, maxLength: 100}, tz: {type: string, default: UTC}}
      responses:
        "201":
          description: Создан. edit_key показывается один раз
          content:
            application/json:
              schema:
                type: object
                properties: {id: {type: string}, edit_key: {type: string}, feed_url: {type: string}, name: {type: string}, tz: {type: string}, note: {type: string}}
        "409": {$ref: "#/components/responses/Error"}
        "422": {$ref: "#/components/responses/Error"}
  /api/calendars/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: string, pattern: "^[0-9a-f]{24}$"}}
    get:
      summary: Календарь и его события
      responses:
        "200": {description: Календарь}
        "404": {$ref: "#/components/responses/Error"}
    delete:
      summary: Удалить календарь
      parameters:
        - {name: X-Edit-Key, in: header, required: true, schema: {type: string}}
      responses:
        "204": {description: Удалён}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
  /api/calendars/{id}/events:
    parameters:
      - {name: id, in: path, required: true, schema: {type: string}}
    post:
      summary: Добавить событие
      parameters:
        - {name: X-Edit-Key, in: header, required: true, schema: {type: string}}
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/EventIn"}
      responses:
        "201":
          description: Событие
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Event"}
        "403": {$ref: "#/components/responses/Error"}
        "409": {$ref: "#/components/responses/Error"}
        "422": {$ref: "#/components/responses/Error"}
  /api/calendars/{id}/events/{uid}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: string}}
      - {name: uid, in: path, required: true, schema: {type: string}}
      - {name: X-Edit-Key, in: header, required: true, schema: {type: string}}
    put:
      summary: Заменить событие (SEQUENCE увеличивается)
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/EventIn"}
      responses:
        "200":
          description: Событие
          content:
            application/json:
              schema: {$ref: "#/components/schemas/Event"}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
    delete:
      summary: Удалить событие
      responses:
        "204": {description: Удалено}
        "403": {$ref: "#/components/responses/Error"}
        "404": {$ref: "#/components/responses/Error"}
  /api/calendars/{id}/export.ics:
    get:
      summary: Скачать календарь как .ics
      parameters:
        - {name: id, in: path, required: true, schema: {type: string}}
      responses:
        "200":
          description: .ics
          content:
            text/calendar: {schema: {type: string}}
        "404": {$ref: "#/components/responses/Error"}
  /feed/{id}.ics:
    get:
      summary: Лента для подписки (webcal)
      description: ETag вычисляется от содержимого; поддерживается If-None-Match (ответ 304). Есть Last-Modified.
      parameters:
        - {name: id, in: path, required: true, schema: {type: string, pattern: "^[0-9a-f]{24}$"}}
        - {name: If-None-Match, in: header, schema: {type: string}}
      responses:
        "200":
          description: .ics
          headers:
            ETag: {schema: {type: string}}
            Last-Modified: {schema: {type: string}}
          content:
            text/calendar: {schema: {type: string}}
        "304": {description: Не изменялась}
        "404": {$ref: "#/components/responses/Error"}
  /healthz:
    get:
      summary: Проверка живости
      responses:
        "200": {description: ok}
components:
  responses:
    Error:
      description: Ошибка
      content:
        application/json:
          schema:
            type: object
            properties:
              error: {type: object, properties: {code: {type: string}, message: {type: string}}}
  schemas:
    EventIn:
      type: object
      required: [summary, start]
      properties:
        summary: {type: string, maxLength: 255}
        start: {type: string, example: "2026-03-02T10:00", description: "ISO 8601; только дата означает событие на весь день"}
        end: {type: [string, "null"]}
        tz: {type: [string, "null"], description: "UTC, floating или имя IANA; по умолчанию часовой пояс календаря"}
        all_day: {type: [boolean, "null"]}
        description: {type: string, maxLength: 5000}
        location: {type: string, maxLength: 255}
        rrule: {type: [string, "null"], example: "FREQ=WEEKLY;BYDAY=MO,WE;COUNT=12"}
        exdate: {type: array, items: {type: string}}
        status: {type: [string, "null"], enum: [TENTATIVE, CONFIRMED, CANCELLED, null]}
        uid: {type: [string, "null"]}
    Event:
      type: object
      properties:
        uid: {type: string}
        summary: {type: string}
        description: {type: string}
        location: {type: string}
        start: {type: string}
        end: {type: [string, "null"]}
        tz: {type: string}
        all_day: {type: boolean}
        rrule: {type: [string, "null"]}
        exdate: {type: array, items: {type: string}}
        status: {type: [string, "null"]}
        seq: {type: integer}
        created: {type: [string, "null"]}
        modified: {type: [string, "null"]}
