# Архитектура [English](ARCHITECTURE.md) Приложение состоит из двух частей: **движок вычислений на TypeScript + UI** в WebView2 и тонкая **оболочка на Rust** (Tauri 2) для всего, что касается ОС. ``` src/ engine/ сам калькулятор (чистый TS, без DOM) lexer.ts строка → сырые лексемы (числа, слова, символы) vocab-data.ts таблицы слов en+ru: единицы, валюты, операторы vocab.ts объединённый поиск по таблицам unitdata.ts коэффициенты конвертации (базовые единицы на измерение) extraunits.ts скорость/давление/энергия/мощность/частота/топливо + крипто registry.ts фраза → токен; строится один раз при запуске tokenizer.ts лексемы + реестр → семантические токены parser.ts токены → AST (Pratt-парсер, терпимый к «мусору») evaluator.ts AST + контекст документа → значения с единицами formatter.ts значения → строки для отображения datetime.ts карта часовых поясов, календарная арифметика index.ts фасад SumEngine + API расширений + итоги листа ui/ editor.ts обвязка CodeMirror 6: подсветка, оверлей результатов, копирование search.ts оверлей поиска по всем листам (текст + запросы по значению) export.ts рендерит лист + результаты в PNG (буфер обмена/файл) sparkline.ts рендер SVG-спарклайна для результатов `chart` app.css темы (светлая/тёмная через CSS-переменные) main.ts запуск приложения: документы, настройки, строка статуса, dnd workspace.ts межлистовые ссылки: кэш экспортов, обнаружение циклов storage.ts команды Tauri / запасной вариант на localStorage extensions.ts запускает пользовательские .js-файлы в песочнице QuickJS/WASM, мост к API numi.* движка i18n.ts строки интерфейса (en/ru) updater.ts проверка tauri-plugin-updater, установка + перезапуск logger.ts тонкая обёртка над console (info/warn/error) src-tauri/ src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов (open.er-api.com + CoinGecko + Yahoo Finance v8 chart) с кэшем, исторические курсы (frankfurter.dev, постоянный кэш на каждую дату), резервные копии, миграция папки данных, открытие файлов, регистрация плагинов (autostart, global-shortcut, single-instance, opener, dialog, updater, process) ``` ## Как строка превращается в результат 1. **Lexer** разбивает `"5 дюймов в см"` на лексемы: `5`, `дюймов`, `в`, `см`. Тот же lexer сегментирует фразы из словаря, чтобы совпадение было последовательным. 2. **Registry** хранит все фразы, которые понимает движок, по первой лексеме. Побеждает наиболее длинное совпадение; регистрозависимые записи (`m` vs `M`, `kB`) проверяются раньше регистронезависимых, а неоднозначная нижняя форма (`mm` vs `Mm`) просто не получает нечувствительный к регистру запасной вариант. 3. **Tokenizer** проходит по лексемам, спрашивает реестр и выдаёт типизированные токены. Несколько контекстных хаков: например, `in` сразу после числа в конце строки означает дюймы (`6 ft 3 in`). 4. **Parser** — Pratt-парсер, терпимый к шуму: неизвестные слова отбрасываются до разбора (поэтому `потратил 20 на пиццу плюс 5` работает) — кроме слов сразу после оператора конвертации, которые могут называть часовой пояс. Приоритет от наименьшего к наибольшему: конвертации на уровне строки → поразрядные → аддитивные → мультипликативные (включая `20% от x`) → унарные → постфиксные (единицы, `%`, `!`, `^`) → первичные. 5. **Evaluator** вычисляет тройки `{ value: Decimal, unit }`. Операнды одного измерения конвертируются через базовые единицы (`toBase`/`fromBase`); температуры несут смещение, единицы расхода топлива могут быть обратными (mpg ↔ л/100км). Контекст документа предоставляет переменные и результаты предыдущих строк для `сумма`/`avg`/`prev`. 6. **Formatter** отображает значение в формате единицы, с точностью и группировкой из настроек, или в hex/binary/дроби/римских цифрах и т.д. Числа — `decimal.js` везде, никаких артефактов двоичной арифметики. ## Как добавлять вещи **Единицу** — добавь строку в `UNIT_DATA` в `unitdata.ts` (id, измерение, коэффициент к базовой единице измерения) и её названия в `vocab-data.ts` в соответствующей категории (`"myunit.variants": "name,names,имя"`, `"myunit.format": "sym"`). Для совершенно нового измерения расширь объединение `Dimension` в `types.ts` и используй `extraunits.ts` — фразы живут прямо там. **Валюту** — фиатные коды берутся из таблицы `Currency` в `vocab-data.ts`; крипто идёт в `CRYPTO` в `extraunits.ts` (код, id CoinGecko, фразы, снимок цены) плюс зеркальный список в `src-tauri/src/main.rs`. **Оператор/функцию** — зарегистрируй фразу в `buildRegistry()` (`registry.ts`), добавь токен/узел AST если нужен новый синтаксис, реализуй в `evaluator.ts`. Большинство слов-операторов — это данные, а не код. **Город часового пояса** — одна строка в карте `Z` в `datetime.ts`. ## Подводные камни движка - Конфликты фраз реальны: одиночные буквы регистрозависимы по замыслу (`m` метр / `M` миллион, `k` тысяча / `K` кельвин). Генерация префиксов фильтруется там, где пересекается со словами — например, секунды получают только ms/µs/ns/ps, потому что символ «аттосекунды» затенил бы слово `as`. - `и`/`или` двойного назначения: `и` складывает (`5 и 3` = 8), `или` — побитовое. `от/без/на` работают только с процентом в левой части; иначе это просто текст. Тот же токен `на` используется как разделитель даты для исторических курсов (`в EUR на 2024-01-01`) — `parseSeq` проверяет `pctop(on)` + `datelit` сразу после цели конвертации валюты и поглощает оба при совпадении. - Курсы валют — «единиц за 1 USD»; соотношение единицы валюты вычисляется при вычислении из текущей карты курсов, поэтому обновление курсов пересчитывает всё при следующей оценке. Исторические курсы живут отдельно в `historicalRates: Map>` на `SumEngine` и передаются через `EvalCtx` в `evalConv`. - Живые рыночные данные (акции, сырьё через Yahoo Finance) объединяются в ту же карту курсов через `applyAllRates()` в `main.ts`, чтобы один вызов `setRates()` не перезаписывал другой. - Поиск неизвестного (`? * 1.2 = 1000`): `?` токенизируется как `{ t: "unknown" }`, разбирается в узел `{ k: "unknown" }`. `parseLine` обнаруживает `unknown` + `assign` и строит `{ k: "goalseek", lhs, rhs }`. Решатель пробует линейный метод (f(0), f(1) → точный корень для аффинных выражений) и переходит к бисекции для нелинейных. - `ChartValue` (`kind: "chart"`) возвращается из `evalAgg("chart", …)` и минует `formatValue` — оверлей результатов рисует SVG-спарклайн вместо текста. - Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции) при запуске. `buildRegistry()` (`registry.ts`) — тонкий оркестратор над `registerCoreVocab` / `registerUnits` / `registerAreaVolume` / `registerCurrencies` / `registerExtraUnits` / `registerCrypto` / `registerCompletions` — **порядок вызовов важен**: `Registry.addPhrase` оставляет *первую* регистрацию одинаковой фразы и отбрасывает последующие (базовый словарь обязан побеждать фразы единиц/валют, если они случайно совпадают). - Умножение двух величин одного измерения (`2 kg * 500 g`) конвертирует правый операнд в единицу левого перед умножением, чтобы результат имел осмысленную единицу (`1 kg`, а не `1000` в подписи `kg`). Умножение разных измерений (`$12 * 3`, `5 hours * 2`) не затронуто — там правая часть трактуется как простой числовой множитель. - `//` начинает комментарий только в начале строки или после пробела (`findCommentStart` в `index.ts`), поэтому URL вида `see https://example.com` не обрезается на двойном слэше. - **Литералы и форматы дат.** Помимо ISO (`2024-01-01`), лексер понимает `D.M.YYYY` / `M/D/YYYY` (`31.12.2024`, `12/31/2024`) с одинаковым разделителем с обеих сторон и 4-значным годом. Порядок день/месяц разрешается для каждого литерала отдельно: компонент `>12` однозначно определяет, где день; если оба `≤12`, решает настройка движка `dateFormat`. Все три формы литералов отклоняют компоненты вне диапазона через round-trip проверку (`validDateMs` в `lexer.ts` — `new Date(y, mo-1, d)` молча *перекатывает* невалидные даты вроде `2026-13-45` вместо ошибки, поэтому построенные год/месяц/день сравниваются обратно со входом). `SumEngine` разрешает `dateFormat: "system"` в реальный порядок день/месяц один раз через `detectDateOrder()` (`datetime.ts`, пробует `Intl.DateTimeFormat` на фикстуре `2000-12-31`) и передаёт его в `tokenize()` → `lexLine()` при каждом вызове; настройки `"dmy"`/`"mdy"` пропускают детекцию. Отображение работает так же в обратную сторону: `formatter.ts` либо использует форматирование `Intl` по локали ОС (`"system"`), либо строит фиксированную строку `YYYY-MM-DD` / `DD.MM.YYYY` / `MM/DD/YYYY` через `Intl.DateTimeFormat(...).formatToParts()` (это нужно, чтобы уважать собственный IANA-часовой пояс значения, чего форматтер на основе голого `ms` сделать не может). Экземпляры `Intl.DateTimeFormat` кэшируются по ключу опций в `formatter.ts` (`dtf()`), так как их создание сравнительно дорого, а каждая строка с датой пересобирает формат при каждой отрисовке. - У разбора названий часовых поясов (`time in Europe/Berlin`) есть две независимые ловушки. Во-первых, шумовой фильтр `parseLine` оставляет word-токены только сразу после `conv`-токена (`in`/`as`/...); любой другой тип токена сбрасывает это состояние — голый `/` между двумя словами часового пояса обычно отбросил бы второе слово раньше, чем его увидит парсер, поэтому фильтр отдельно разрешает `/`, встреченный при сборе post-conv слов. Во-вторых, цикл сбора слов в `parseTarget()` зеркалит то же исключение, чтобы продолжать читать после `/`, а не останавливаться на первом слове. Нужны оба места — исправление только одного всё равно урезает `Europe/Berlin` до `Europe`. ## Ссылки между листами `@Sheet.key` лексится напрямую в `lexer.ts` (не через реестр фраз — названия листов это произвольный текст пользователя, а не словарь), давая лексему `xref` с `{ sheet, key }`. Движок умеет только *спросить* значение: `EvalCtx.resolveXRef?.(sheet, key)` возвращает `{ ok: true; value } | { ok: false; reason }`; без резолвера (тесты, расширения, `evaluateExpression`) ссылка просто не резолвится. Нерешённые ссылки бросают `XRefError` внутри, он ловится в `SumEngine.evaluateDocument` и превращается в `LineResult.error` (рисуется как `#ref?` в колонке результатов) — все прочие ошибки вычисления по-прежнему тихо дают пустую строку, это не изменилось. Движок не знает про "другие листы" — это целиком живёт в `src/workspace.ts`. `Workspace` читает тексты всех листов из `data.contents` приложения, кэширует экспорт каждого листа (его присвоенные переменные, `total`, `last`) после первого вычисления, и сбрасывает кэш листа плюс всех его транзитивных зависимых (находятся сканированием текста на `@Name.`) при правке листа или обновлении курсов. `Workspace.evaluateSheet(id, text)` кладёт `id` в стек резолвинга на всё время вызова, поэтому лист, ссылающийся сам на себя — напрямую или через другой лист — сразу натыкается на проверку "уже в стеке" и получает `circular reference` вместо бесконечной рекурсии. ## Особенности UI - CodeMirror встраивает свою базовую тему во время выполнения *после* `app.css`, поэтому всё, что должно побеждать (отступ контента для колонки результатов, цвет курсора), живёт в расширении `EditorView.theme(...)`, а не в стилях. - Колонка результатов — абсолютно позиционированный оверлей; каждый результат размещается по координатам `view.lineBlockAt(...)` и перерисовывается при прокрутке/изменении геометрии. Клик для копирования читает `data-value`. - Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа занимает менее миллисекунды, поэтому нет дебаунса и воркеров. - Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование текста каждого открытого листа, а не расширение поиска CodeMirror — ему нужно пересекать документы, поэтому он не трогает активный редактор, пока не кликнут результат (`editor.goToLine`). Живёт в `src/ui/search.ts` (`initSearch()`), который владеет DOM `#search-overlay` и собственным дебаунсом ввода на 150 мс — запрос по значению (`>1000`) пересчитывает каждый открытый лист, так что дебаунс важен, как только листов становится больше пары. - Поповер настроек разбит на четыре вкладки (Общие / Формат / Данные / Обновления) в `index.html`, каждая — `.settings-tabpanel`, переключаемая кнопкой `#settings-tabs .tab` в `bindSettingsUI()` (`main.ts`) — обычный `classList.toggle`, без роутера. Стиль вкладок повторяет идиому сайдбара `doc-item.active` (`background: var(--sel)` для активной таблетки) вместо нового визуального паттерна. ## Хранилище - `settings.json` всегда находится в `%APPDATA%/app.summarum.calc`. - `documents.json` (все листы) + `backups/` живут либо там, либо в папке данных, выбранной пользователем (путь хранится в настройках). - Ежедневный снимок при запуске (`backups/documents-YYYY-MM-DD.json`, хранится 14); удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту. Сохранение откладывается на 400 мс. ## Платформы и упаковка - Одна кодовая база, три десктопных платформы. Всё общее лежит в `tauri.conf.json`, платформенные куски — в `tauri.windows.conf.json`, `tauri.linux.conf.json` и `tauri.macos.conf.json`, которые Tauri накладывает поверх базового конфига (JSON Merge Patch) в зависимости от собираемой цели. Каждый задаёт только `bundle.targets` и опции своего упаковщика: NSIS на Windows, deb/rpm/AppImage на Linux, .app/.dmg на macOS. - `release.yml` собирает пять артефактов на трёх видах раннеров: Windows x64 и arm64, Linux x64 (`ubuntu-22.04` — самый старый раннер с webkit2gtk-4.1, что держит планку glibc низко), macOS arm64 и x64. Две отдельные macOS-цели вместо `universal-apple-darwin`: так же, как Windows уже собирается по архитектурам, вдвое меньше вес загрузки, и апдейтер получает ключи `darwin-aarch64` / `darwin-x86_64`, которые ищет по умолчанию. - Вся матрица остаётся `max-parallel: 1`. Все джобы отдают артефакты в *один и тот же* объект GitHub Release, а проверка find-or-create в tauri-action гонит, если две джобы доходят до неё одновременно — добавление платформ это ограничение не сняло, а распространило на большее число джоб. - Подписи ОС нет нигде. Windows выходит неподписанным; macOS подписан *ad-hoc* (`bundle.macOS.signingIdentity: "-"`) — сертификат Apple для этого не нужен, но без него Apple Silicon вообще откажется запускать скачанный бинарник (сочтёт повреждённым, а не просто предупредит). Gatekeeper в любом случае попросит пользователя разрешить приложение при первом запуске. - `bundle.fileAssociations` покрывает `.numi`/`.sum` на Windows (записи в реестр из NSIS) и macOS (`CFBundleDocumentTypes`). На Linux он не даёт ничего полезного: там для регистрации расширения нужен ещё XML для shared-mime-info, который Tauri не генерирует, так что `.numi` открываются перетаскиванием в окно, а не из файлового менеджера. - `icons/icon.icns` создаёт `scripts/gen-icon.ts` рядом с PNG и `.ico`, вручную и без зависимостей: магия `icns` + общая длина, дальше по чанку `[OSType][длина][PNG]` на размер, с современными PNG-тегами, которые выдаёт `iconutil` (`icp4`/`icp5`, `ic07`–`ic14`). После правки картинки перегенерить через `npm run icons`. ## Автообновление - `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если подписанное обновление доступно, пользователю предлагают установить, затем `downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`. `checkForUpdate()` возвращает `AvailableUpdate | null | "error"` — `null` значит проверка прошла успешно и ничего новее нет, `"error"` значит сама проверка не удалась (офлайн, рассинхрон подписи и т.п.); различие важно только для ручного пути ниже. Теперь виден любой исход: сбои через `console.warn`, а успешный результат «нет обновления» логирует текущую версию рядом с «это последний опубликованный релиз» через `console.info` — самый частый ложный сигнал тревоги это когда установленная сборка И ЕСТЬ последний релиз (например, сразу после его установки), что раньше было неотличимо от молча сломанной проверки. - Закрытие окна прячет его в трей вместо завершения процесса (см. `on_window_event` в `main.rs`), поэтому проверка только при старте может долго оставаться незамеченной на машине, которую редко перезагружают. `main.ts` также перезапускает `checkUpdate()` через `setInterval` (каждые 6 часов), чтобы долгоживущий трей-процесс всё равно замечал новые релизы, а в настройках (вкладка «Обновления») есть кнопка «Проверить обновления» (`checkUpdate(true)`), которая сообщает «последняя версия» / «не удалось проверить» через toast — эти уведомления подавлены на автоматических фоновых проверках, чтобы приложение не напоминало о себе каждые несколько часов. Тумблер `SettingsData.autoUpdateEnabled` отключает и проверку при запуске, и периодическую (значение перечитывается из `settings` на каждом тике интервала, так что переключение прямо во время работы срабатывает сразу); ручная кнопка тумблер игнорирует и работает всегда. - Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub Release. `release.yml` подписывает артефакт обновления каждой платформы парой ключей minisign (это собственная подпись обновлений Tauri, к подписи кода в ОС отношения не имеет): публичная половина зашита в `tauri.conf.json`; приватная половина + пароль живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)` (write-only — в репозитории нет постоянной копии). Потеря этого ключа означает, что уже установленные копии никогда больше не смогут доверять будущему подписанному релизу. ## Хранилище (кэш-файлы) Все файлы ниже живут в `%APPDATA%/app.summarum.calc` (или в папке данных пользователя): | Файл | Содержимое | TTL | |------|-----------|-----| | `settings.json` | настройки | постоянно | | `documents.json` | все листы | постоянно (дебаунс 400 мс) | | `rates.json` | живые курсы валют (open.er-api.com + CoinGecko) | 1 час | | `market.json` | котировки акций/сырья (Yahoo Finance v8 chart, по запросу на символ) | 15 минут | | `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату (frankfurter.dev) | навсегда | | `backups/documents-YYYY-MM-DD.json` | ежедневные снимки | 14 дней | | `backups/deleted/*.numi` | мягко удалённые листы | настраивается | ## Тесты `npm test` запускает 373 vitest-кейса в 21 файле: `src/engine/*.test.ts` покрывает все классы выражений, оба языка, детерминированные инжектированные курсы, поиск неизвестного, исторические курсы (инжектированные), литералы и отображение форматов дат (отдельные движки с явным `dateFormat` — формат «system» по умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным исправленным багам; `src/workspace.test.ts` покрывает межлистовые ссылки, кэширование и обнаружение циклов. UI проверяется вручную; движок — там где живёт вся сложность.