package hae import ( "bytes" "encoding/json" "strconv" "time" ) // maxEntityID — предел длины идентификатора сущности. // // `id` приходит из тела, которым отправитель управляет целиком, а уезжает и в // первичный ключ таблицы, и в записи лога. UUID HealthKit — 36 байт, так что // запас велик; правило то же, что уже действует для имён непокрытых секций, и // оно снимает класс, а не случай. const maxEntityID = 128 // rfc3339Layout — второй формат метки, которым HAE шлёт stateOfMind // (находка 16). Метрики и тренировки идут первым, `timeLayout`. const rfc3339Layout = time.RFC3339 // entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и // хранится дословно. // // Длительность читается сырым сообщением, а не числом: нечисловое значение — // это пропуск ОДНОГО поля, а не сломанная сущность, и типизированное поле // уводило бы всю тренировку в счётчик «не разобралась как объект». type entityHead struct { ID string `json:"id"` Name string `json:"name"` Date string `json:"date"` Start string `json:"start"` End string `json:"end"` Duration json.RawMessage `json:"duration"` } // decodeEntities разбирает элементы одной покрытой секции в сущности. // // Пропуск одного элемента не уносит соседей: у каждого класса пропуска свой // счётчик, тело остаётся в архиве, и доставку вернёт пересборка, когда разбор // научится понимать пропущенное. func decodeEntities(raws []json.RawMessage, kind string, res *Result) []Entity { if len(raws) == 0 { return nil } out := make([]Entity, 0, len(raws)) for _, raw := range raws { var head entityHead if err := json.Unmarshal(raw, &head); err != nil { res.SkippedEntityMalformed++ continue } if head.ID == "" || len(head.ID) > maxEntityID { res.SkippedNoID++ continue } start, ok := parseEntityTime(firstNonEmpty(head.Start, head.Date)) if !ok { res.SkippedEntityNoTime++ continue } // Конец, которого нет или который не читается, равен началу. У точки то // же вырождение запрещено — там оно схлопнуло бы две записи в одну // координату, — а сущность адресуется своим `id`, и схлопывать нечего. // Истина при этом остаётся в Raw дословно. end := start if head.End != "" { if e, ok := parseEntityTime(head.End); ok { end = e } } _, offset := start.Zone() e := Entity{ ID: head.ID, Kind: kind, Name: head.Name, Start: start.UTC(), End: end.UTC(), OffsetSeconds: offset, Duration: parseDuration(head.Duration), Raw: raw, } out = append(out, e) } if len(out) == 0 { return nil } return out } // parseEntityTime разбирает метку сущности, принимая оба измеренных формата. // // Оба, а не приписанный секции: формы однозначны и не пересекаются (RFC 3339 // несёт `T` и `Z`), а HAE выравнивает секции между собой по ходу своих // обновлений — `stateOfMind` уже шлёт стабильные коды HealthKit там, где старые // секции шлют переводы. Приписанный секции формат ломался бы молча в день // такого выравнивания. // // Метка ТОЧКИ остаётся строгой (parseTime), и асимметрия намеренная: по метке // точки выводится слой, причём по метке в исходной зоне. Терпимость там // означала бы, что метка в UTC тихо портит выравнивание и часовая выгрузка // складывается с минутной; у сущности слоя нет, и терять на строгости нечего. func parseEntityTime(s string) (time.Time, bool) { if s == "" { return time.Time{}, false } if t, err := time.Parse(timeLayout, s); err == nil { return t, true } if t, err := time.Parse(rfc3339Layout, s); err == nil { return t, true } return time.Time{}, false } // parseDuration переводит длительность в секунды. // // Отсутствие и нечисловое значение дают nil, а не ноль: ноль — законная // длительность, и потребитель, сложивший столбец, иначе не отличил бы // «источник не прислал» от «измерено ноль». Вычислять длительность из // интервала нельзя: HAE шлёт 91.746 при интервале в 91 секунду. func parseDuration(raw json.RawMessage) *float64 { lit := bytes.TrimSpace(raw) if len(lit) == 0 { return nil } v, err := strconv.ParseFloat(string(lit), 64) if err != nil { return nil } return &v } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }