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
29 KiB
Архитектура
Приложение состоит из двух частей: движок вычислений на 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)
Как строка превращается в результат
- Lexer разбивает
"5 дюймов в см"на лексемы:5,дюймов,в,см. Тот же lexer сегментирует фразы из словаря, чтобы совпадение было последовательным. - Registry хранит все фразы, которые понимает движок, по первой лексеме.
Побеждает наиболее длинное совпадение; регистрозависимые записи (
mvsM,kB) проверяются раньше регистронезависимых, а неоднозначная нижняя форма (mmvsMm) просто не получает нечувствительный к регистру запасной вариант. - Tokenizer проходит по лексемам, спрашивает реестр и выдаёт типизированные токены.
Несколько контекстных хаков: например,
inсразу после числа в конце строки означает дюймы (6 ft 3 in). - Parser — Pratt-парсер, терпимый к шуму: неизвестные слова отбрасываются
до разбора (поэтому
потратил 20 на пиццу плюс 5работает) — кроме слов сразу после оператора конвертации, которые могут называть часовой пояс. Приоритет от наименьшего к наибольшему: конвертации на уровне строки → поразрядные → аддитивные → мультипликативные (включая20% от x) → унарные → постфиксные (единицы,%,!,^) → первичные. - Evaluator вычисляет тройки
{ value: Decimal, unit }. Операнды одного измерения конвертируются через базовые единицы (toBase/fromBase); температуры несут смещение, единицы расхода топлива могут быть обратными (mpg ↔ л/100км). Контекст документа предоставляет переменные и результаты предыдущих строк длясумма/avg/prev. - 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 проверяется вручную; движок — там где
живёт вся сложность.