Files
av 1b649ba3d5 добавлен словарь категориальных значений HAE → коды HealthKit
- фазы сна, контекст пульса и имена тренировок попадают в реестр
  `category_value` (миграция 00010): строка хранится дословно, выведенный код
  лежит рядом отдельной записью, а не полем внутри точки
- словарь и синонимы кодов живут в бинаре (`internal/healthkit`); локаль из
  `Accept-Language` сужает поиск, но в ключ реестра не входит — заголовков в
  сыром архиве нет
- наблюдение входит в отпечаток витрины, выведенный код — нет: он производная
  от словаря, а не от журнала
2026-08-04 07:32:19 +03:00

184 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}