mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +03:00
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
314 lines
29 KiB
Markdown
314 lines
29 KiB
Markdown
# Архитектура
|
||
|
||
[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 проверяется вручную; движок — там где
|
||
живёт вся сложность.
|