// Package healthkit — знание о значениях HealthKit: словарь локализованных // строк и эквивалентность имён кодов между версиями iOS. // // Отдельно от разбора HAE потому, что источников у этого знания будет два. // HAE отдаёт перечислимые значения строками локали телефона («БДГ», «Сидячий // образ жизни»), а родной экспорт Apple — кодами (находка 37); словарь нужен // первому, таблица синонимов — обоим. Пакет не зависит ни от чего внутреннего и // ничего не пишет: обе операции — чистые функции. // // Чего здесь нет намеренно: знания о том, КАКИЕ поля HAE несут категориальные // значения. Имена `value`, `context`, `name` принадлежат формату HAE и живут в // internal/hae — иначе импорт родного экспорта потянул бы за собой словарь имён // полей чужого приложения. package healthkit import "strings" // Префикс кодов фазы сна. Вынесен ради читаемости таблицы: без него шесть строк // словаря отличаются друг от друга последним словом в конце длинной строки. const sleepPrefix = "HKCategoryValueSleepAnalysis" // dictionary — локализованная строка → код HealthKit, по локалям. // // Словарь не составлен, а ВЫВЕДЕН: сопоставлением потока HAE с родным экспортом // Apple за тот же период (docs/research/apple-health.md, находка 43). Числа // вхождений на живом корпусе — 692 «Основная», 568 «Бодрствование», 206 «БДГ», // 171 «Во сне», 94 «Глубокий», 38 «В кровати» — сошлись с фазами экспорта // однозначно. // // Локаль одна, `ru`: другого языка телефон не присылал ни разу. Строка // незнакомой локали получает код вторым разрядом Code, если сама строка // однозначна, — так что вторая локаль добавляется одной записью и ничего не // ломает. // // Контекста пульса и типов тренировок здесь нет, и это не пробел, а отказ // угадывать: экспорт Apple хранит контекст пульса метаданным-числом, а не // `HKCategoryValue*`, и сопоставление «Сидячий образ жизни» с чем бы то ни было // осталось бы догадкой. Их строки видны реестром с пустым кодом — это и есть // заявка на будущий вывод. var dictionary = map[string]map[string]string{ "ru": { "Основная": sleepPrefix + "AsleepCore", "Бодрствование": sleepPrefix + "Awake", "БДГ": sleepPrefix + "AsleepREM", "Глубокий": sleepPrefix + "AsleepDeep", "В кровати": sleepPrefix + "InBed", "Во сне": sleepPrefix + "AsleepUnspecified", }, } // synonyms — устаревшее имя кода → нынешнее. // // Коды HealthKit устойчивее локализованных строк, но не вечны: те же 338 // записей сна экспортированы как `…Asleep` в 2021 году и как // `…AsleepUnspecified` в 2026-м, причём счётчики сошлись до единицы — Apple // переименовала значение и переписывает историю при выгрузке (находка 43). Без // этой таблицы история раскололась бы вторично, уже на «стабильной» стороне. // // Таблица ПЛОСКАЯ: ни одно её значение не является ключом, поэтому алиас // разрешается ровно за один шаг. Инвариант держит тест по таблице целиком, а не // обход цепочек в рантайме: у обхода нет ни одного достижимого сценария, зато // есть собственный вырожденный случай — «что вернуть при превышении глубины». var synonyms = map[string]string{ sleepPrefix + "Asleep": sleepPrefix + "AsleepUnspecified", } // byValue — строка → код, когда локаль неизвестна. // // Пустой код означает «строка встречается в разных локалях с разными кодами» и // от «строки нет вовсе» на выходе Code не отличается: оба исхода означают «не // угадываем». Различать их незачем — решение одно. // // Индекс существует не ради удобства. Заголовки запроса в сыром архиве не // лежат, поэтому доставка, восстановленная из осиротевшего тела, приезжает без // `Accept-Language`; правило «нет локали — нет кода» сделало бы состояние // функцией от того, уцелела ли учётная строка, то есть сломало бы // `import + replay`. var byValue = buildByValue() func buildByValue() map[string]string { out := make(map[string]string) for _, values := range dictionary { for value, code := range values { code = Canonical(code) if prev, seen := out[value]; seen && prev != code { // Расхождение локалей: выбирать не из чего. out[value] = "" continue } out[value] = code } } return out } // Code возвращает канонический код HealthKit для локализованной строки. // // Три разряда, и второй обязателен, а не удобен (см. byValue): // // 1. пара (локаль, строка) есть в словаре — её код; // 2. локали нет либо пары нет, но строка однозначна по всем локалям — её код; // 3. иначе — пустой код. // // Пустой код честнее догадки: по коду сверяются с экспортом Apple, а неверный // код неотличим от верного до тех пор, пока по нему не примут решение. func Code(locale, value string) string { if value == "" { return "" } if values, ok := dictionary[locale]; ok { if code, ok := values[value]; ok { return Canonical(code) } } return byValue[value] } // Canonical приводит устаревшее имя кода к нынешнему. // // Зовётся и изнутри Code, поэтому «две формы сходятся в один код» верно по // построению, а не по дисциплине того, кто правит словарь. func Canonical(code string) string { if to, ok := synonyms[code]; ok { return to } return code } // maxLocaleTag — предел длины языкового тега. // // Первичный подтег BCP 47 — от двух до восьми букв; предел стоит на всём теге // до отсечения подтегов, с запасом. Заголовок контролирует отправитель целиком, // а `MaxHeaderBytes` у Go — мегабайт: без предела мегабайтная строка уехала бы // в свёртку регистра и в сравнение со словарём. const maxLocaleTag = 32 // Locale нормализует заголовок `Accept-Language` до языкового тега. // // Правило lookup RFC 4647: берётся первый тег списка, вес `q` отбрасывается, // подтеги отсекаются, регистр сворачивается — `RU-ru,ru;q=0.9` даёт `ru`. // Свёртка регистра обязательна: теги BCP 47 регистронезависимы, и без неё `RU` // и `ru` были бы разными языками, а локаль сужает поиск по словарю. // // Согласования весов нет намеренно: измеренное значение заголовка — `ru` // (находка 32), одна строка без вариантов. Появятся веса — правило стоит // пересматривать целиком, а не дописывать. // // Не тег — пустая строка: `*`, пустой заголовок, мусор. Пустая локаль законна и // вывода кода не отменяет (см. Code). func Locale(header string) string { tag := header if i := strings.IndexAny(tag, ",;"); i >= 0 { tag = tag[:i] } tag = strings.TrimSpace(tag) if len(tag) > maxLocaleTag { return "" } if i := strings.IndexByte(tag, '-'); i >= 0 { tag = tag[:i] } if !isLanguageTag(tag) { return "" } return strings.ToLower(tag) } // isLanguageTag проверяет первичный подтег: от двух до восьми ASCII-букв. // // Проверка нужна не эстетике. Без неё `*` из `Accept-Language: *` стал бы // полноценной локалью, а мусор из чужого заголовка — ключом поиска по словарю. func isLanguageTag(s string) bool { if len(s) < 2 || len(s) > 8 { return false } for i := range len(s) { c := s[i] if (c < 'a' || c > 'z') && (c < 'A' || c > 'Z') { return false } } return true }