summarum/ARCHITECTURE.ru.md
Horizontal42 51ee3b3208 linux + macos release builds (unsigned)
split bundle config into tauri.{windows,linux,macos}.conf.json so each
platform only carries its own targets; gen-icon emits icon.icns; macos is
ad-hoc signed since arm64 refuses unsigned downloads
2026-08-14 13:39:08 +03:00

314 lines
29 KiB
Markdown
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.

# Архитектура
[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 (буфер обмена/файл)
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы в песочнице
QuickJS/WASM, мост к API numi.* движка
i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск
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<date, Map<code, number>>` на `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` запускает 259 vitest-кейсов: `src/engine/*.test.ts` покрывает все
классы выражений, оба языка, детерминированные инжектированные курсы, поиск
неизвестного, исторические курсы (инжектированные), литералы и отображение
форматов дат (отдельные движки с явным `dateFormat` — формат «system» по
умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным
исправленным багам; `src/workspace.test.ts` покрывает межлистовые ссылки,
кэширование и обнаружение циклов. UI проверяется вручную; движок — там где
живёт вся сложность.