feat: разбор и хранение тренировок и состояния разума
- секции `workouts` и `stateOfMind` покрыты разбором: тренировка лежит одной строкой вместе с маршрутом и внутренними рядами, запись — по ключу `род + id`; миграция 00007 заводит обе таблицы и возвращает в очередь `partial`-доставки с этими ключами - сущность заменяется целиком, но условно: приехавшая побеждает, если не теряет содержания сохранённой (множество ключей и длины верхнеуровневых массивов), а при равном содержании выигрывает версия из более поздней доставки ЖУРНАЛА — «побеждает приехавшая» было бы функцией порядка свёртки, и живая витрина расходилась бы с пересборкой молча - отпечаток витрины покрывает тренировки и записи и снимается одним снимком базы; отчёт `reindex` считает «было и стало» по каждой единице хранения
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user