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 // softString — строка заголовка, которая переживает значение не того типа. // // Значение не того ТИПА стоит одного поля, а не сущности. Правило уже записано // рядом для длительности («нечисловое значение — это пропуск ОДНОГО поля, а не // сломанная сущность»); без него `name`, приехавшее числом, уносит тренировку // вместе с маршрутом — а доставка при этом числится разобранной, и ретеншен // получает ответ «терять нечего» ровно там, где потеряно 95% содержимого. // // Через json.Unmarshaler, а не через разбор ошибки постфактум. Соблазн есть: // encoding/json при несовпадении типа «skips that field and completes the // unmarshaling as best it can» и возвращает *UnmarshalTypeError, то есть // трёхстрочный errors.As выглядел бы равноценным. Он неравноценен — та же // документация оговаривает, что дозаполнение полей ПОСЛЕ проблемного не // гарантировано. Разбор, построенный на этом, перестал бы быть функцией тела: // одна и та же тренировка давала бы разный заголовок в зависимости от порядка // ключей на проводе, а он у HAE нестабилен. // // Различение счётчиков сохраняется само: элемент, который сам не объект, даёт // ошибку ВЕРХНЕГО уровня и по-прежнему уходит в «не разобралось как объект», а // не в «нет id». // Признак `present` отличает «ключа не было» от «ключ был, но строки из него не // вышло». Различие нужно ровно одному полю — метке начала, — и там оно // существенно: см. фолбэк `start → date` ниже. // // Именно «ключ был», а не «значение не той формы»: `null` тоже даёт пустую // строку, и без этого различения `{"date":"…","start":null}` уводил бы // тренировку на момент времени из другого поля — молча и без счётчика. type softString struct { value string // present — ключ присутствовал в объекте. UnmarshalJSON зовётся только на // присутствующий ключ, поэтому признак взводится безусловно. present bool } func (s *softString) UnmarshalJSON(raw []byte) error { // Приёмник задаётся ЦЕЛИКОМ, а не дописывается. JSON допускает повтор // ключа, и encoding/json зовёт UnmarshalJSON на каждое вхождение с // семантикой «побеждает последнее» — так работает соседний Duration и весь // разбор метрик. Накопленный признак сделал бы разбор функцией не тела, а // истории вызовов: `{"start":123,"start":"2025-06-05 …"}` терял бы // тренировку с маршрутом при валидной последней метке. *s = softString{present: true} var v string if err := json.Unmarshal(raw, &v); err != nil { // Значение не строка — поле считается непрочитанным. Ошибку глушим // сознательно: это и есть мягкость, ради которой тип заведён. return nil } s.value = v return nil } // entityHead — поля сущности, нужные разбору. Всё остальное остаётся в Raw и // хранится дословно. type entityHead struct { ID softString `json:"id"` Name softString `json:"name"` Date softString `json:"date"` Start softString `json:"start"` End softString `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 { // Род элемента проверяется ДО разбора, потому что `json.Unmarshal` // «null» в структуру ошибкой не считает (для JSON null это no-op) — и // элемент-`null` уходил бы в счётчик «нет id», то есть сменившаяся // форма СЕКЦИИ диагностировалась бы как сменившаяся форма // ИДЕНТИФИКАТОРА. Два счётчика заведены ровно ради этого различия. if !isJSONObject(raw) { res.SkippedEntityMalformed++ continue } var head entityHead if err := json.Unmarshal(raw, &head); err != nil { res.SkippedEntityMalformed++ continue } // Идентификатор исключение из мягкости: без строкового `id` сущность не // адресуема, а приведение чужого нестрокового значения к строке было бы // выдумыванием идентичности за источник. Нестроковый `id` мягкое чтение // уже превратило в пустую строку — исход тот же, что у отсутствующего. id := head.ID.value if id == "" || len(id) > maxEntityID { res.SkippedNoID++ continue } // Фолбэк `start → date` существует для сущностей, у которых ключа // `start` НЕТ ВОВСЕ. Если ключ пришёл, но строки из него не вышло // (число, объект, `null`), фолбэк не срабатывает: композиция двух // правил подставила бы метку ДРУГОГО момента времени — неотличимую от // настоящей и ничем не считаемую. Такой `start` считается неразбираемой // меткой. if head.Start.present && head.Start.value == "" { res.SkippedEntityNoTime++ continue } start, ok := parseEntityTime(firstNonEmpty(head.Start.value, head.Date.value)) if !ok { res.SkippedEntityNoTime++ continue } // Конец, которого нет или который не читается, равен началу. У точки то // же вырождение запрещено — там оно схлопнуло бы две записи в одну // координату, — а сущность адресуется своим `id`, и схлопывать нечего. // Истина при этом остаётся в Raw дословно. end := start if head.End.value != "" { if e, ok := parseEntityTime(head.End.value); ok { end = e } } _, offset := start.Zone() e := Entity{ ID: id, Kind: kind, Name: head.Name.value, 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 } // isJSONObject говорит, является ли значение объектом JSON, по первому байту // литерала — так же, как это делает сканер encoding/json. func isJSONObject(raw json.RawMessage) bool { lit := bytes.TrimSpace(raw) return len(lit) > 0 && lit[0] == '{' } func firstNonEmpty(a, b string) string { if a != "" { return a } return b }