summarum/ARCHITECTURE.ru.md

171 lines
14 KiB
Markdown
Raw 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: подсветка, оверлей результатов, копирование
app.css темы (светлая/тёмная через CSS-переменные)
main.ts запуск приложения: документы, настройки, строка статуса, dnd
storage.ts команды Tauri / запасной вариант на localStorage
extensions.ts запускает пользовательские .js-файлы через движок
i18n.ts строки интерфейса (en/ru)
updater.ts проверка tauri-plugin-updater, установка + перезапуск
src-tauri/
src/main.rs трей, скрытие в трей, команды хранилища, загрузка курсов
(open.er-api.com + CoinGecko + Yahoo Finance) с кэшем,
исторические курсы (frankfurter.app, постоянный кэш
на каждую дату), резервные копии, миграция папки данных,
открытие файлов, регистрация плагинов (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-спарклайн вместо текста.
- Реестр не пересобирается никогда; расширения мутируют его (добавляют фразы/функции)
при запуске.
## Особенности UI
- CodeMirror встраивает свою базовую тему во время выполнения *после* `app.css`,
поэтому всё, что должно побеждать (отступ контента для колонки результатов,
цвет курсора), живёт в расширении `EditorView.theme(...)`, а не в стилях.
- Колонка результатов — абсолютно позиционированный оверлей; каждый результат
размещается по координатам `view.lineBlockAt(...)` и перерисовывается при
прокрутке/изменении геометрии. Клик для копирования читает `data-value`.
- Оценка движка синхронная на каждое нажатие клавиши — полный разбор листа
занимает менее миллисекунды, поэтому нет дебаунса и воркеров.
- Поиск по всем листам (`Ctrl+F`) — простое построчное сканирование
`data.contents` в `main.ts`, а не расширение поиска CodeMirror — ему нужно
пересекать документы, поэтому он не трогает активный редактор, пока не
кликнут результат (`editor.goToLine`).
## Хранилище
- `settings.json` всегда находится в `%APPDATA%/app.summarum.calc`.
- `documents.json` (все листы) + `backups/` живут либо там, либо в папке данных,
выбранной пользователем (путь хранится в настройках).
- Ежедневный снимок при запуске (`backups/documents-YYYY-MM-DD.json`, хранится 14);
удалённые листы становятся `.numi`-файлами в `backups/deleted/` и удаляются по возрасту.
Сохранение откладывается на 400 мс.
## Автообновление
- `updater.ts` вызывает `check()` из `tauri-plugin-updater` при запуске; если
подписанное обновление доступно, пользователю предлагают установить, затем
`downloadAndInstall()` + `relaunch()` из `tauri-plugin-process`.
- Эндпоинт обновлений — `latest.json`, публикуемый вместе с каждым GitHub
Release. `release.yml` подписывает обе архитектуры парой ключей minisign:
публичная половина зашита в `tauri.conf.json`; приватная половина + пароль
живут только как секреты репозитория `TAURI_SIGNING_PRIVATE_KEY(_PASSWORD)`
(write-only — в репозитории нет постоянной копии). Потеря этого ключа
означает, что уже установленные копии никогда больше не смогут доверять
будущему подписанному релизу.
## Хранилище (кэш-файлы)
Все файлы ниже живут в `%APPDATA%/app.summarum.calc` (или в папке данных пользователя):
| Файл | Содержимое | TTL |
|------|-----------|-----|
| `settings.json` | настройки | постоянно |
| `documents.json` | все листы | постоянно (дебаунс 400 мс) |
| `rates.json` | живые курсы валют | 1 час |
| `market.json` | котировки акций/сырья | 15 минут |
| `rates-YYYY-MM-DD.json` | исторические курсы ЕЦБ за одну дату | навсегда |
| `backups/documents-YYYY-MM-DD.json` | ежедневные снимки | 14 дней |
| `backups/deleted/*.numi` | мягко удалённые листы | настраивается |
## Тесты
`npm test` запускает 113 vitest-кейсов по движку (`src/engine/*.test.ts`):
все классы выражений, оба языка, детерминированные инжектированные курсы,
поиск неизвестного, исторические курсы (инжектированные), плюс набор регрессий
по всем известным исправленным багам. UI проверяется вручную; движок — там
где живёт вся сложность.