summarum/ARCHITECTURE.ru.md

26 KiB
Raw Permalink Blame History

Архитектура

English

Приложение состоит из двух частей: движок вычислений на 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-файлы через движок
  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 мс.

Автообновление

  • 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.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 запускает 152 vitest-кейса: src/engine/*.test.ts покрывает все классы выражений, оба языка, детерминированные инжектированные курсы, поиск неизвестного, исторические курсы (инжектированные), литералы и отображение форматов дат (отдельные движки с явным dateFormat — формат «system» по умолчанию зависит от локали машины CI), плюс набор регрессий по всем известным исправленным багам; src/workspace.test.ts покрывает межлистовые ссылки, кэширование и обнаружение циклов. UI проверяется вручную; движок — там где живёт вся сложность.