diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index b1a7c48..32d02f7 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -92,6 +92,16 @@ jobs: VERSION="$(./scripts/bump-version.sh '${{ steps.plan.outputs.kind }}' | tail -1)" echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Раздел «Не выпущено» закрывается здесь же, а не руками: пока это была + # отдельная обязанность человека, записи 0.1.10 уехали в релиз, так и + # оставшись под заголовком «не выпущено». Из закрытого раздела release.yml + # соберёт заметки к релизу. + - name: Закрыть версию в журнале + if: steps.plan.outputs.release == 'yes' + env: + VERSION: ${{ steps.bump.outputs.version }} + run: ./scripts/changelog.sh release "$VERSION" + - name: Проверить, что тега ещё нет if: steps.plan.outputs.release == 'yes' env: @@ -112,11 +122,17 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add Sources/ClaudeWeekCore/Version.swift + git add Sources/ClaudeWeekCore/Version.swift CHANGELOG.md if git diff --cached --quiet; then - echo "Version.swift не изменился — коммит не нужен, тег ставлю на текущий main" + echo "ни версия, ни журнал не изменились — коммит не нужен, тег ставлю на текущий main" else - git commit -m "Поднял версию до $VERSION" + # Версию могли поднять прямо в PR (kind=keep) — тогда в коммит идёт + # один журнал, и сообщение должно говорить именно об этом. + if git diff --cached --quiet -- Sources/ClaudeWeekCore/Version.swift; then + git commit -m "Закрыл версию $VERSION в журнале изменений" + else + git commit -m "Поднял версию до $VERSION и закрыл журнал" + fi git push origin HEAD:main fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68c18e6..01ab9d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -146,7 +146,50 @@ jobs: - name: Заметки к релизу run: | PREV="$(git describe --tags --abbrev=0 "$GITHUB_REF_NAME^" 2>/dev/null || true)" + REPO="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" + + # Что нового рассказывает журнал, а не история git: сообщения коммитов + # написаны про код, а тому, кто скачивает образ, нужно, что изменилось + # для него. Раздел версии закрывает release-on-merge.yml; молчит + # журнал (релиз собран руками, запись забыли) — заметки собираются из + # коммитов, как раньше. Ссылки внутри журнала относительные, на + # странице релиза они ведут в никуда — разворачиваем их на текущий тег. + NOTES="" + if NOTES="$(./scripts/changelog.sh section "$VERSION")"; then + NOTES="$(printf '%s\n' "$NOTES" \ + | sed -E "s#\]\((docs/|scripts/|\.github/)#](${REPO}/blob/${GITHUB_REF_NAME}/\1#g")" + fi + { + echo '## Что нового' + echo + if [ -n "$NOTES" ]; then + printf '%s\n' "$NOTES" + # Коммиты — под спойлером и ниже журнала: они про код, а страницу + # выпуска открывают, чтобы узнать, что изменилось для человека. + # Порядок ещё и практический: окно обновления в самой программе + # показывает первые строки заметок, обрывая их на первой разметке. + if [ -n "$PREV" ]; then + echo + echo '
' + echo "Коммиты с $PREV" + echo + git log --pretty='- %s' "$PREV..$GITHUB_REF_NAME" + echo + echo '
' + fi + echo + echo "Прошлые версии — в [журнале изменений]($REPO/blob/$GITHUB_REF_NAME/CHANGELOG.md)." + elif [ -n "$PREV" ]; then + git log --pretty='- %s' "$PREV..$GITHUB_REF_NAME" + else + echo '- первый релиз' + fi + if [ -n "$PREV" ]; then + echo + echo "[Все изменения с $PREV]($REPO/compare/$PREV...$GITHUB_REF_NAME)" + fi + echo echo '## Установка' echo echo "Скачайте \`ClaudeWeek-$VERSION-arm64.dmg\`, перетащите приложение" @@ -171,16 +214,6 @@ jobs: echo 'Требуется macOS 14+ на Apple Silicon (M1 и новее). Готового образа' echo 'под Intel нет — там сборка из исходников тем же `install.sh`.' echo - echo '## Что изменилось' - echo - if [ -n "$PREV" ]; then - git log --pretty='- %s' "$PREV..$GITHUB_REF_NAME" - echo - echo "[Все изменения с $PREV](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/$PREV...$GITHUB_REF_NAME)" - else - echo '- первый релиз' - fi - echo echo '## Контрольная сумма' echo echo '```' diff --git a/CHANGELOG.md b/CHANGELOG.md index 140a4da..b5d8fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,204 +1,246 @@ -# Changelog - -All notable changes to ClaudeWeek are recorded here. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -Entries for 0.1.0–0.1.7 were reconstructed from git history and release notes — -the file was started during 0.1.8 development. From 0.1.8 on, every change is -written here in the same pull request that makes it, under `Unreleased`; the -heading is renamed to the version once the release goes out. Release notes on -GitHub are still generated from commit subjects by `release.yml`, so this file -is the place for the shorter, human-facing story. - -## [Unreleased] - -### Changed - -- New app icon: the ring from the menu bar, drawn large on an ink-coloured - tile — green from 0 to 80 %, amber to 90 %, red closing the circle to 100 %, - in the same colours the panel uses. The old icon was three week bars, and - below 32 pt they merged into a green smudge; the ring survives down to 16 pt, - which is the size Spotlight and the "Open with" list show. Thresholds of the - icon are deliberately not the notification defaults (80 / 95): five per cent - of a circle is one pixel at that size, and red would not be visible at all. -- Notification screenshots in the README and the guide retaken — the banners - carry the app icon, so both showed the old one. The README now opens with the - icon as well (`docs/images/app-icon.png`). +# Журнал изменений + +Здесь записано всё заметное, что менялось в ClaudeWeek. + +Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — по +[семантическому версионированию](https://semver.org/lang/ru/). + +Записи 0.1.0–0.1.7 восстановлены по истории git и заметкам к релизам: сам файл +завели уже во время работы над 0.1.8. С 0.1.8 каждое изменение пишется сюда тем +же pull request, который его вносит, — в раздел «Не выпущено». Заголовок +раздела превращается в номер версии при выпуске, и делает это +[workflow](.github/workflows/release-on-merge.yml), а не человек: строчка, +которую надо не забыть, рано или поздно забывается. Отсюда же собираются +заметки к релизу на GitHub, так что написанное здесь читают не только те, кто +заглядывает в репозиторий. + +## [Не выпущено] + +### Добавлено + +- Английский интерфейс. Язык следует за системным — русский на русской macOS, + английский на любой другой, — а на вкладке «Общие» выбирается явно: «Как в + системе», «Русский» или «English». Переключается на ходу, без перезапуска: + язык лежит в конфиге, и панель с настройками перерисовываются по нему тем же + движением, что и на любую другую правку. Переведено всё, что видит человек: + панель и её разбивка по моделям, меню строки меню, шесть вкладок настроек, + баннеры уведомлений, окна обновления и ошибки, доходящие до панели. Дни + недели, длительности и единицы токенов идут за языком со своими правилами + склонения — «2 дня 4 часа» и «2 days 4 hours». Лог-файл остаётся русским: его + читают при разборе поломки, и одну ошибку не должно быть видно двумя разными + строками. +- `--lang=system|ru|en` — язык на один запуск, не трогая настройку. Тем же + ключом снимаются английские картинки: `--screenshot` рисует то, что выбрано. +- Ссылка «журнал изменений» рядом с номером версии на вкладке «О программе» — + открывает этот файл на GitHub. Раньше прочитать, что изменилось, можно было + только при доступном обновлении: кнопка «Что нового» появляется вместе с ним + и ведёт на страницу выпуска. +- `scripts/changelog.sh` — закрыть версию в журнале (`release`) и достать её + раздел (`section`). Им же пользуются оба релизных workflow, так что + посмотреть заранее, что уедет в заметки, можно той же командой. + +### Изменено + +- Журнал ведётся по-русски, как и вся остальная документация проекта, и + разделы в нём русские: «Добавлено», «Изменено», «Исправлено», «Заметки». +- Заметки к релизу начинаются с раздела журнала, а список коммитов уехал под + спойлер ниже: страницу выпуска открывают, чтобы узнать, что изменилось для + человека, а не какие подписи были у коммитов. Из тех же строк собирается окно + обновления в самой программе — теперь оно показывает первые пункты «что + нового», а не начало инструкции по установке. +- Раздел «Не выпущено» закрывает `release-on-merge.yml` тем же шагом, которым + поднимает версию: заголовок превращается в номер с датой, сверху заводится + новый пустой, ссылки сравнения дописываются. Пока это была ручная + обязанность, она не пережила первого же выпуска — записи 0.1.10 уехали в + релиз, так и оставшись под «не выпущено»; заодно эта версия закрыта задним + числом. + +## [0.1.10] — 2026-08-16 + +### Изменено + +- Новая иконка приложения: то самое кольцо из строки меню, нарисованное крупно + на чернильной плитке — зелёное от 0 до 80 %, жёлтое до 90 %, красное замыкает + круг на 100 %, теми же цветами, какими говорит панель. Прежней иконкой были + три полосы недели, и мельче 32 pt они сливались в зелёное пятно; кольцо + держится до 16 pt — размера, которым его показывают Spotlight и список + «Открыть в программе». Пороги на иконке намеренно не те, что стоят по + умолчанию у уведомлений (80 / 95): пять процентов круга на таком размере — + один пиксель, красного не было бы видно вовсе. +- Скриншоты уведомлений в README и руководстве пересняты: баннер несёт иконку + приложения, и на обоих она была старая. README теперь и открывается иконкой + (`docs/images/app-icon.png`). ## [0.1.9] — 2026-08-16 -### Added - -- Notifications when spend crosses a threshold you set: two thresholds per - limit — 80 % and 95 % for the week, 75 % and 95 % for the 5-hour session — - configured on a new "Уведомления" tab, with a "Показать пример" button in each - section that sends the real banner on demand. Each limit can be silenced on - its own, and one switch turns all notifications off. The banner is two lines — - how much is spent and how long until the reset — and *which* limit it is comes - from the artwork rather than a third line of text: the 5-hour session arrives - as an arc filled to the spend, the weekly limit as a red number, the same - language the menu bar icon speaks, coloured by the notification thresholds - themselves — amber between the two, red from the second one on. Three rules - keep it quiet: one banner per - threshold per limit window, only on the way up, and no two banners closer than - five minutes. What has been said is stored in - `~/.config/claude-week/alerts.json`, so a restart mid-week does not repeat it. - The decision logic and the wording both live in the core (`AlertPlanner`, - `LimitAlert.message`) and are covered by tests. -- `--screenshot` now also renders the notifications tab - (`settings-notifications-light.png`, `settings-notifications-dark.png`). Form - views are captured through a real window: `ImageRenderer` returns an empty - rectangle for a grouped `Form`, and an inactive window would draw every switch - grey, making enabled settings look off. -- `Formatting.longDuration` — the same interval in words ("2 дня 4 часа", - "1 час 12 минут"), with Russian pluralisation. The panel keeps the short form; - a banner has room for the long one. - -### Notes - -- Notifications are on out of the box: a feature that stays silent until you - find its tab does not do the job it was added for. macOS asks for permission - once, on the first launch of the new version; if it is refused, the tab says - so and offers the only place the ban can be lifted — System Settings. A build - run straight from `swift run` has no notifications at all: macOS identifies an - app by its bundle, and there isn't one. -- Session notifications need the official source. The local estimate does not - compute the 5-hour percentage at all, so offline there is nothing to warn - about — and an expired session stays quiet rather than warning off a cached - number that reset hours ago. +### Добавлено + +- Уведомления, когда расход перешагнул заданную вами отметку: по два порога на + лимит — 80 и 95 % у недели, 75 и 95 % у пятичасовой сессии, — на новой + вкладке «Уведомления», где в каждой секции есть кнопка «Показать пример»: она + присылает настоящий баннер по требованию. Каждый лимит выключается отдельно, + все уведомления разом — одним тумблером. В баннере две строки — сколько + потрачено и через сколько сброс, — а *какой* это лимит, говорит картинка, а + не третья строка текста: пятичасовая сессия приходит дугой, залитой по + расходу, недельный лимит — красным числом. Тот же язык, на котором говорит + значок в строке меню, и цвет считается по самим порогам уведомлений — жёлтый + между первым и вторым, красный от второго и выше. Тишину держат три правила: + один баннер на порог за окно лимита, только на ухудшении и не чаще раза в + пять минут. Сказанное хранится в `~/.config/claude-week/alerts.json`, поэтому + перезапуск посреди недели ничего не повторяет. И решение, и формулировки + живут в ядре (`AlertPlanner`, `LimitAlert.message`) и покрыты тестами. +- `--screenshot` снимает теперь и вкладку уведомлений + (`settings-notifications-light.png`, `settings-notifications-dark.png`). + Формы снимаются через настоящее окно: `ImageRenderer` отдаёт для + сгруппированной `Form` пустой прямоугольник, а в неактивном окне все + переключатели рисуются серыми — включённые настройки выглядели бы + выключенными. +- `Formatting.longDuration` — тот же интервал словами («2 дня 4 часа», + «1 час 12 минут»), со склонением. Панели хватает короткой формы, а в баннере + есть место под длинную. + +### Заметки + +- Уведомления включены из коробки: функция, молчащая до тех пор, пока не найдёшь её + вкладку, не делает того, ради чего её добавляли. macOS спрашивает разрешение + один раз, при первом запуске новой версии; если отказали — вкладка так и + говорит и ведёт в единственное место, где запрет снимается, в Системные + настройки. У сборки, запущенной прямо через `swift run`, уведомлений нет + вовсе: macOS узнаёт программу по бандлу, а его там нет. +- Уведомлениям о сессии нужен официальный источник. Локальная оценка процент + пятичасового лимита не считает вовсе, поэтому офлайн предупреждать не о чем — + а истёкшая сессия молчит, вместо того чтобы пугать цифрой из кеша, сброшенной + несколько часов назад. ## [0.1.8] — 2026-08-15 -### Added +### Добавлено -- Model breakdown in the panel: clicking the percentage — on a day row or on - the 5-hour session row — replaces the day rows with one row per model (Opus, - Sonnet, Haiku), each with the same kind of bar showing its share of the - week's spend. Clicking the percentage again brings the week back. Every share - carries a `≈`, and a line under the rows marks the whole thing as a rough - local estimate. Hovering a row gives the rest: share of the weekly limit, - input / output / cache tokens, reply count and weighted cost. -- `--screenshot` now also renders the panel in that state +- Разбивка по моделям в панели: щелчок по проценту — в строке дня или в строке + пятичасовой сессии — заменяет дни строками моделей (Opus, Sonnet, Haiku), и у + каждой такая же полоса с её долей в расходе недели. Щелчок по проценту ещё + раз возвращает неделю. Каждая доля идёт со знаком `≈`, а строка под ними + прямо говорит, что это грубая локальная оценка. Наведение на строку добавляет + остальное: долю недельного лимита, токены на вход, выход и кеш, число ответов + и взвешенную стоимость. +- `--screenshot` снимает теперь и панель в этом состоянии (`panel-models-light.png`, `panel-models-dark.png`). -### Changed +### Изменено -- The update status on the About tab now names the version and the day it was - checked: "у вас последняя — 0.1.7, проверено ВТ в 14:23". Previously it gave - the time only, which read as "today" for a check made two days ago. -- Usage index schema bumped to 2: records now carry the model family and token - counts, which is what the breakdown is built from. The old index cannot be - migrated and is rebuilt from transcripts on first run — one longer scan, then - business as usual. A stale index is now recognised by its version before it is - parsed, so a schema change no longer looks like a corrupted file in the log. +- Состояние обновления на вкладке «О программе» называет версию и день + проверки: «у вас последняя — 0.1.7, проверено ВТ в 14:23». Раньше там стояло + одно время, и проверка двухдневной давности читалась как сегодняшняя. +- Схема индекса расхода поднята до 2: записи несут семейство модели и число + токенов — из этого и собрана разбивка. Старый индекс не мигрируется, а + пересобирается из транскриптов при первом запуске — один долгий проход, дальше + как обычно. Устаревший индекс теперь узнаётся по версии до разбора, поэтому + смена схемы больше не выглядит в логе испорченным файлом. -### Notes +### Заметки -- The breakdown is computed from local transcripts in `~/.claude/projects` and - weighted by model prices, so it is an estimate — which the panel states - plainly while the breakdown is on screen. The official `/api/oauth/usage` - endpoint reports one number for the whole week and says nothing about - models — see [docs/API.md](docs/API.md). The week total in the footer is - still the exact figure from the server. +- Разбивка считается по локальным транскриптам в `~/.claude/projects` и + взвешивается ценами моделей, то есть это оценка — о чём панель прямо и + говорит, пока разбивка на экране. Официальный `/api/oauth/usage` отдаёт одно + число на всю неделю и про модели не сообщает ничего — см. + [docs/API.md](docs/API.md). Итог недели в подвале по-прежнему точная цифра + сервера. ## [0.1.7] — 2026-08-15 -### Added +### Добавлено -- Releases now go out on pull request merge: `release-on-merge.yml` bumps the - version, tags it and starts the release build. PR labels pick the part — - `версия:мажор`, `версия:минор`, patch by default, `без-релиза` to skip. - Documentation-only merges do not produce a release. -- `scripts/bump-version.sh` for raising the version in `Version.swift`. +- Релизы выходят по слиянию pull request: `release-on-merge.yml` поднимает + версию, ставит тег и запускает сборку релиза. Разряд выбирается метками PR — + `версия:мажор`, `версия:минор`, по умолчанию патч, `без-релиза` пропускает + выпуск. Слияние с одной только документацией релиза не даёт. +- `scripts/bump-version.sh` — поднять версию в `Version.swift`. ## [0.1.6] — 2026-08-10 -### Changed +### Изменено -- Release builds moved to the `macos-26` runner: on macOS 26 the look of an app - is decided by the SDK it was linked with, and builds from the older runner - looked dated next to locally built ones. The workflow now fails if the SDK - turns out to be older than 26. -- Factory defaults changed to the settings the app had actually been used with; - README screenshots were regenerated to match. +- Сборка релиза переехала на раннер `macos-26`: на macOS 26 облик программы + решает SDK, которым она слинкована, и сборки со старого раннера выглядели + устаревшими рядом с собранными дома. Workflow теперь падает, если SDK + оказался старее 26. +- Заводские настройки заменены на те, с которыми программой на самом деле + пользовались; скриншоты в README пересняты под них. -### Fixed +### Исправлено -- Strict builds no longer break on deprecated Keychain keys. +- Строгая сборка больше не спотыкается об устаревшие ключи Keychain. ## [0.1.5] — 2026-08-10 -### Fixed +### Исправлено -- The Keychain access prompt no longer comes back after every update: the token - is read through `/usr/bin/security` rather than a direct Keychain query, so - the permission survives the token being refreshed by Claude Code. +- Запрос доступа к Keychain больше не возвращается после каждого обновления: + токен читается через `/usr/bin/security`, а не прямым запросом к Keychain, + поэтому разрешение переживает обновление токена со стороны Claude Code. ## [0.1.4] — 2026-08-09 -### Added +### Добавлено -- Release builds are signed with a stable project certificate - (`scripts/signing-cert.sh`). The signature proves nothing about origin — it is - there so the designated requirement stays the same between versions and macOS - keeps the Keychain permission across updates. Without the secrets configured - the build falls back to ad-hoc signing. +- Релизные сборки подписываются постоянным сертификатом проекта + (`scripts/signing-cert.sh`). О происхождении сборки подпись не говорит ничего + — она нужна, чтобы designated requirement не менялся от версии к версии и + macOS сохраняла разрешение на Keychain при обновлении. Без настроенных + секретов сборка откатывается на ad-hoc подпись. -### Fixed +### Исправлено -- Release workflow no longer fails outright when checking for signing secrets: - the `secrets` context is not available in a step condition, and GitHub - rejected the whole file. +- Релизный workflow больше не падает целиком на проверке секретов подписи: + контекст `secrets` в условии шага недоступен, и GitHub отвергал весь файл. ## [0.1.3] — 2026-08-08 -### Added +### Добавлено -- In-app updates: the app checks GitHub releases (at startup and once a day), - offers the new version, downloads the disk image, verifies its checksum and - replaces the running bundle. Installing and relaunching stay manual. -- Launch at login moved from the menu into Settings, where the rest of the - settings live. +- Обновление изнутри программы: она спрашивает релизы GitHub (при запуске и раз + в сутки), предлагает новую версию, скачивает образ, сверяет его контрольную + сумму и заменяет работающий бандл. Установка и перезапуск остаются ручными. +- Автозапуск переехал из меню в настройки, где живёт всё остальное. ## [0.1.2] — 2026-08-07 -### Added +### Добавлено -- Install scripts: `install.sh`, `make-app.sh`, `make-dmg.sh`, `uninstall.sh`, - plus the `probe-usage` and `probe-panel` probes. -- Launch at login through a launchd agent set up by the installer. -- Choice of which limit fills the menu bar ring arc and which one sits as the - number inside it. +- Скрипты установки: `install.sh`, `make-app.sh`, `make-dmg.sh`, + `uninstall.sh`, а с ними пробы `probe-usage` и `probe-panel`. +- Автозапуск через launchd-агент, который ставит установщик. +- Выбор, какой лимит заполняет дугу кольца в строке меню, а какой стоит цифрой + внутри. -### Changed +### Изменено -- Menu bar colour now follows spend thresholds rather than pace, so the icon no - longer turns amber before any meaningful spend. +- Цвет в строке меню считается теперь по порогам расхода, а не по темпу — + значок больше не желтеет раньше сколько-нибудь заметной траты. ## [0.1.1] — 2026-08-07 -### Fixed +### Исправлено -- The panel opens on the first click when the desktop has just been switched. -- The week resets to zero immediately after the reset moment instead of showing - the exhausted limit until the next scheduled poll. -- The percentage column is wide enough not to wrap. +- Панель открывается с первого щелчка после переключения рабочего стола. +- Неделя обнуляется сразу после момента сброса, а не показывает исчерпанный + лимит до следующего опроса. +- Колонка процента достаточно широка, чтобы не переноситься. -### Added +### Добавлено -- A release can be rebuilt by hand from its tag (Actions → Release → Run - workflow), for when a tag push misses Actions entirely. +- Релиз можно пересобрать руками по его тегу (Actions → Release → Run + workflow) — на случай, когда push тега прошёл мимо Actions вовсе. ## [0.1.0] — 2026-08-06 -First release: the weekly Claude Code limit in the menu bar — panel with the -seven days of the week window, the 5-hour session, official numbers from -`/api/oauth/usage` with a local estimate as fallback, settings window, MIT -licence, CI, and a disk image built by tag for Apple Silicon. +Первый релиз: недельный лимит Claude Code в строке меню — панель с семью днями +недельного окна, пятичасовая сессия, официальные цифры из `/api/oauth/usage` с +локальной оценкой как запасным вариантом, окно настроек, лицензия MIT, CI и +образ, собираемый по тегу под Apple Silicon. -[Unreleased]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.9...HEAD +[Не выпущено]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.10...HEAD +[0.1.10]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.9...v0.1.10 [0.1.9]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.8...v0.1.9 [0.1.8]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.7...v0.1.8 [0.1.7]: https://github.com/Greem4/ClaudeWeek/compare/v0.1.6...v0.1.7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3c3e94..030fece 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,18 +39,34 @@ swift run ClaudeWeekTests # все проверки прохо ## Журнал изменений Заметное для человека — в [CHANGELOG.md](CHANGELOG.md), в раздел -`## [Unreleased]`, тем же PR, что и сама правка. Файл по-английски: заметки к -релизу читают и те, кто по-русски не читает. +`## [Не выпущено]`, тем же PR, что и сама правка. Журнал по-русски, как и всё +остальное в проекте: его читают те же люди, что README, — и в программе, и на +странице выпуска. + +Разделы — русские заголовки Keep a Changelog: `Добавлено`, `Изменено`, +`Исправлено`, `Убрано`, а ещё `Заметки` — для оговорок, которые сами по себе не +изменение, но знать о них надо. Строка пишется про поведение, а не про код: +«щелчок по проценту открывает разбивку по моделям», а не «добавил +ModelsWindowController». Внутренняя правка, о которой человеку знать нечего, +сюда не идёт вовсе — для неё есть история git. + +Заголовок «Не выпущено» превращается в номер версии сам: +[`release-on-merge.yml`](.github/workflows/release-on-merge.yml) закрывает +раздел тем же шагом, которым поднимает версию, и заводит сверху новый пустой. +Руками этого делать не нужно, и помнить об этом тоже: пока помнить было надо, +записи 0.1.10 уехали в релиз, оставшись под заголовком «не выпущено». + +Дальше закрытый раздел уходит в заметки к релизу — их собирает +[`release.yml`](.github/workflows/release.yml), и написанное вами видит каждый, +кто откроет страницу выпуска; список коммитов остаётся там же, свёрнутым. +Раздел пуст — заметки соберутся из сообщений коммитов, как раньше. + +Что уедет в релиз, видно заранее: -Разделы те же, что в Keep a Changelog: `Added`, `Changed`, `Fixed`, `Removed`. -Строка пишется про поведение, а не про код: «clicking the percentage opens the -model breakdown», а не «добавил ModelsWindowController». Внутренняя правка, о -которой человеку знать нечего, сюда не идёт вовсе — для неё есть история git. - -Выпустили версию — заголовок `Unreleased` переименовывается в её номер с датой, -а сверху заводится новый пустой `Unreleased`. Workflow этого не делает: он -собирает заметки релиза из сообщений коммитов, а журнал остаётся тем местом, -где то же самое сказано короче и человеческими словами. +```bash +./scripts/changelog.sh section 0.1.11 # текст будущих заметок +./scripts/changelog.sh release 0.1.11 --dry-run # как закроется раздел +``` ## Коммиты @@ -82,8 +98,8 @@ model breakdown», а не «добавил ModelsWindowController». Внутр Версию в `Sources/ClaudeWeekCore/Version.swift` руками поднимать не надо — подняли в самом PR, workflow это увидит и выпустит ровно ту цифру, что в файле, -а не следующую. После слияния сделайте `git pull`: в `main` уехал коммит бота -с новой версией. +а не следующую. После слияния сделайте `git pull`: в `main` уехал коммит бота — +с новой версией и закрытым разделом журнала. Посчитать версию локально — тем же скриптом, которым это делает workflow: @@ -93,7 +109,16 @@ model breakdown», а не «добавил ModelsWindowController». Внутр Если релиз всё-таки нужно выпустить руками (событие потерялось, сборка упала) — это обычный тег: `git tag v0.2.0 && git push origin v0.2.0`, либо Actions → -Release → Run workflow с готовым тегом в поле `ref`. +Release → Run workflow с готовым тегом в поле `ref`. Раздел журнала при таком +выпуске никто не закроет, поэтому закройте его сами — до тега: + +```bash +./scripts/changelog.sh release 0.2.0 +``` + +Забыли — релиз не пострадает: заметки соберутся из раздела «Не выпущено», как +он есть. Но в журнале версия так и останется незакрытой, а это ровно та +забывчивость, из-за которой записи 0.1.10 однажды уехали в релиз безымянными. ## Стиль кода diff --git a/README.md b/README.md index aee60c1..065f67f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![CI](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml/badge.svg)](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml) [![Релиз](https://img.shields.io/github/v/release/Greem4/ClaudeWeek?label=релиз&color=brightgreen)](https://github.com/Greem4/ClaudeWeek/releases/latest) +[![Журнал изменений](https://img.shields.io/badge/журнал-изменений-blue)](CHANGELOG.md) [![macOS 14+](https://img.shields.io/badge/macOS-14%2B-black?logo=apple&logoColor=white)](#установка) [![Swift 6](https://img.shields.io/badge/Swift-6-F05138?logo=swift&logoColor=white)](Package.swift) [![Лицензия MIT](https://img.shields.io/badge/лицензия-MIT-blue)](LICENSE) @@ -72,6 +73,12 @@ SHA256 с суммой из релиза, заменит себя и спрос [docs/USAGE.md](docs/USAGE.md#обновление); то же самое из терминала делает `ClaudeWeek --update`. +Чем одна версия отличается от другой, записано в +[журнале изменений](CHANGELOG.md) — человеческими словами, а не списком +коммитов. Оттуда же собираются заметки к каждому релизу, так что читать его +можно и прямо на странице выпуска. В программе он открывается ссылкой рядом с +номером версии — «О программе». + ### Из исходников Нужны Command Line Tools (`xcode-select --install`); полный Xcode не требуется. @@ -147,6 +154,9 @@ Code сбрасывает каждым обновлением токена, и уведомления — общим тумблером. Подробнее ниже. - **Пять палитр и компактный режим** — вкладка «Панель»; изменения видны сразу, на живой панели. +- **Русский и английский** — язык следует за системным, а на вкладке «Общие» + выбирается явно: «Как в системе», «Русский» или «English». Переключается на + ходу, без перезапуска. Подробности по каждому пункту, все настройки, ключи конфига и флаги командной строки — в [руководстве](docs/USAGE.md). @@ -259,6 +269,7 @@ ARCH=arm64 ./scripts/make-dmg.sh # dist/ClaudeWeek-<версия>-arm64.dmg | Файл | О чём | |---|---| +| [CHANGELOG.md](CHANGELOG.md) | журнал изменений: что нового в каждой версии, начиная с первой | | [docs/USAGE.md](docs/USAGE.md) | руководство: доступ, панель, расчёт плана, все настройки и ключи конфига, командная строка | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | карта кода: кто за что отвечает, потоки данных, инварианты, рецепты правок | | [docs/API.md](docs/API.md) | официальный источник: схема ответа, токен, дисциплина запросов | diff --git a/Resources/Info.plist b/Resources/Info.plist index 5050c61..cbbda89 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -8,6 +8,17 @@ ClaudeWeek CFBundleIdentifier com.greem4.claudeweek + + CFBundleDevelopmentRegion + ru + CFBundleLocalizations + + ru + en + CFBundleIconFile ClaudeWeek CFBundleExecutable diff --git a/Sources/ClaudeWeekApp/CLI.swift b/Sources/ClaudeWeekApp/CLI.swift index 28d1846..d50f3b0 100644 --- a/Sources/ClaudeWeekApp/CLI.swift +++ b/Sources/ClaudeWeekApp/CLI.swift @@ -15,6 +15,7 @@ enum CLI { ClaudeWeek --config=ПУТЬ свой файл конфигурации ClaudeWeek --calibrate=N подогнать локальную оценку под официальные N % (число берётся из /usage внутри Claude Code) + ClaudeWeek --lang=X язык интерфейса: system, ru или en ClaudeWeek --screenshot КАТ отрисовать панель и иконку в PNG (обе темы) ClaudeWeek --icon КАТ сгенерировать .iconset для сборки бандла ClaudeWeek --update поставить свежий выпуск с GitHub, если он вышел @@ -25,7 +26,7 @@ enum CLI { /// Флаги без значения и префиксы флагов со значением. По ним же отличаем /// опечатку от каталога у `--icon` и `--screenshot`: те не начинаются с «-». static let flags = ["--help", "-h", "--verbose", "--json", "--icon", "--screenshot", "--update"] - static let flagPrefixes = ["--config=", "--provider=", "--calibrate="] + static let flagPrefixes = ["--config=", "--provider=", "--calibrate=", "--lang="] static func isKnown(_ argument: String) -> Bool { flags.contains(argument) || flagPrefixes.contains { argument.hasPrefix($0) } @@ -145,7 +146,7 @@ enum CLI { /// `bundle` — что подменяем. У `swift run` бандла нет, поэтому явный путь /// к установленной копии допустим: `--update` из отладочной сборки чинит /// ту, что в ~/Applications. - static func update(bundle: URL?) async -> Int32 { + static func update(bundle: URL?, lang: Lang = .ru) async -> Int32 { guard let bundle else { FileHandle.standardError.write(Data(""" обновлять нечего: запущено не из ClaudeWeek.app. @@ -174,7 +175,7 @@ enum CLI { print("вышла версия \(release.version), у вас \(ClaudeWeek.version)") do { try await UpdateInstaller(bundle: bundle).install(release) { stage in - print(" \(stage.title)") + print(" \(stage.title(lang))") } } catch { let text = (error as? UpdateError)?.errorDescription ?? error.localizedDescription diff --git a/Sources/ClaudeWeekApp/DayBar.swift b/Sources/ClaudeWeekApp/DayBar.swift index 1c3700a..6764976 100644 --- a/Sources/ClaudeWeekApp/DayBar.swift +++ b/Sources/ClaudeWeekApp/DayBar.swift @@ -57,14 +57,17 @@ struct DayRowTap { /// Что случится по клику — словами, для подсказки при наведении и /// VoiceOver: полосы сами о своей нажимаемости не говорят. - var hint: String { - expands ? "нажмите — вся неделя" : "нажмите — только сегодня" + func hint(_ s: L10n) -> String { + expands + ? s.pick("нажмите — вся неделя", "tap for the whole week") + : s.pick("нажмите — только сегодня", "tap for today only") } } /// Строка панели: подпись дня, полоса и числа «факт / план». /// Числа обязательны — цвет нигде не остаётся единственным носителем смысла. struct DayRow: View { + @Environment(\.strings) private var s let day: DayUsage let label: String let fullLabel: String @@ -93,7 +96,7 @@ struct DayRow: View { // Жест на цифрах VoiceOver не видит — строка объявлена одним // элементом. Поэтому разбивка достаётся ему отдельным действием: // мышью в колонку, с клавиатуры — из списка действий строки. - .accessibilityAction(named: "Расход по моделям") { valueTap?() } + .accessibilityAction(named: s.pick("Расход по моделям", "Spend by model")) { valueTap?() } .help(tooltip) // Нажимаемой строка становится целиком, вместе с прозрачными зазорами @@ -104,7 +107,7 @@ struct DayRow: View { .contentShape(Rectangle()) .onTapGesture(perform: tap.action) .accessibilityAddTraits(.isButton) - .accessibilityHint(tap.hint) + .accessibilityHint(tap.hint(s)) } else { row } @@ -154,7 +157,7 @@ struct DayRow: View { column .contentShape(Rectangle()) .onTapGesture(perform: valueTap) - .help(DayRow.valueHint) + .help(DayRow.valueHint(s)) } else { column } @@ -162,7 +165,9 @@ struct DayRow: View { /// Что случится по клику на цифрах — словами. Одно на строку дня и строку /// сессии: место разное, действие одно. - static let valueHint = "нажмите — расход по моделям" + static func valueHint(_ s: L10n) -> String { + s.pick("нажмите — расход по моделям", "tap for spend by model") + } /// Наведение поясняет, какие именно часы стоят за строкой: у крайних /// суток недели подпись дня повторяется, и различает их только время. @@ -171,7 +176,7 @@ struct DayRow: View { private var tooltip: String { let day = interval.map { "\(fullLabel), \($0)" } ?? fullLabel guard let tap else { return day } - return "\(day) · \(tap.hint)" + return "\(day) · \(tap.hint(s))" } private var values: String { @@ -182,13 +187,15 @@ struct DayRow: View { private var voiceOverLabel: String { let plan = Formatting.percent(day.planPercent, withSign: false) // Начертание и цвет VoiceOver не читает: текущие сутки он узнаёт словом. - let today = isToday ? "\(fullLabel), сегодня" : fullLabel + let today = isToday ? s.pick("\(fullLabel), сегодня", "\(fullLabel), today") : fullLabel let name = interval.map { "\(today), \($0)" } ?? today guard let used = day.usedPercent else { - return "\(name), план \(plan) процентов, расхода ещё нет" + return s.pick("\(name), план \(plan) процентов, расхода ещё нет", + "\(name), plan \(plan) per cent, nothing spent yet") } let fact = Formatting.percent(used, withSign: false) - let verdict = isOverspent ? ", перерасход" : "" - return "\(name), потрачено \(fact) процентов из \(plan) плановых\(verdict)" + let verdict = isOverspent ? s.pick(", перерасход", ", overspent") : "" + return s.pick("\(name), потрачено \(fact) процентов из \(plan) плановых\(verdict)", + "\(name), \(fact) per cent spent of \(plan) planned\(verdict)") } } diff --git a/Sources/ClaudeWeekApp/ModelRow.swift b/Sources/ClaudeWeekApp/ModelRow.swift index ab3e4c6..93101dd 100644 --- a/Sources/ClaudeWeekApp/ModelRow.swift +++ b/Sources/ClaudeWeekApp/ModelRow.swift @@ -33,6 +33,7 @@ struct ModelBar: View { /// сетке — подпись, полоса, число справа, — чтобы переключение туда-обратно не /// перекладывало панель заново. struct ModelRow: View { + @Environment(\.strings) private var s let usage: ModelUsage /// Доля этой модели в недельном лимите: та же доля, умноженная на итог /// недели. Стоит в подсказке — в строке для второго числа места нет. @@ -45,7 +46,7 @@ struct ModelRow: View { var body: some View { HStack(spacing: 8) { - Text(usage.title) + Text(usage.title(s.lang)) .font(Theme.dayFont) .foregroundStyle(palette.primaryText.color) .lineLimit(1) @@ -58,7 +59,7 @@ struct ModelRow: View { } .accessibilityElement(children: .ignore) .accessibilityLabel(voiceOverLabel) - .accessibilityAction(named: "Назад к неделе") { valueTap?() } + .accessibilityAction(named: s.pick("Назад к неделе", "Back to the week")) { valueTap?() } .help(tooltip) } @@ -86,19 +87,33 @@ struct ModelRow: View { /// условная стоимость. Вопрос «почему у Haiku миллионы токенов и ничего в /// доле» решается именно этой подсказкой — чтение кеша вдесятеро дешевле. private var tooltip: String { - """ + let input = Formatting.tokens(usage.tokens.input, lang: s.lang) + let output = Formatting.tokens(usage.tokens.output, lang: s.lang) + let cacheWrite = Formatting.tokens(usage.tokens.cacheWrite, lang: s.lang) + let cacheRead = Formatting.tokens(usage.tokens.cacheRead, lang: s.lang) + let cost = Formatting.cost(usage.cost, lang: s.lang) + return s.pick(""" ≈\(Formatting.percent(limitPercent)) недельного лимита - Вход \(Formatting.tokens(usage.tokens.input)) · выход \(Formatting.tokens(usage.tokens.output)) - Кеш: запись \(Formatting.tokens(usage.tokens.cacheWrite)), чтение \(Formatting.tokens(usage.tokens.cacheRead)) - Ответов \(usage.messages) · условная стоимость \(Formatting.cost(usage.cost)) - """ + Вход \(input) · выход \(output) + Кеш: запись \(cacheWrite), чтение \(cacheRead) + Ответов \(usage.messages) · условная стоимость \(cost) + """, """ + ≈\(Formatting.percent(limitPercent)) of the weekly limit + Input \(input) · output \(output) + Cache: write \(cacheWrite), read \(cacheRead) + Replies \(usage.messages) · weighted cost \(cost) + """) } private var voiceOverLabel: String { - """ - \(usage.title), \(Formatting.percent(usage.sharePercent, withSign: false)) процентов \ - расхода недели, примерно \(Formatting.percent(limitPercent, withSign: false)) процентов \ + let share = Formatting.percent(usage.sharePercent, withSign: false) + let limit = Formatting.percent(limitPercent, withSign: false) + return s.pick(""" + \(usage.title(s.lang)), \(share) процентов расхода недели, примерно \(limit) процентов \ недельного лимита, ответов \(usage.messages) - """ + """, """ + \(usage.title(s.lang)), \(share) per cent of the week’s spend, roughly \(limit) per cent \ + of the weekly limit, \(usage.messages) replies + """) } } diff --git a/Sources/ClaudeWeekApp/NotificationController.swift b/Sources/ClaudeWeekApp/NotificationController.swift index b5342db..fade082 100644 --- a/Sources/ClaudeWeekApp/NotificationController.swift +++ b/Sources/ClaudeWeekApp/NotificationController.swift @@ -200,31 +200,40 @@ final class NotificationController { // MARK: Для настроек /// Состояние словами — строка на вкладке «Уведомления». - var summary: String { + func summary(_ s: L10n) -> String { guard bundled else { // Ровно как автозапуск и обновление: у отладочного `swift run` // бандла нет, и молчащие уведомления выглядели бы поломкой. - return """ + return s.pick(""" Доступно только у собранного приложения: отладочный swift run \ macOS не считает программой и уведомления от него не принимает. - """ + """, """ + Only available to a built app: macOS does not consider a debug \ + swift run an application and accepts no notifications from it. + """) } return switch authorization { case .notDetermined: - "macOS спросит разрешение, когда программа соберётся показать первый баннер." + s.pick("macOS спросит разрешение, когда программа соберётся показать первый баннер.", + "macOS will ask for permission when the app is about to show its first banner.") case .denied: - """ + s.pick(""" macOS не пропускает уведомления ClaudeWeek. Разрешить их можно \ только в системных настройках — кнопка ниже. - """ + """, """ + macOS is blocking ClaudeWeek notifications. They can only be allowed \ + in System Settings — the button below. + """) case .authorized: - "macOS пропускает уведомления." + s.pick("macOS пропускает уведомления.", "macOS lets notifications through.") case .provisional: - "macOS пропускает уведомления тихо: без звука и сразу в Центр уведомлений." + s.pick("macOS пропускает уведомления тихо: без звука и сразу в Центр уведомлений.", + "macOS delivers notifications quietly: no sound, straight to Notification Centre.") case .ephemeral: - "Уведомления разрешены на время этого сеанса." + s.pick("Уведомления разрешены на время этого сеанса.", + "Notifications are allowed for this session only.") @unknown default: - "Состояние разрешения macOS неизвестно." + s.pick("Состояние разрешения macOS неизвестно.", "The macOS permission state is unknown.") } } diff --git a/Sources/ClaudeWeekApp/PanelModel.swift b/Sources/ClaudeWeekApp/PanelModel.swift index 4b85c3d..65aec57 100644 --- a/Sources/ClaudeWeekApp/PanelModel.swift +++ b/Sources/ClaudeWeekApp/PanelModel.swift @@ -167,13 +167,17 @@ final class PanelModel { /// свежие перед ним цифры сервера или посчитанные на месте. var sourceHint: String { switch sourceState { - case .synced: "данные online" - case .stale, .local: "данные offline" - case .pending: "данные загружаются" - case .missing: "данные недоступны" + case .synced: strings.pick("данные online", "data online") + case .stale, .local: strings.pick("данные offline", "data offline") + case .pending: strings.pick("данные загружаются", "loading data") + case .missing: strings.pick("данные недоступны", "no data available") } } + /// Строки панели на выбранном языке. Одно место на всю панель: строки, + /// подписи для VoiceOver и подсказки берут язык отсюда, а не каждый свой. + var strings: L10n { config.strings } + var isEstimate: Bool { snapshot?.isEstimate ?? false } /// Меньше двух часов до сброса — футер получает акцент. diff --git a/Sources/ClaudeWeekApp/PopoverView.swift b/Sources/ClaudeWeekApp/PopoverView.swift index 16a25db..6e2e192 100644 --- a/Sources/ClaudeWeekApp/PopoverView.swift +++ b/Sources/ClaudeWeekApp/PopoverView.swift @@ -23,6 +23,10 @@ struct PopoverView: View { private var appearance: AppearanceConfig { model.config.appearance } private var palette: Palette { appearance.theme.palette } + /// Язык панели — тот же, что у настроек: обе половины программы читают + /// его из конфига, и переключение перерисовывает их разом. + private var s: L10n { model.strings } + var body: some View { VStack(alignment: .leading, spacing: Theme.rowSpacing) { header @@ -75,6 +79,7 @@ struct PopoverView: View { // вуаль поверх него. Непрозрачный — сплошная заливка палитры. .background(backdrop) .environment(\.palette, palette) + .environment(\.strings, s) // Текст уходит сам: панель открывают ради полос, и оставлять её без // них до следующего клика нельзя. Повторный клик снимает текст раньше // — смена `showsSourceText` отменяет и эту задачу. @@ -123,7 +128,9 @@ struct PopoverView: View { } // Заголовок называет то, что сейчас в строках: иначе разбивка // читалась бы как недельный ряд со странными подписями. - Text(model.showsModels ? "МОДЕЛИ" : "ЛИМИТ НЕДЕЛИ") + Text(model.showsModels + ? s.pick("МОДЕЛИ", "MODELS") + : s.pick("ЛИМИТ НЕДЕЛИ", "WEEKLY LIMIT")) .font(Theme.titleFont) .tracking(0.4) .fixedSize() @@ -159,7 +166,7 @@ struct PopoverView: View { // приходится туда же, куда щёлкнул. ZStack(alignment: .trailing) { if let window = model.snapshot?.window { - Text(Formatting.resetLabel(window)) + Text(Formatting.resetLabel(window, lang: s.lang)) .fixedSize() .opacity(showsHeaderSourceText ? 0 : 1) } @@ -181,7 +188,7 @@ struct PopoverView: View { // кружок. VoiceOver цвет с заливкой не читает, поэтому источник он // получает подсказкой, и только когда кружок стоит здесь. .accessibilityElement(children: .combine) - .accessibilityHint(showsSessionRow ? "" : model.sourceState.spokenName) + .accessibilityHint(showsSessionRow ? "" : model.sourceState.spokenName(s)) } // MARK: Дни @@ -195,8 +202,8 @@ struct PopoverView: View { ForEach(model.dayRows(snapshot), id: \.index) { day in DayRow( day: day, - label: Formatting.weekdayShort(day.start, calendar: calendar), - fullLabel: Formatting.weekdayFull(day.start, calendar: calendar), + label: Formatting.weekdayShort(day.start, calendar: calendar, lang: s.lang), + fullLabel: Formatting.weekdayFull(day.start, calendar: calendar, lang: s.lang), // День сброса короче суток: интервал поясняет, какая из // его половин сейчас на строке. interval: day.isPartial ? Formatting.interval(day.start, day.end, calendar: calendar) : nil, @@ -216,7 +223,8 @@ struct PopoverView: View { private func models(_ snapshot: UsageSnapshot) -> some View { VStack(spacing: Theme.rowSpacing) { if snapshot.byModel.isEmpty { - Text("разбивки нет: транскриптов за это окно не нашлось") + Text(s.pick("разбивки нет: транскриптов за это окно не нашлось", + "no breakdown: no transcripts found for this window")) .font(Theme.captionFont) .foregroundStyle(palette.secondaryText.color) .fixedSize(horizontal: false, vertical: true) @@ -226,7 +234,7 @@ struct PopoverView: View { .contentShape(Rectangle()) .onTapGesture(perform: toggleModels) .accessibilityAddTraits(.isButton) - .accessibilityHint("нажмите — назад к неделе") + .accessibilityHint(s.pick("нажмите — назад к неделе", "tap to go back to the week")) } else { ForEach(snapshot.byModel, id: \.family) { usage in ModelRow( @@ -240,7 +248,7 @@ struct PopoverView: View { // Откуда эти числа — сразу под ними, а не в футере: футер // говорит про неделю, и оговорка про разбивку, стоящая там, // читалась бы как оговорка про весь лимит. - Text("≈ примерный локальный подсчёт") + Text(s.pick("≈ примерный локальный подсчёт", "≈ rough local estimate")) .font(Theme.captionFont) .foregroundStyle(palette.secondaryText.color) .frame(maxWidth: .infinity, alignment: .leading) @@ -281,7 +289,7 @@ struct PopoverView: View { } } .opacity(0.6) - .accessibilityLabel("данные загружаются") + .accessibilityLabel(s.pick("данные загружаются", "loading data")) } // MARK: Футер @@ -317,7 +325,7 @@ struct PopoverView: View { // размером с меню он читается как ошибка вёрстки. Обе кнопки // футера дублируются пунктами меню — клавиатуре они не нужны. .focusEffectDisabled() - .accessibilityLabel("настройки") + .accessibilityLabel(s.pick("настройки", "settings")) Button(action: onRefresh) { Text(model.isRefreshing ? "…" : "⟳") @@ -326,7 +334,7 @@ struct PopoverView: View { } .buttonStyle(.plain) .focusEffectDisabled() - .accessibilityLabel("обновить") + .accessibilityLabel(s.pick("обновить", "refresh")) } } } @@ -352,7 +360,7 @@ struct PopoverView: View { Spacer(minLength: 8) - Text("в настройках →") + Text(s.pick("в настройках →", "in settings →")) .font(Theme.footerFont) .foregroundStyle(palette.secondaryText.color) } @@ -360,19 +368,22 @@ struct PopoverView: View { } .buttonStyle(.plain) .focusEffectDisabled() - .accessibilityLabel("\(text). Открыть настройки") + .accessibilityLabel(s.pick("\(text). Открыть настройки", "\(text). Open settings")) } private var summary: String { - guard let metrics = model.metrics else { return "нет данных" } + guard let metrics = model.metrics else { return s.pick("нет данных", "no data") } + let left = Formatting.duration(metrics.timeLeft, lang: s.lang) if model.state == .exhausted { - return "лимит недели исчерпан · сброс через \(Formatting.duration(metrics.timeLeft))" + return s.pick("лимит недели исчерпан · сброс через \(left)", + "weekly limit spent · resets in \(left)") } // Темпа «1.0×» здесь больше нет: то же самое видно по расхождению // зелёной и синей полос, а числом это читалось как ещё один лимит. // Когда темп ведёт к беде, о ней говорит строка прогноза ниже. - return "осталось \(Formatting.percent(metrics.remainingPercent))" - + " · сброс через \(Formatting.duration(metrics.timeLeft))" + let remaining = Formatting.percent(metrics.remainingPercent) + return s.pick("осталось \(remaining) · сброс через \(left)", + "\(remaining) left · resets in \(left)") } /// Вторая строка футера появляется только когда при нынешнем темпе лимит @@ -382,8 +393,9 @@ struct PopoverView: View { let exhaustion = model.metrics?.exhaustionDate, let window = model.snapshot?.window else { return nil } - let day = Formatting.weekdayShort(exhaustion, calendar: window.calendar) + let day = Formatting.weekdayShort(exhaustion, calendar: window.calendar, lang: s.lang) let clock = Formatting.clock(exhaustion, calendar: window.calendar) - return "при таком темпе кончится \(day) \(clock)" + return s.pick("при таком темпе кончится \(day) \(clock)", + "at this rate it runs out \(day) \(clock)") } } diff --git a/Sources/ClaudeWeekApp/SessionRow.swift b/Sources/ClaudeWeekApp/SessionRow.swift index 8c45427..e53a1bc 100644 --- a/Sources/ClaudeWeekApp/SessionRow.swift +++ b/Sources/ClaudeWeekApp/SessionRow.swift @@ -64,6 +64,7 @@ struct SessionRow: View { let animated: Bool @Environment(\.palette) private var palette + @Environment(\.strings) private var s private var isWarning: Bool { state != .normal @@ -72,7 +73,7 @@ struct SessionRow: View { var body: some View { VStack(alignment: .leading, spacing: 3) { HStack(spacing: 8) { - Text("5 Ч") + Text(s.pick("5 Ч", "5 H")) .font(Theme.dayFont) .foregroundStyle(palette.secondaryText.color) .frame(width: Theme.dayLabelWidth, alignment: .leading) @@ -115,7 +116,7 @@ struct SessionRow: View { .accessibilityLabel(voiceOverLabel) // Тем же отдельным действием, что и в строках дней: жест на цифрах // VoiceOver не достаётся — строка объявлена одним элементом. - .accessibilityAction(named: "Расход по моделям") { onValueTap?() } + .accessibilityAction(named: s.pick("Расход по моделям", "Spend by model")) { onValueTap?() } } /// Процент сессии — он же кнопка разбивки по моделям. @@ -138,7 +139,7 @@ struct SessionRow: View { column .contentShape(Rectangle()) .onTapGesture(perform: onValueTap) - .help(DayRow.valueHint) + .help(DayRow.valueHint(s)) } else { column } @@ -149,7 +150,8 @@ struct SessionRow: View { at: session.resetsAt, now: now, display: resetDisplay, - calendar: calendar + calendar: calendar, + lang: s.lang ) } @@ -158,16 +160,19 @@ struct SessionRow: View { /// подлежащего не остаётся, а молчать о полосе в потолке нельзя. private var caption: String { session.isExhausted - ? "лимит исчерпан · отпустит \(reset)" - : "сброс \(reset)" + ? s.pick("лимит исчерпан · отпустит \(reset)", "limit spent · frees up \(reset)") + : s.pick("сброс \(reset)", "resets \(reset)") } private var voiceOverLabel: String { let percent = Formatting.percent(session.usedPercent, withSign: false) - let verdict = session.isExhausted ? ", лимит исчерпан" : "" - return """ + let verdict = session.isExhausted ? s.pick(", лимит исчерпан", ", limit spent") : "" + return s.pick(""" Пятичасовая сессия, потрачено \(percent) процентов\(verdict), \ - сброс \(reset), \(source.spokenName) - """ + сброс \(reset), \(source.spokenName(s)) + """, """ + Five-hour session, \(percent) per cent spent\(verdict), \ + resets \(reset), \(source.spokenName(s)) + """) } } diff --git a/Sources/ClaudeWeekApp/SettingsView.swift b/Sources/ClaudeWeekApp/SettingsView.swift index 2bfef42..8bd6766 100644 --- a/Sources/ClaudeWeekApp/SettingsView.swift +++ b/Sources/ClaudeWeekApp/SettingsView.swift @@ -9,25 +9,27 @@ import ClaudeWeekCore struct SettingsView: View { @Bindable var model: SettingsModel + private var s: L10n { model.config.strings } + var body: some View { TabView { GeneralSettings(model: model) - .tabItem { Label("Общие", systemImage: "gearshape") } + .tabItem { Label(s.pick("Общие", "General"), systemImage: "gearshape") } MenuBarSettings(model: model) - .tabItem { Label("Строка меню", systemImage: "menubar.rectangle") } + .tabItem { Label(s.pick("Строка меню", "Menu bar"), systemImage: "menubar.rectangle") } AppearanceSettings(model: model) - .tabItem { Label("Панель", systemImage: "paintpalette") } + .tabItem { Label(s.pick("Панель", "Panel"), systemImage: "paintpalette") } NotificationSettings(model: model) - .tabItem { Label("Уведомления", systemImage: "bell") } + .tabItem { Label(s.pick("Уведомления", "Notifications"), systemImage: "bell") } AccessSettings(model: model) - .tabItem { Label("Доступ", systemImage: "key") } + .tabItem { Label(s.pick("Доступ", "Access"), systemImage: "key") } AboutSettings(model: model) - .tabItem { Label("О программе", systemImage: "info.circle") } + .tabItem { Label(s.pick("О программе", "About"), systemImage: "info.circle") } } .frame(width: 640, height: 580) } @@ -59,73 +61,101 @@ private struct GeneralSettings: View { private var config: Binding { $model.config } + /// Строки на выбранном языке. Читаются из конфига, а не из глобальной + /// переменной: смена языка меняет конфиг, а по нему SwiftUI перерисует + /// вкладку сам — без перезапуска и без ручного оповещения. + private var s: L10n { model.config.strings } + var body: some View { Form { - Section("Запуск") { - Toggle("Запускать при входе в систему", isOn: launchAtLogin) + Section { + Picker(s.languageTitle, selection: config.language) { + ForEach(Language.allCases, id: \.self) { language in + Text(language.title(s.lang)).tag(language) + } + } + Text(s.languageHint) + .font(.caption) + .foregroundStyle(.secondary) + } + + Section(s.pick("Запуск", "Startup")) { + Toggle(s.pick("Запускать при входе в систему", "Launch at login"), isOn: launchAtLogin) .disabled(!LoginItem.isAvailable) Text(launchHint) .font(.caption) .foregroundStyle(.secondary) } - Section("Источник данных") { - Picker("Откуда брать цифры", selection: config.provider) { - Text("Официальный, с падением на локальный").tag(ProviderPreference.auto) - Text("Только официальный").tag(ProviderPreference.official) - Text("Только локальная оценка").tag(ProviderPreference.local) + Section(s.pick("Источник данных", "Data source")) { + Picker(s.pick("Откуда брать цифры", "Where the numbers come from"), selection: config.provider) { + Text(s.pick("Официальный, с падением на локальный", "Official, local as fallback")) + .tag(ProviderPreference.auto) + Text(s.pick("Только официальный", "Official only")).tag(ProviderPreference.official) + Text(s.pick("Только локальная оценка", "Local estimate only")).tag(ProviderPreference.local) } Text(providerHint) .font(.caption) .foregroundStyle(.secondary) - LabeledContent("Обновлять раз в") { + LabeledContent(s.pick("Обновлять раз в", "Refresh every")) { HStack { Slider( value: config.refreshInterval, in: 60...1800, step: 30 ) - Text(Formatting.duration(model.config.refreshInterval)) + Text(Formatting.duration(model.config.refreshInterval, lang: s.lang)) .font(.caption.monospacedDigit()) .frame(width: 70, alignment: .trailing) } } } - Section("Недельное окно") { - Picker("День сброса", selection: config.resetWeekday) { - ForEach(Array(weekdays.enumerated()), id: \.offset) { index, name in + Section(s.pick("Недельное окно", "Week window")) { + Picker(s.pick("День сброса", "Reset day"), selection: config.resetWeekday) { + ForEach(Array(Formatting.weekdayNames(s.lang).enumerated()), id: \.offset) { index, name in Text(name).tag(index + 1) } } HStack { - Stepper("Час сброса: \(model.config.resetHour)", value: config.resetHour, in: 0...23) - Stepper("Минута: \(model.config.resetMinute)", value: config.resetMinute, in: 0...59) + Stepper( + s.pick("Час сброса: \(model.config.resetHour)", "Reset hour: \(model.config.resetHour)"), + value: config.resetHour, + in: 0...23 + ) + Stepper( + s.pick("Минута: \(model.config.resetMinute)", "Minute: \(model.config.resetMinute)"), + value: config.resetMinute, + in: 0...59 + ) } - Picker("Таймзона", selection: config.timeZone) { - Text("Системная").tag("") + Picker(s.pick("Таймзона", "Time zone"), selection: config.timeZone) { + Text(s.pick("Системная", "System")).tag("") ForEach(popularZones, id: \.self) { zone in Text(zone).tag(zone) } } - Text(""" + Text(s.pick(""" При живом официальном источнике момент сброса берётся из ответа \ сервера — эти поля нужны только офлайн. - """) + """, """ + While the official source is alive, the reset moment comes from the \ + server — these fields matter offline only. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Рабочий день") { - Picker("Распорядок", selection: config.workHours) { + Section(s.pick("Рабочий день", "Working day")) { + Picker(s.pick("Распорядок", "Schedule"), selection: config.workHours) { ForEach(WorkHours.presets, id: \.self) { hours in - Text(hours.title).tag(hours) + Text(hours.title(s.lang)).tag(hours) } // Часы, накрученные степперами, тоже должны где-то стоять, // иначе список показывал бы чужое значение как выбранное. if !WorkHours.presets.contains(model.config.workHours) { - Text(model.config.workHours.title).tag(model.config.workHours) + Text(model.config.workHours.title(s.lang)).tag(model.config.workHours) } } @@ -134,17 +164,18 @@ private struct GeneralSettings: View { // самовольный сброс настройки. HStack { Stepper( - "С \(model.config.workHours.start):00", + s.pick("С \(model.config.workHours.start):00", "From \(model.config.workHours.start):00"), value: config.workHours.start, in: 0...(model.config.workHours.end - 1) ) Stepper( - "до \(hourLabel(model.config.workHours.end))", + s.pick("до \(hourLabel(model.config.workHours.end))", + "to \(hourLabel(model.config.workHours.end))"), value: config.workHours.end, in: (model.config.workHours.start + 1)...24 ) } - LabeledContent("Получается", value: workHoursSummary) + LabeledContent(s.pick("Получается", "Adds up to"), value: workHoursSummary) Text(workHoursHint) .font(.caption) .foregroundStyle(.secondary) @@ -168,64 +199,78 @@ private struct GeneralSettings: View { // Запущено не из бандла — из .build, отладочным `swift run`. // Прописывать такой путь в launchd бессмысленно, и молчаливо // погашенная галочка выглядела бы поломкой. - return """ + return s.pick(""" Доступно только у собранного приложения: у отладочного swift run \ исполняемый файл лежит в .build и живёт до следующей сборки. - """ + """, """ + Only available to a built app: with a debug swift run the executable \ + sits in .build and lives until the next build. + """) } - return """ + return s.pick(""" Правит того же launchd-агента, что ставит install.sh, — файл \ ~/Library/LaunchAgents/\(LoginItem.label).plist. Включение и выключение \ начинают действовать со следующего входа в систему: запущенное \ приложение галочка не гасит и второй копии не поднимает. - """ + """, """ + Edits the same launchd agent install.sh sets up — the file \ + ~/Library/LaunchAgents/\(LoginItem.label).plist. Turning it on or off takes \ + effect at the next login: it neither quits the running app nor starts a \ + second copy. + """) } /// Полночь показываем как «0:00 следующих суток», а не как «24:00»: /// в степпере это край шкалы, и человеку нужно понимать, что дальше некуда. private func hourLabel(_ hour: Int) -> String { - hour >= 24 ? "полуночи" : "\(hour):00" + hour >= 24 ? s.pick("полуночи", "midnight") : "\(hour):00" } /// «10:00 – 18:00, 8 ч в сутки» — часы словами, чтобы не считать их /// в уме по двум степперам. private var workHoursSummary: String { let work = model.config.workHours - guard !work.isAllDay else { return "круглые сутки, 24 ч" } - return "\(work.clockRange), \(work.hours) ч в сутки" + guard !work.isAllDay else { return s.pick("круглые сутки, 24 ч", "all day and night, 24 h") } + return s.pick("\(work.clockRange(s.lang)), \(work.hours) ч в сутки", + "\(work.clockRange(s.lang)), \(work.hours) h a day") } private var workHoursHint: String { let hours = model.config.workHours.hours guard !model.config.workHours.isAllDay else { - return """ + return s.pick(""" Круглосуточно: план растёт и ночью, поэтому за сон набегает \ около 40 % недельного лимита. - """ + """, """ + Around the clock: the plan grows at night too, so sleep alone eats \ + about 40 % of the weekly limit. + """) } - return """ + return s.pick(""" Недельный лимит раскладывается по этим часам — \(hours) ч в сутки. \ Ночью план стоит: утром вы начинаете с той же отметки, на которой \ закончили, а работа в три ночи целиком ложится в перерасход. - """ + """, """ + The weekly limit is spread over these hours — \(hours) h a day. At night \ + the plan stands still: you start the morning exactly where you left off, \ + and work at three in the morning counts entirely as overspend. + """) } private var providerHint: String { switch model.config.provider { case .auto: - "Как в /usage; когда сеть или авторизация отваливаются — локальная оценка с пометкой ≈." + s.pick("Как в /usage; когда сеть или авторизация отваливаются — локальная оценка с пометкой ≈.", + "Same as /usage; when the network or the token gives out, a local estimate marked ≈.") case .official: - "Только цифры сервера. Нет сети — панель честно скажет, что данных нет." + s.pick("Только цифры сервера. Нет сети — панель честно скажет, что данных нет.", + "Server numbers only. No network — the panel plainly says it has no data.") case .local: - "Считает по транскриптам ~/.claude/projects. Работает офлайн, точность зависит от калибровки." + s.pick("Считает по транскриптам ~/.claude/projects. Работает офлайн, точность зависит от калибровки.", + "Counted from transcripts in ~/.claude/projects. Works offline; accuracy depends on calibration.") } } - private let weekdays = [ - "Воскресенье", "Понедельник", "Вторник", "Среда", - "Четверг", "Пятница", "Суббота", - ] - private let popularZones = [ "Europe/Saratov", "Europe/Moscow", "Europe/Kaliningrad", "Europe/Samara", "Asia/Yekaterinburg", "Asia/Novosibirsk", "Asia/Vladivostok", @@ -243,13 +288,15 @@ private struct MenuBarSettings: View { private var thresholds: Thresholds { model.config.thresholds } + private var s: L10n { model.config.strings } + var body: some View { Form { - Section("Значок") { - Picker("Показывать", selection: $model.config.menuBarStyle) { - Text("Полоса и процент").tag(MenuBarStyle.percent) - Text("Только полоса").tag(MenuBarStyle.compact) - Text("Кольцо с процентом").tag(MenuBarStyle.ring) + Section(s.pick("Значок", "Icon")) { + Picker(s.pick("Показывать", "Show"), selection: $model.config.menuBarStyle) { + Text(s.pick("Полоса и процент", "Bar and percentage")).tag(MenuBarStyle.percent) + Text(s.pick("Только полоса", "Bar only")).tag(MenuBarStyle.compact) + Text(s.pick("Кольцо с процентом", "Ring with percentage")).tag(MenuBarStyle.ring) } Text(styleHint) .font(.caption) @@ -258,9 +305,9 @@ private struct MenuBarSettings: View { // Расклад кольца спрашиваем только когда оно выбрано: у полосы // второго лимита нет, и пункт стоял бы там без смысла. if model.config.menuBarStyle == .ring { - Picker("Заполнять дугой", selection: $model.config.ringArc) { + Picker(s.pick("Заполнять дугой", "Fill the arc with"), selection: $model.config.ringArc) { ForEach(RingArc.allCases, id: \.self) { arc in - Text(arc.title).tag(arc) + Text(arc.title(s.lang)).tag(arc) } } Text(ringHint) @@ -269,32 +316,42 @@ private struct MenuBarSettings: View { } } - Section("Цвет") { - Toggle("Менять цвет по порогам", isOn: colorize) - Text(""" + Section(s.pick("Цвет", "Colour")) { + Toggle(s.pick("Менять цвет по порогам", "Change colour at thresholds"), isOn: colorize) + Text(s.pick(""" Выключенный — значок всегда нейтрального цвета, а расход \ по-прежнему виден заполнением и цифрой. Панель красится в любом \ случае: пороги ниже задают цвет и её заголовку с полосой сессии. - """) + """, """ + Off — the icon stays neutral, while the spend is still readable \ + from the fill and the number. The panel is coloured either way: \ + the thresholds below also colour its heading and session bar. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Недельный лимит") { - PercentRow(title: "Жёлтый после", value: weekWarn) - PercentRow(title: "Красный после", value: weekCritical) + Section(s.pick("Недельный лимит", "Weekly limit")) { + PercentRow(title: s.pick("Жёлтый после", "Amber after"), value: weekWarn) + PercentRow(title: s.pick("Красный после", "Red after"), value: weekCritical) } - Section("Пятичасовая сессия") { - PercentRow(title: "Жёлтый после", value: sessionWarn) - PercentRow(title: "Красный после", value: sessionCritical) - Text(""" + Section(s.pick("Пятичасовая сессия", "5-hour session")) { + PercentRow(title: s.pick("Жёлтый после", "Amber after"), value: sessionWarn) + PercentRow(title: s.pick("Красный после", "Red after"), value: sessionCritical) + Text(s.pick(""" Считается по факту: сколько потрачено прямо сейчас. План и \ прогноз на цвет больше не влияют — они отвечают на вопрос \ «в графике ли я», а цвет на «пора ли беспокоиться». Сессию \ сообщает только официальный источник: на локальной оценке её \ процент стоит на нуле. - """) + """, """ + Counted from the fact: how much is spent right now. Neither the \ + plan nor the forecast colours anything any more — they answer \ + “am I on schedule”, while colour answers “should I worry yet”. \ + The session comes from the official source only: on a local \ + estimate its percentage stays at zero. + """)) .font(.caption) .foregroundStyle(.secondary) } @@ -304,9 +361,15 @@ private struct MenuBarSettings: View { private var styleHint: String { switch model.config.menuBarStyle { - case .percent: "Полоса недели с планом, под ней недельный процент." - case .compact: "Одна полоса недели, без числа — самый узкий значок." - case .ring: "Два лимита в одном значке: один на дуге, второй цифрой внутри." + case .percent: + s.pick("Полоса недели с планом, под ней недельный процент.", + "The week bar with its plan, the weekly percentage underneath.") + case .compact: + s.pick("Одна полоса недели, без числа — самый узкий значок.", + "One week bar, no number — the narrowest icon there is.") + case .ring: + s.pick("Два лимита в одном значке: один на дуге, второй цифрой внутри.", + "Both limits in one icon: one on the arc, the other as the number inside.") } } @@ -317,15 +380,21 @@ private struct MenuBarSettings: View { private var ringHint: String { switch model.config.ringArc { case .session: - """ + s.pick(""" Дуга — пятичасовая сессия, цифра внутри — недельный процент. \ Цвет у каждого свой, по своим порогам ниже. - """ + """, """ + The arc is the 5-hour session, the number inside is the weekly \ + percentage. Each has its own colour, by its own thresholds below. + """) case .week: - """ + s.pick(""" Дуга — недельный лимит, цифра внутри — процент пятичасовой сессии. \ Цвет у каждого свой, по своим порогам ниже. - """ + """, """ + The arc is the weekly limit, the number inside is the 5-hour session \ + percentage. Each has its own colour, by its own thresholds below. + """) } } @@ -387,12 +456,14 @@ private struct AppearanceSettings: View { private var appearance: Binding { $model.config.appearance } + private var s: L10n { model.config.strings } + var body: some View { Form { - Section("Тема") { - Picker("Палитра", selection: appearance.theme) { + Section(s.pick("Тема", "Theme")) { + Picker(s.pick("Палитра", "Palette"), selection: appearance.theme) { ForEach(ThemeKind.allCases, id: \.self) { theme in - Text(theme.title).tag(theme) + Text(theme.title(s.lang)).tag(theme) } } .pickerStyle(.segmented) @@ -402,9 +473,10 @@ private struct AppearanceSettings: View { .foregroundStyle(.secondary) } - Section("Фон панели") { - Toggle("Прозрачный фон с размытием", isOn: appearance.transparentPanel) - LabeledContent("Плотность фона") { + Section(s.pick("Фон панели", "Panel background")) { + Toggle(s.pick("Прозрачный фон с размытием", "Translucent background with blur"), + isOn: appearance.transparentPanel) + LabeledContent(s.pick("Плотность фона", "Background density")) { HStack { Slider(value: appearance.panelTintOpacity, in: 0...1, step: 0.01) .disabled(!model.config.appearance.transparentPanel) @@ -413,7 +485,7 @@ private struct AppearanceSettings: View { .frame(width: 50, alignment: .trailing) } } - LabeledContent("Скругление углов") { + LabeledContent(s.pick("Скругление углов", "Corner radius")) { HStack { Slider(value: appearance.cornerRadius, in: 0...24, step: 1) Text("\(Int(model.config.appearance.cornerRadius)) pt") @@ -421,21 +493,27 @@ private struct AppearanceSettings: View { .frame(width: 50, alignment: .trailing) } } - Text(""" + Text(s.pick(""" Ноль плотности — чистый материал строки меню, как у системных \ меню; дальше вуаль подкрашивает его, но размытие остаётся \ видно. Нужен глухой фон — выключите прозрачность, это \ отдельный режим. Как это выглядит, видно на самой панели: она \ висит у строки меню, пока открыто это окно. - """) + """, """ + Zero density is the bare menu bar material, the same one system \ + menus use; above it a veil tints the material while the blur \ + stays visible. Want a solid background — turn translucency off, \ + that is a separate mode. The panel itself shows the result: it \ + hangs by the menu bar while this window is open. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Суточные полосы") { - Picker("Показывать", selection: appearance.panelLayout) { + Section(s.pick("Суточные полосы", "Day bars")) { + Picker(s.pick("Показывать", "Show"), selection: appearance.panelLayout) { ForEach(PanelLayout.allCases, id: \.self) { layout in - Text(layout.title).tag(layout) + Text(layout.title(s.lang)).tag(layout) } } .pickerStyle(.segmented) @@ -444,40 +522,50 @@ private struct AppearanceSettings: View { .foregroundStyle(.secondary) } - Section("Строки панели") { - Toggle("Пятичасовая сессия", isOn: appearance.showSession) - Toggle("Прогноз «кончится в …»", isOn: appearance.showForecast) - Text(""" + Section(s.pick("Строки панели", "Panel rows")) { + Toggle(s.pick("Пятичасовая сессия", "5-hour session"), isOn: appearance.showSession) + Toggle(s.pick("Прогноз «кончится в …»", "Forecast “runs out at …”"), isOn: appearance.showForecast) + Text(s.pick(""" Откуда взяты цифры, говорит кружок рядом с полосой сессии: \ залитый зелёный — ответ сервера, залитый жёлтый — он же, но \ из кеша, контурный красный — локальная оценка. Наведите на \ него, и панель скажет, что именно случилось. - """) + """, """ + Where the numbers come from is told by the dot next to the \ + session bar: solid green — a live server reply, solid amber — \ + the same reply from cache, hollow red — a local estimate. Hover \ + it and the panel says what exactly happened. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Сброс сессии") { - Picker("Подписывать", selection: appearance.sessionReset) { + Section(s.pick("Сброс сессии", "Session reset")) { + Picker(s.pick("Подписывать", "Label it as"), selection: appearance.sessionReset) { ForEach(SessionResetDisplay.allCases, id: \.self) { display in - Text(display.title).tag(display) + Text(display.title(s.lang)).tag(display) } } .pickerStyle(.segmented) .disabled(!model.config.appearance.showSession) - LabeledContent("Выглядит так") { + LabeledContent(s.pick("Выглядит так", "Looks like this")) { Text(sessionResetSample) .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) } - Text(""" + Text(s.pick(""" Момент сброса приходит от сервера вместе с процентом — настройка \ выбирает не его, а лишь то, каким концом его показать. Час \ за полночь подписывается днём недели: пятичасовое окно легко \ через неё перешагивает. - """) + """, """ + The reset moment arrives from the server together with the \ + percentage — this setting picks not the moment but which end of \ + it to show. An hour past midnight is labelled with the weekday: \ + a 5-hour window steps over midnight easily. + """)) .font(.caption) .foregroundStyle(.secondary) } @@ -494,31 +582,39 @@ private struct AppearanceSettings: View { at: now.addingTimeInterval(72 * 60), now: now, display: model.config.appearance.sessionReset, - calendar: model.config.calendar + calendar: model.config.calendar, + lang: s.lang ) - return "сброс \(reset)" + return s.pick("сброс \(reset)", "resets \(reset)") } private var layoutHint: String { switch model.config.appearance.panelLayout { case .week: - "Семь полос, по дню недели каждая: весь ряд перед глазами." + s.pick("Семь полос, по дню недели каждая: весь ряд перед глазами.", + "Seven bars, one per weekday: the whole row in front of you.") case .compact: - """ + s.pick(""" Только текущие сутки — панель короче на шесть строк. Неделя не \ потеряна: щёлкните по строке дня, и ряд раскроется целиком, пока \ панель открыта. Итог недели и час сброса остаются на месте в любом \ случае. - """ + """, """ + Today only — six rows shorter. The week is not lost: click the day \ + row and the whole row unfolds for as long as the panel stays open. \ + The week total and the reset time stay put either way. + """) } } private var themeHint: String { switch model.config.appearance.theme { case .system: - "Родная палитра: прогнана через валидатор на дальтонизм и контраст." + s.pick("Родная палитра: прогнана через валидатор на дальтонизм и контраст.", + "The native palette: run through a validator for colour blindness and contrast.") case .midnight, .graphite, .paper, .contrast: - "Экспериментальная палитра: валидатором не проверялась, роли цветов те же." + s.pick("Экспериментальная палитра: валидатором не проверялась, роли цветов те же.", + "An experimental palette: not validated, though the colours keep their roles.") } } } @@ -540,65 +636,88 @@ struct NotificationSettings: View { /// чтобы посмотреть, на чём они стоят. private var isOff: Bool { !notifications.enabled } + private var s: L10n { model.config.strings } + var body: some View { Form { - Section("Уведомления") { - Toggle("Предупреждать о приближении к лимиту", isOn: enabled) - Text(model.notifications.summary) + Section(s.pick("Уведомления", "Notifications")) { + Toggle(s.pick("Предупреждать о приближении к лимиту", "Warn when a limit gets close"), + isOn: enabled) + Text(model.notifications.summary(s)) .font(.caption) .foregroundStyle(.secondary) if model.notifications.needsSystemSettings { - Button("Открыть настройки уведомлений macOS") { + Button(s.pick("Открыть настройки уведомлений macOS", "Open macOS notification settings")) { model.notifications.openSystemSettings() } } - Toggle("Со звуком", isOn: sound) + Toggle(s.pick("Со звуком", "With sound"), isOn: sound) .disabled(isOff) - Text(""" + Text(s.pick(""" В баннере две строки: сколько израсходовано и через сколько \ сброс. Какой это лимит, говорит картинка справа — пятичасовая \ сессия приходит дугой, недельный лимит красным числом. - """) + """, """ + The banner is two lines: how much is spent and how long until \ + the reset. Which limit it is comes from the artwork on the \ + right — the 5-hour session arrives as an arc, the weekly limit \ + as a red number. + """)) .font(.caption) .foregroundStyle(.secondary) - Text(""" + Text(s.pick(""" Об одном пороге программа говорит один раз за окно лимита и \ только на ухудшении: откатившийся расход молчит, а следующая \ неделя и следующая сессия начинают отсчёт заново. Два баннера \ подряд не приходят ближе, чем через пять минут. - """) + """, """ + A threshold is announced once per limit window and only on the \ + way up: a spend that fell back stays quiet, while the next week \ + and the next session start counting afresh. Two banners never \ + come closer than five minutes apart. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Недельный лимит") { - Toggle("Уведомлять о недельном лимите", isOn: weekEnabled) - PercentRow(title: "Предупредить после", value: weekFirst) - PercentRow(title: "И ещё раз после", value: weekSecond) + Section(s.pick("Недельный лимит", "Weekly limit")) { + Toggle(s.pick("Уведомлять о недельном лимите", "Notify about the weekly limit"), + isOn: weekEnabled) + PercentRow(title: s.pick("Предупредить после", "Warn after"), value: weekFirst) + PercentRow(title: s.pick("И ещё раз после", "And again after"), value: weekSecond) previewButton(.week) - Text(""" + Text(s.pick(""" Неделя не сбросится до её конца, поэтому предупреждать о ней \ стоит раньше: после первого порога расход ещё можно растянуть \ на оставшиеся дни. - """) + """, """ + The week will not reset before it ends, so it deserves an \ + earlier warning: after the first threshold the spend can still \ + be stretched over the days that are left. + """)) .font(.caption) .foregroundStyle(.secondary) } .disabled(isOff) - Section("Пятичасовая сессия") { - Toggle("Уведомлять о пятичасовой сессии", isOn: sessionEnabled) - PercentRow(title: "Предупредить после", value: sessionFirst) - PercentRow(title: "И ещё раз после", value: sessionSecond) + Section(s.pick("Пятичасовая сессия", "5-hour session")) { + Toggle(s.pick("Уведомлять о пятичасовой сессии", "Notify about the 5-hour session"), + isOn: sessionEnabled) + PercentRow(title: s.pick("Предупредить после", "Warn after"), value: sessionFirst) + PercentRow(title: s.pick("И ещё раз после", "And again after"), value: sessionSecond) previewButton(.session) - Text(""" + Text(s.pick(""" Сессию сообщает только официальный источник: на локальной \ оценке её процент не считается вовсе, и уведомлений о ней \ не будет. - """) + """, """ + The session comes from the official source only: a local \ + estimate does not compute its percentage at all, so there will \ + be no notifications about it. + """)) .font(.caption) .foregroundStyle(.secondary) } @@ -612,7 +731,7 @@ struct NotificationSettings: View { /// сброса, — и одна кнопка на двоих показывала бы чужой. private func previewButton(_ kind: AlertKind) -> some View { HStack { - Button("Показать пример") { model.previewNotification(kind) } + Button(s.pick("Показать пример", "Show an example")) { model.previewNotification(kind) } .disabled(!model.notifications.canPreview) Spacer() } @@ -695,63 +814,94 @@ struct NotificationSettings: View { private struct AccessSettings: View { @Bindable var model: SettingsModel + private var s: L10n { model.config.strings } + var body: some View { Form { - Section("Токен для официального источника") { - Text(""" + Section(s.pick("Токен для официального источника", "Token for the official source")) { + Text(s.pick(""" Берётся из Keychain Claude Code, запись «Claude Code-credentials». \ Виджет видит ровно тот аккаунт, что показывает /usage: сервер узнаёт \ его по этому токену. Запись только читается — обновляет её сам \ Claude Code, и лезть туда вдвоём значит потерять токен. - """) + """, """ + Taken from the Claude Code Keychain item “Claude Code-credentials”. \ + The widget sees exactly the account /usage shows: the server \ + recognises it by this token. The item is only ever read — Claude \ + Code refreshes it itself, and two writers mean a lost token. + """)) .font(.caption) .foregroundStyle(.secondary) - Text(""" + Text(s.pick(""" Вставить свой токен нельзя, и это не упущение: /api/oauth/usage \ принимает только токен сеанса Claude Code. Ключ API (sk-ant-api…) и \ годовой токен от claude setup-token он отвергает с 401 — проверено. - """) + """, """ + Pasting your own token is not possible, and that is not an \ + oversight: /api/oauth/usage accepts a Claude Code session token \ + only. An API key (sk-ant-api…) and the year-long token from \ + claude setup-token are both rejected with 401 — tested. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Можно и без токена") { - Text(""" + Section(s.pick("Можно и без токена", "It works without a token too")) { + Text(s.pick(""" Доступ к записи Keychain можно не давать вовсе: на вкладке «Общие» \ выберите «Только локальная оценка» — расход посчитается по вашим же \ транскриптам в ~/.claude/projects, без сети и без единого секрета. \ Цена отказа — знак ≈ перед процентом. - """) + """, """ + You can withhold Keychain access entirely: pick “Local estimate \ + only” on the General tab and the spend is counted from your own \ + transcripts in ~/.claude/projects — no network, not a single \ + secret. The price of saying no is a ≈ in front of the percentage. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Если macOS снова спросила доступ") { - Text(""" + Section(s.pick("Если macOS снова спросила доступ", "If macOS asks for access again")) { + Text(s.pick(""" Запись читается утилитой /usr/bin/security — той же, которой \ пишет токен сам Claude Code, и потому разрешение на неё \ восстанавливается при каждом обновлении токена. Прямой запрос \ к Keychain остался запасным путём и диалога не показывает: \ не пустили — панель уходит на локальную оценку. - """) + """, """ + The item is read through /usr/bin/security — the same tool Claude \ + Code writes the token with, which is why the permission is \ + restored every time the token is refreshed. A direct Keychain \ + query remains as a fallback and shows no dialog: denied, the \ + panel falls back to a local estimate. + """)) .font(.caption) .foregroundStyle(.secondary) - Text(""" + Text(s.pick(""" Вопрос всё же вернулся — значит доступа нет и у security. \ Заведите постоянный сертификат подписи: ./scripts/signing-cert.sh \ в каталоге исходников, затем ./scripts/install.sh. Он держит \ приложение в списке доверенных приложений записи, и обновления \ переподписываются им же. - """) + """, """ + The prompt came back anyway — then security has no access either. \ + Set up a stable signing certificate: ./scripts/signing-cert.sh in \ + the source directory, then ./scripts/install.sh. It keeps the app \ + on the item’s trusted list, and updates are re-signed with it. + """)) .font(.caption) .foregroundStyle(.secondary) } - Section("Проверка") { + Section(s.pick("Проверка", "Check")) { HStack { - Button(model.isChecking ? "Проверяю…" : "Проверить сейчас", action: model.checkNow) + Button(model.isChecking + ? s.pick("Проверяю…", "Checking…") + : s.pick("Проверить сейчас", "Check now"), + action: model.checkNow) .disabled(model.isChecking) Spacer() if let result = model.checkResult { @@ -761,10 +911,13 @@ private struct AccessSettings: View { .multilineTextAlignment(.trailing) } } - Text(""" + Text(s.pick(""" Токен никуда не отправляется, кроме api.anthropic.com, не пишется \ в config.json и не попадает в лог. - """) + """, """ + The token goes nowhere but api.anthropic.com, is never written to \ + config.json and never reaches the log. + """)) .font(.caption) .foregroundStyle(.secondary) } @@ -781,22 +934,38 @@ private struct AboutSettings: View { /// после: отменять нечем, прежние значения нигде не сохранены. @State private var confirmingReset = false + private var s: L10n { model.config.strings } + var body: some View { Form { Section { - LabeledContent("Версия", value: ClaudeWeek.version) - LabeledContent("Бюджет недели") { + LabeledContent(s.pick("Версия", "Version")) { + HStack(spacing: 8) { + Text(ClaudeWeek.version) + Text("·").foregroundStyle(.tertiary) + // Ссылка стоит у версии, а не в разделе обновления: + // «что нового» там появляется, только когда есть куда + // обновляться, а прочитать, чем эта версия отличается + // от прошлой, хочется и на самой свежей. Ссылка + // текстом, а не кнопкой: кнопка забирает фокус вкладки + // и открывается с синей рамкой вокруг единственной + // строчки, которую здесь читают. + Text(.init("[\(s.pick("журнал изменений", "changelog"))]" + + "(\(ClaudeWeek.changelogURL.absoluteString))")) + } + } + LabeledContent(s.pick("Бюджет недели", "Week budget")) { Text(model.budgetNote) } - LabeledContent("Ручная калибровка") { + LabeledContent(s.pick("Ручная калибровка", "Manual calibration")) { Text(model.config.calibration.observedPercent.map { - "\(Formatting.percent($0)) официальных" - } ?? "не было — бюджет подбирается сам") + s.pick("\(Formatting.percent($0)) официальных", "\(Formatting.percent($0)) official") + } ?? s.pick("не было — бюджет подбирается сам", "none — the budget tunes itself")) } } - Section("Обновление") { - LabeledContent("Состояние") { + Section(s.pick("Обновление", "Update")) { + LabeledContent(s.pick("Состояние", "Status")) { Text(model.update.summary) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -805,7 +974,7 @@ private struct AboutSettings: View { Button(model.update.actionTitle) { model.update.run() } .disabled(model.update.isWorking || !model.update.isAvailable) if let release = model.update.release { - Button("Что нового") { NSWorkspace.shared.open(release.page) } + Button(s.pick("Что нового", "What’s new")) { NSWorkspace.shared.open(release.page) } } } Text(updateHint) @@ -813,32 +982,44 @@ private struct AboutSettings: View { .foregroundStyle(.secondary) } - Section("Файлы") { - fileRow("Конфигурация", url: ConfigStore.fileURL) - fileRow("Кеш", url: Store.cacheURL) - fileRow("Лог", url: URL(fileURLWithPath: NSHomeDirectory()) + Section(s.pick("Файлы", "Files")) { + fileRow(s.pick("Конфигурация", "Configuration"), url: ConfigStore.fileURL) + fileRow(s.pick("Кеш", "Cache"), url: Store.cacheURL) + fileRow(s.pick("Лог", "Log"), url: URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent("Library/Logs/ClaudeWeek.log")) } Section { - Button("Сбросить настройки", role: .destructive) { confirmingReset = true } - Text(""" + Button(s.pick("Сбросить настройки", "Reset settings"), role: .destructive) { + confirmingReset = true + } + Text(s.pick(""" Вернёт всё к заводским значениям, кроме подобранного бюджета недели \ и калибровки — их программа набрала по живым данным. - """) + """, """ + Puts everything back to factory values except the week budget it \ + worked out and the calibration — those the app earned from live \ + data. + """)) .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .confirmationDialog( - "Сбросить настройки к заводским?", + s.pick("Сбросить настройки к заводским?", "Reset settings to factory values?"), isPresented: $confirmingReset ) { - Button("Сбросить", role: .destructive, action: model.resetToDefaults) - Button("Отмена", role: .cancel) {} + Button(s.pick("Сбросить", "Reset"), role: .destructive, action: model.resetToDefaults) + Button(s.pick("Отмена", "Cancel"), role: .cancel) {} } message: { - Text("Тема, прозрачность, пороги и недельное окно вернутся к значениям по умолчанию. Бюджет недели и калибровка останутся.") + Text(s.pick(""" + Тема, прозрачность, пороги и недельное окно вернутся к значениям по \ + умолчанию. Бюджет недели и калибровка останутся. + """, """ + Theme, translucency, thresholds and the week window go back to their \ + defaults. The week budget and the calibration stay. + """)) } } @@ -846,18 +1027,27 @@ private struct AboutSettings: View { guard model.update.isAvailable else { // Ровно как с автозапуском: у отладочного `swift run` подменять // нечего, и молчаливо погашенная кнопка выглядела бы поломкой. - return """ + return s.pick(""" Доступно только у собранного приложения: у отладочного swift run \ исполняемый файл лежит в .build и живёт до следующей сборки. - """ + """, """ + Only available to a built app: with a debug swift run the executable \ + sits in .build and lives until the next build. + """) } - return """ + return s.pick(""" Программа сама спрашивает GitHub при запуске и раз в сутки, о найденном \ сообщает строкой внизу панели. Скачивание и установка — только по этой \ кнопке: она покажет, что изменилось, сверит образ по SHA256 из релиза, \ заменит приложение и спросит про перезапуск. Настройки, кеш и \ калибровка остаются на месте. - """ + """, """ + The app asks GitHub itself at launch and once a day, and reports what it \ + finds on the bottom line of the panel. Downloading and installing happen \ + only through this button: it shows what changed, verifies the image \ + against the SHA256 from the release, replaces the app and asks about \ + restarting. Settings, cache and calibration stay where they are. + """) } private func fileRow(_ title: String, url: URL) -> some View { @@ -868,7 +1058,7 @@ private struct AboutSettings: View { .lineLimit(1) .truncationMode(.head) .textSelection(.enabled) - Button("Открыть") { NSWorkspace.shared.open(url) } + Button(s.pick("Открыть", "Open")) { NSWorkspace.shared.open(url) } .disabled(!FileManager.default.fileExists(atPath: url.path)) } } diff --git a/Sources/ClaudeWeekApp/SettingsWindow.swift b/Sources/ClaudeWeekApp/SettingsWindow.swift index 159e953..54d7a1b 100644 --- a/Sources/ClaudeWeekApp/SettingsWindow.swift +++ b/Sources/ClaudeWeekApp/SettingsWindow.swift @@ -91,13 +91,16 @@ final class SettingsModel { /// Подпись бюджета: заданный человеком важнее подобранного программой, /// поэтому и показываем, откуда взялось число. var budgetNote: String { + let s = config.strings if config.weeklyBudget > 0 { - return String(format: "%.2f $ ≈ 100 %% · задан вами", config.weeklyBudget) + let cost = Formatting.cost(config.weeklyBudget, lang: s.lang) + return s.pick("\(cost) ≈ 100 % · задан вами", "\(cost) ≈ 100 % · set by you") } if let pickedBudget, pickedBudget > 0 { - return String(format: "%.2f $ ≈ 100 %% · подобран сам", pickedBudget) + let cost = Formatting.cost(pickedBudget, lang: s.lang) + return s.pick("\(cost) ≈ 100 % · подобран сам", "\(cost) ≈ 100 % · worked out by the app") } - return "не подобран" + return s.pick("не подобран", "not worked out yet") } /// «Показать пример» с вкладки уведомлений: тот же баннер, что придёт @@ -163,7 +166,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { let hosting = NSHostingController(rootView: SettingsView(model: model)) let window = NSWindow(contentViewController: hosting) - window.title = "Настройки ClaudeWeek" + window.title = model.config.strings.pick("Настройки ClaudeWeek", "ClaudeWeek Settings") window.styleMask = [.titled, .closable, .miniaturizable] window.isReleasedWhenClosed = false window.delegate = self diff --git a/Sources/ClaudeWeekApp/SourceDot.swift b/Sources/ClaudeWeekApp/SourceDot.swift index 2ccaf10..06a863f 100644 --- a/Sources/ClaudeWeekApp/SourceDot.swift +++ b/Sources/ClaudeWeekApp/SourceDot.swift @@ -79,13 +79,13 @@ extension SourceState { /// Для VoiceOver: кружок сам по себе скрыт от него — цвет и заливку /// озвучить нечем, поэтому строка называет источник словом. - var spokenName: String { + func spokenName(_ s: L10n) -> String { switch self { - case .synced: "данные с сервера" - case .stale: "данные с сервера, устаревшие" - case .local: "локальная оценка" - case .pending: "данные загружаются" - case .missing: "данных нет" + case .synced: s.pick("данные с сервера", "data from the server") + case .stale: s.pick("данные с сервера, устаревшие", "data from the server, stale") + case .local: s.pick("локальная оценка", "local estimate") + case .pending: s.pick("данные загружаются", "loading data") + case .missing: s.pick("данных нет", "no data") } } } diff --git a/Sources/ClaudeWeekApp/StatusItemController.swift b/Sources/ClaudeWeekApp/StatusItemController.swift index 89edf7b..4433549 100644 --- a/Sources/ClaudeWeekApp/StatusItemController.swift +++ b/Sources/ClaudeWeekApp/StatusItemController.swift @@ -7,6 +7,10 @@ final class StatusItemController: NSObject { private let statusItem: NSStatusItem private let dropdown = DropdownPanel() private let model: PanelModel + + /// Строки меню и подсказок. Берутся из той же модели, что и панель, — + /// у AppKit-частей нет окружения SwiftUI, но конфиг у них общий. + private var s: L10n { model.strings } private var provider: any UsageProvider private let update = UpdateController() private let notifications = NotificationController() @@ -43,6 +47,7 @@ final class StatusItemController: NSObject { configurePanel() observeSystemEvents() startTimers() + update.strings = config.strings update.start() // Разрешение на баннеры спрашиваем сразу, а не в момент первого // порога: системный диалог, выскочивший посреди работы через три дня @@ -112,7 +117,8 @@ final class StatusItemController: NSObject { button.image = menuBarImage(palette: model.config.appearance.theme.palette) // Без текстового заголовка кнопку нечего озвучивать — даём подпись сами. - button.setAccessibilityLabel("ClaudeWeek — потрачено \(model.menuBarTitle)") + button.setAccessibilityLabel(s.pick("ClaudeWeek — потрачено \(model.menuBarTitle)", + "ClaudeWeek — \(model.menuBarTitle) spent")) button.toolTip = tooltip } @@ -157,13 +163,19 @@ final class StatusItemController: NSObject { /// Кольцо показывает два лимита сразу, и подсказка обязана назвать оба: /// иначе непонятно, чей процент горит красным. private var tooltip: String { - guard let metrics = model.metrics else { return "ClaudeWeek — данных пока нет" } - var lines = ["Неделя — \(Formatting.percent(metrics.usedPercent)) из лимита"] + guard let metrics = model.metrics else { + return s.pick("ClaudeWeek — данных пока нет", "ClaudeWeek — no data yet") + } + let used = Formatting.percent(metrics.usedPercent) + var lines = [s.pick("Неделя — \(used) из лимита", "Week — \(used) of the limit")] if let session = model.session { - lines.append("Сессия 5 ч — \(Formatting.percent(session.usedPercent)) из лимита") + let spent = Formatting.percent(session.usedPercent) + lines.append(s.pick("Сессия 5 ч — \(spent) из лимита", "5-hour session — \(spent) of the limit")) } - lines.append("План на сейчас — \(Formatting.percent(metrics.planNowPercent))") - lines.append("До сброса недели \(Formatting.duration(metrics.timeLeft))") + let plan = Formatting.percent(metrics.planNowPercent) + lines.append(s.pick("План на сейчас — \(plan)", "Plan for now — \(plan)")) + let left = Formatting.duration(metrics.timeLeft, lang: s.lang) + lines.append(s.pick("До сброса недели \(left)", "Week resets in \(left)")) return lines.joined(separator: "\n") } @@ -210,16 +222,16 @@ final class StatusItemController: NSObject { let menu = NSMenu() - menu.addItem(withTitle: "Обновить", action: #selector(refreshFromMenu), keyEquivalent: "r") + menu.addItem(withTitle: s.pick("Обновить", "Refresh"), action: #selector(refreshFromMenu), keyEquivalent: "r") .target = self menu.addItem(.separator()) - menu.addItem(withTitle: "Настройки…", action: #selector(openConfig), keyEquivalent: ",") + menu.addItem(withTitle: s.pick("Настройки…", "Settings…"), action: #selector(openConfig), keyEquivalent: ",") .target = self menu.addItem(.separator()) - menu.addItem(withTitle: "О программе", action: #selector(showAbout), keyEquivalent: "") + menu.addItem(withTitle: s.pick("О программе", "About"), action: #selector(showAbout), keyEquivalent: "") .target = self - menu.addItem(withTitle: "Выйти", action: #selector(quit), keyEquivalent: "q") + menu.addItem(withTitle: s.pick("Выйти", "Quit"), action: #selector(quit), keyEquivalent: "q") .target = self statusItem.menu = menu @@ -281,6 +293,7 @@ final class StatusItemController: NSObject { let notificationsEnabled = config.notifications.enabled && !model.config.notifications.enabled model.config = config + update.strings = config.strings applyAppearance() render() if notificationsEnabled { notifications.apply(config.notifications) } @@ -330,9 +343,13 @@ final class StatusItemController: NSObject { config.provider = .official do { let snapshot = try await ResolvingProvider(config: config).fetch() - return ("получилось: \(Formatting.percent(snapshot.usedPercent)) недельного лимита", true) + // Метод статический, окружения у него нет — язык берём из того же + // конфига, с которым проверяют доступ. + let spent = Formatting.percent(snapshot.usedPercent) + return (config.strings.pick("получилось: \(spent) недельного лимита", + "worked: \(spent) of the weekly limit"), true) } catch { - let text = (error as? UsageError)?.errorDescription ?? error.localizedDescription + let text = (error as? UsageError)?.message(config.strings.lang) ?? error.localizedDescription return (text, false) } } @@ -476,6 +493,7 @@ final class StatusItemController: NSObject { Log.info("конфиг изменился, применяю") let notificationsEnabled = config.notifications.enabled && !model.config.notifications.enabled model.config = config + update.strings = config.strings if notificationsEnabled { notifications.apply(config.notifications) } settings?.adopt(config) applyAppearance() diff --git a/Sources/ClaudeWeekApp/Theme.swift b/Sources/ClaudeWeekApp/Theme.swift index 02d543b..7c4d922 100644 --- a/Sources/ClaudeWeekApp/Theme.swift +++ b/Sources/ClaudeWeekApp/Theme.swift @@ -299,3 +299,23 @@ extension NSColor { ) } } + +// MARK: - Язык в окружении + +private struct StringsKey: EnvironmentKey { + /// Русский по умолчанию — тот язык, на котором панель говорила до + /// появления второго. Живое дерево всегда получает язык из конфига; + /// значение отсюда достаётся только предпросмотрам. + static let defaultValue = L10n(Lang.ru) +} + +extension EnvironmentValues { + /// Строки интерфейса на всё дерево разом. Панель и окно настроек ставят + /// их один раз, поэтому строки дней, сессии и моделей не таскают язык + /// параметром через полдюжины инициализаторов — а смена языка в конфиге + /// перерисовывает их тем же движением, что и всё остальное. + var strings: L10n { + get { self[StringsKey.self] } + set { self[StringsKey.self] = newValue } + } +} diff --git a/Sources/ClaudeWeekApp/UpdateController.swift b/Sources/ClaudeWeekApp/UpdateController.swift index 1ea4c3f..edc862e 100644 --- a/Sources/ClaudeWeekApp/UpdateController.swift +++ b/Sources/ClaudeWeekApp/UpdateController.swift @@ -9,6 +9,11 @@ import ClaudeWeekCore @MainActor @Observable final class UpdateController { + /// Язык окон и строки в панели. У AppKit-класса нет окружения SwiftUI, + /// поэтому язык ему выдаёт владелец — тем же движением, каким применяет + /// конфиг. + var strings = L10n(Lang.ru) + enum State: Equatable { /// Ни новостей, ни повода что-то показывать. case idle @@ -121,7 +126,7 @@ final class UpdateController { if manually { offer(release) } } } catch { - let text = (error as? UpdateError)?.errorDescription ?? error.localizedDescription + let text = (error as? UpdateError)?.message(strings.lang) ?? error.localizedDescription Log.warn("проверка обновлений не удалась: \(text)") state = manually ? .failed(text, nil) : .idle if manually { report(text) } @@ -148,7 +153,7 @@ final class UpdateController { state = .installed(release) askRelaunch(release) } catch { - let text = (error as? UpdateError)?.errorDescription ?? error.localizedDescription + let text = (error as? UpdateError)?.message(strings.lang) ?? error.localizedDescription Log.warn("не поставил обновление: \(text)") state = .failed(text, release) report(text) @@ -170,7 +175,8 @@ final class UpdateController { try process.run() } catch { Log.warn("не смог перезапуститься: \(error)") - state = .failed("новая версия поставлена, но перезапустить не вышло — запустите вручную", nil) + state = .failed(strings.pick("новая версия поставлена, но перезапустить не вышло — запустите вручную", + "the new version is installed but the restart failed — start it by hand"), nil) return } NSApp.terminate(nil) @@ -183,11 +189,11 @@ final class UpdateController { /// и отправляем за подробностями на страницу релиза. private func offer(_ release: Release) { let alert = NSAlert() - alert.messageText = "Вышла версия \(release.version)" + alert.messageText = strings.pick("Вышла версия \(release.version)", "Version \(release.version) is out") alert.informativeText = Self.digest(of: release) - alert.addButton(withTitle: "Обновить") - alert.addButton(withTitle: "Что нового") - alert.addButton(withTitle: "Отмена") + alert.addButton(withTitle: strings.pick("Обновить", "Update")) + alert.addButton(withTitle: strings.pick("Что нового", "What’s new")) + alert.addButton(withTitle: strings.pick("Отмена", "Cancel")) switch present(alert) { case .alertFirstButtonReturn: @@ -206,30 +212,35 @@ final class UpdateController { /// человек должен сам. private func askRelaunch(_ release: Release) { let alert = NSAlert() - alert.messageText = "ClaudeWeek \(release.version) установлена" - alert.informativeText = """ + alert.messageText = strings.pick("ClaudeWeek \(release.version) установлена", + "ClaudeWeek \(release.version) is installed") + alert.informativeText = strings.pick(""" В строке меню пока работает \(ClaudeWeek.version) — новая версия \ начнётся с перезапуска. Настройки, кеш и калибровка остались на месте. - """ - alert.addButton(withTitle: "Перезапустить") - alert.addButton(withTitle: "Позже") + """, """ + The menu bar still runs \(ClaudeWeek.version) — the new one starts with a \ + restart. Settings, cache and calibration stayed where they were. + """) + alert.addButton(withTitle: strings.pick("Перезапустить", "Restart")) + alert.addButton(withTitle: strings.pick("Позже", "Later")) if present(alert) == .alertFirstButtonReturn { relaunch() } } private func sayUpToDate() { let alert = NSAlert() - alert.messageText = "У вас последняя версия" - alert.informativeText = "ClaudeWeek \(ClaudeWeek.version) — свежее на GitHub ничего нет." - alert.addButton(withTitle: "Хорошо") + alert.messageText = strings.pick("У вас последняя версия", "You are on the latest version") + alert.informativeText = strings.pick("ClaudeWeek \(ClaudeWeek.version) — свежее на GitHub ничего нет.", + "ClaudeWeek \(ClaudeWeek.version) — nothing newer on GitHub.") + alert.addButton(withTitle: strings.pick("Хорошо", "OK")) _ = present(alert) } private func report(_ text: String) { let alert = NSAlert() alert.alertStyle = .warning - alert.messageText = "Обновиться не вышло" + alert.messageText = strings.pick("Обновиться не вышло", "The update did not go through") alert.informativeText = text - alert.addButton(withTitle: "Понятно") + alert.addButton(withTitle: strings.pick("Понятно", "Got it")) _ = present(alert) } @@ -243,22 +254,49 @@ final class UpdateController { /// Начало заметок без разметки: NSAlert растёт вместе с текстом, а полный /// список коммитов в модальном окне никому не нужен. private static func digest(of release: Release) -> String { - var lines: [String] = [] + var items: [String] = [] for raw in release.notes.split(whereSeparator: \.isNewline) { let line = raw.trimmingCharacters(in: .whitespaces) + // Свёрнутый список коммитов и строка-ссылка «все изменения» стоят + // после главного — дальше читать нечего. Ссылка узнаётся по тому, + // что занимает строку целиком: ссылка на `docs/` посреди фразы + // заканчивается словами, а не закрывающей скобкой. + if line.hasPrefix("<") || (line.hasPrefix("[") && line.hasSuffix(")")) { break } // Заголовки секций, ограждения блоков кода и команды карантина — // это про установку руками, которой здесь как раз не будет. guard !line.isEmpty, !line.hasPrefix("#"), !line.hasPrefix("```") else { continue } - lines.append(line) - if lines.count == 6 { break } + + if line.hasPrefix("- ") || line.hasPrefix("* ") { + if items.count == 3 { break } + items.append(String(line.dropFirst(2))) + } else if let last = items.popLast() { + // Заметки приходят из журнала, а там строки перенесены по + // ширине файла: без склейки окно обрывало бы фразу на + // полуслове там, где кончилась строка markdown. + items.append(last + " " + line) + } else { + items.append(line) + } } - let notes = lines.isEmpty ? "" : lines.joined(separator: "\n") + "\n\n" + let notes = items.isEmpty + ? "" + : items.map { "• " + shortened($0) }.joined(separator: "\n") + "\n\n" return """ \(notes)У вас \(ClaudeWeek.version). Образ скачается со страницы релиза, \ сверится по контрольной сумме и заменит работающее приложение. """ } + /// Пункт журнала бывает в абзац длиной — целиком он раздувает модальное + /// окно и топит соседние. Обрезаем по границе слова: подробности всё равно + /// за кнопкой «Что нового». + private static func shortened(_ text: String, limit: Int = 180) -> String { + guard text.count > limit else { return text } + let cut = text.prefix(limit) + let end = cut.lastIndex(of: " ") ?? cut.endIndex + return cut[.. String { + static func checkedAt( + _ date: Date, + now: Date = Date(), + calendar: Calendar = .current, + lang: Lang = .ru + ) -> String { let day = calendar.isDate(date, inSameDayAs: now) ? "" - : "\(Formatting.weekdayShort(date, calendar: calendar)) " - return "\(day)в \(Formatting.clock(date, calendar: calendar))" + : "\(Formatting.weekdayShort(date, calendar: calendar, lang: lang)) " + let clock = Formatting.clock(date, calendar: calendar) + return L10n(lang).pick("\(day)в \(clock)", "\(day)at \(clock)") } var isWorking: Bool { diff --git a/Sources/ClaudeWeekApp/main.swift b/Sources/ClaudeWeekApp/main.swift index f9e2b25..0bb55b3 100644 --- a/Sources/ClaudeWeekApp/main.swift +++ b/Sources/ClaudeWeekApp/main.swift @@ -31,6 +31,16 @@ if let raw = arguments.first(where: { $0.hasPrefix("--provider=") })? config.provider = choice } +// Язык из аргумента перекрывает конфиг и на один запуск, и на снимки: так +// английские картинки для документации снимаются, ничего не переключая руками. +if let raw = arguments.first(where: { $0.hasPrefix("--lang=") })? + .dropFirst("--lang=".count) { + guard let choice = Language(rawValue: String(raw)) else { + exit(CLI.complain("неизвестный язык «\(raw)»: бывают system, ru и en")) + } + config.language = choice +} + if let raw = arguments.first(where: { $0.hasPrefix("--calibrate=") })? .dropFirst("--calibrate=".count) { guard let percent = Double(raw) else { @@ -40,7 +50,7 @@ if let raw = arguments.first(where: { $0.hasPrefix("--calibrate=") })? } if arguments.contains("--update") { - exit(await CLI.update(bundle: UpdateController.runningBundle)) + exit(await CLI.update(bundle: UpdateController.runningBundle, lang: config.language.resolved)) } if arguments.contains("--json") { diff --git a/Sources/ClaudeWeekCore/Alerts.swift b/Sources/ClaudeWeekCore/Alerts.swift index d559381..84c4d73 100644 --- a/Sources/ClaudeWeekCore/Alerts.swift +++ b/Sources/ClaudeWeekCore/Alerts.swift @@ -172,10 +172,14 @@ public struct LimitAlert: Sendable, Equatable { /// «в воскресенье в 2:10» — одно и то же, сказанное дважды. Точный момент /// сброса по-прежнему стоит в панели, где на него смотрят осознанно. /// Порога нет по той же причине: человек его сам и задал. - public func message(now: Date) -> (title: String, body: String) { + public func message(now: Date, lang: Lang = .ru) -> (title: String, body: String) { + let l = L10n(lang) let spent = (isEstimate ? "≈" : "") + Formatting.percent(percent) - let title = isExhausted ? "Лимит исчерпан" : "Израсходовано \(spent)" - let body = "Сброс через \(Formatting.longDuration(resetsAt.timeIntervalSince(now)))" + let title = isExhausted + ? l.pick("Лимит исчерпан", "Limit reached") + : l.pick("Израсходовано \(spent)", "\(spent) used") + let left = Formatting.longDuration(resetsAt.timeIntervalSince(now), lang: lang) + let body = l.pick("Сброс через \(left)", "Resets in \(left)") return (title, body) } } diff --git a/Sources/ClaudeWeekCore/Config.swift b/Sources/ClaudeWeekCore/Config.swift index 9abc924..95eec70 100644 --- a/Sources/ClaudeWeekCore/Config.swift +++ b/Sources/ClaudeWeekCore/Config.swift @@ -26,10 +26,10 @@ public enum RingArc: String, Codable, Sendable, CaseIterable { /// Дуга — неделя, цифра — пятичасовая сессия. case week - public var title: String { + public func title(_ lang: Lang) -> String { switch self { - case .session: "Пятичасовую сессию" - case .week: "Недельный лимит" + case .session: L10n(lang).pick("Пятичасовую сессию", "The 5-hour session") + case .week: L10n(lang).pick("Недельный лимит", "The weekly limit") } } @@ -57,13 +57,14 @@ public enum ThemeKind: String, Codable, Sendable, CaseIterable { /// Максимальный контраст: плотные цвета, никакой полупрозрачности. case contrast - public var title: String { + public func title(_ lang: Lang) -> String { + let l = L10n(lang) switch self { - case .system: "Системная" - case .midnight: "Полночь" - case .graphite: "Графит" - case .paper: "Бумага" - case .contrast: "Контраст" + case .system: return l.pick("Системная", "System") + case .midnight: return l.pick("Полночь", "Midnight") + case .graphite: return l.pick("Графит", "Graphite") + case .paper: return l.pick("Бумага", "Paper") + case .contrast: return l.pick("Контраст", "Contrast") } } } @@ -79,11 +80,12 @@ public enum SessionResetDisplay: String, Codable, Sendable, CaseIterable { /// «сброс через 1 ч 12 мин (14:35)» — и то, и другое. case both - public var title: String { + public func title(_ lang: Lang) -> String { + let l = L10n(lang) switch self { - case .relative: "Сколько осталось" - case .absolute: "Во сколько сбросится" - case .both: "И то, и другое" + case .relative: return l.pick("Сколько осталось", "Time left") + case .absolute: return l.pick("Во сколько сбросится", "Reset time") + case .both: return l.pick("И то, и другое", "Both") } } } @@ -98,10 +100,11 @@ public enum PanelLayout: String, Codable, Sendable, CaseIterable { /// раскрывает её целиком, пока панель открыта. case compact - public var title: String { + public func title(_ lang: Lang) -> String { + let l = L10n(lang) switch self { - case .week: "Вся неделя" - case .compact: "Только сегодня" + case .week: return l.pick("Вся неделя", "The whole week") + case .compact: return l.pick("Только сегодня", "Today only") } } } @@ -287,6 +290,14 @@ public struct Config: Codable, Sendable, Equatable { public var notifications: NotificationsConfig /// Вид панели; на цифры не влияет. public var appearance: AppearanceConfig + /// Язык интерфейса. `system` — как в системе; на расчёты не влияет, + /// календарь ниже остаётся русским в любом случае. + public var language: Language + + /// Строки интерфейса на выбранном языке. Живёт здесь, а не глобальной + /// переменной: панель и настройки перерисовываются по изменению конфига, + /// и язык обязан меняться тем же движением, что и всё остальное. + public var strings: L10n { L10n(language) } public static let minimumRefreshInterval: TimeInterval = 30 @@ -308,7 +319,8 @@ public struct Config: Codable, Sendable, Equatable { calibration: Calibration(), thresholds: Thresholds(), notifications: NotificationsConfig(), - appearance: AppearanceConfig() + appearance: AppearanceConfig(), + language: .system ) public init( @@ -325,7 +337,8 @@ public struct Config: Codable, Sendable, Equatable { calibration: Calibration, thresholds: Thresholds, notifications: NotificationsConfig = NotificationsConfig(), - appearance: AppearanceConfig = AppearanceConfig() + appearance: AppearanceConfig = AppearanceConfig(), + language: Language = .system ) { self.resetWeekday = resetWeekday self.resetHour = resetHour @@ -341,6 +354,7 @@ public struct Config: Codable, Sendable, Equatable { self.thresholds = thresholds self.notifications = notifications self.appearance = appearance + self.language = language } // Каждое поле необязательно: незнакомый или неполный конфиг не должен @@ -363,7 +377,8 @@ public struct Config: Codable, Sendable, Equatable { thresholds: try c.decodeIfPresent(Thresholds.self, forKey: .thresholds) ?? d.thresholds, notifications: try c.decodeIfPresent(NotificationsConfig.self, forKey: .notifications) ?? d.notifications, - appearance: try c.decodeIfPresent(AppearanceConfig.self, forKey: .appearance) ?? d.appearance + appearance: try c.decodeIfPresent(AppearanceConfig.self, forKey: .appearance) ?? d.appearance, + language: try c.decodeIfPresent(Language.self, forKey: .language) ?? d.language ) } diff --git a/Sources/ClaudeWeekCore/Formatting.swift b/Sources/ClaudeWeekCore/Formatting.swift index ad2e99a..855e4a4 100644 --- a/Sources/ClaudeWeekCore/Formatting.swift +++ b/Sources/ClaudeWeekCore/Formatting.swift @@ -2,19 +2,37 @@ import Foundation /// Форматирование для UI. Имена дней заданы таблицей, а не DateFormatter: /// результат не зависит от установленных в системе локалей и проверяется тестами. +/// +/// Язык приходит параметром и по умолчанию русский — тот, на котором программа +/// говорила до появления второго. Панель и настройки передают выбранный явно. public enum Formatting { static let shortWeekdays = ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"] static let fullWeekdays = [ "Воскресенье", "Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", ] + /// Английские сокращения — двухбуквенные, как в русской таблице: колонка + /// дня в панели рассчитана на две буквы, и «Wed» её распирает. + static let shortWeekdaysEN = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"] + static let fullWeekdaysEN = [ + "Sunday", "Monday", "Tuesday", "Wednesday", + "Thursday", "Friday", "Saturday", + ] + + public static func weekdayShort(_ date: Date, calendar: Calendar, lang: Lang = .ru) -> String { + let index = (calendar.component(.weekday, from: date) - 1 + 7) % 7 + return lang == .ru ? shortWeekdays[index] : shortWeekdaysEN[index] + } - public static func weekdayShort(_ date: Date, calendar: Calendar) -> String { - shortWeekdays[(calendar.component(.weekday, from: date) - 1 + 7) % 7] + public static func weekdayFull(_ date: Date, calendar: Calendar, lang: Lang = .ru) -> String { + let index = (calendar.component(.weekday, from: date) - 1 + 7) % 7 + return lang == .ru ? fullWeekdays[index] : fullWeekdaysEN[index] } - public static func weekdayFull(_ date: Date, calendar: Calendar) -> String { - fullWeekdays[(calendar.component(.weekday, from: date) - 1 + 7) % 7] + /// Семь названий подряд, начиная с воскресенья, — нумерация `Calendar`. + /// Нужны списку «День сброса» в настройках, где дни выбираются не по дате. + public static func weekdayNames(_ lang: Lang) -> [String] { + lang == .ru ? fullWeekdays : fullWeekdaysEN } public static func clock(_ date: Date, calendar: Calendar) -> String { @@ -30,57 +48,46 @@ public enum Formatting { } /// «3 дн 6 ч», «1 ч 12 мин», «12 мин». - public static func duration(_ interval: TimeInterval) -> String { + public static func duration(_ interval: TimeInterval, lang: Lang = .ru) -> String { let total = Int(max(interval, 0).rounded()) let days = total / 86_400 let hours = (total % 86_400) / 3_600 let minutes = (total % 3_600) / 60 + let l = L10n(lang) - if days > 0 { return "\(days) дн \(hours) ч" } - if hours > 0 { return "\(hours) ч \(minutes) мин" } - if minutes > 0 { return "\(minutes) мин" } - return "меньше минуты" + if days > 0 { return "\(days) \(l.pick("дн", "d")) \(hours) \(l.pick("ч", "h"))" } + if hours > 0 { return "\(hours) \(l.pick("ч", "h")) \(minutes) \(l.pick("мин", "m"))" } + if minutes > 0 { return "\(minutes) \(l.pick("мин", "m"))" } + return l.pick("меньше минуты", "under a minute") } /// «2 дня 4 часа», «1 час 12 минут», «42 минуты» — та же длительность, что /// у `duration`, но словами. В панели место дорого и «2 дн 4 ч» там /// уместно; в уведомлении строка одна, места хватает, а сокращения /// читаются телеграммой. - public static func longDuration(_ interval: TimeInterval) -> String { + public static func longDuration(_ interval: TimeInterval, lang: Lang = .ru) -> String { let total = Int(max(interval, 0).rounded()) let days = total / 86_400 let hours = (total % 86_400) / 3_600 let minutes = (total % 3_600) / 60 + let l = L10n(lang) // Ровный остаток называем одним словом: «3 часа», а не «3 часа // 0 минут» — ноль в строке читается как опечатка. if days > 0 { - let head = plural(days, "день", "дня", "дней") - return hours > 0 ? "\(head) \(plural(hours, "час", "часа", "часов"))" : head + let head = l.plural(days, "день", "дня", "дней", "day", "days") + return hours > 0 + ? "\(head) \(l.plural(hours, "час", "часа", "часов", "hour", "hours"))" + : head } if hours > 0 { - let head = plural(hours, "час", "часа", "часов") - return minutes > 0 ? "\(head) \(plural(minutes, "минута", "минуты", "минут"))" : head - } - if minutes > 0 { return plural(minutes, "минута", "минуты", "минут") } - return "меньше минуты" - } - - /// Русское склонение при числе: 1 час, 2 часа, 5 часов, 11 часов. - private static func plural(_ count: Int, _ one: String, _ few: String, _ many: String) -> String { - let tail = count % 100 - let last = count % 10 - let word: String - if (11...14).contains(tail) { - word = many - } else if last == 1 { - word = one - } else if (2...4).contains(last) { - word = few - } else { - word = many + let head = l.plural(hours, "час", "часа", "часов", "hour", "hours") + return minutes > 0 + ? "\(head) \(l.plural(minutes, "минута", "минуты", "минут", "minute", "minutes"))" + : head } - return "\(count) \(word)" + if minutes > 0 { return l.plural(minutes, "минута", "минуты", "минут", "minute", "minutes") } + return l.pick("меньше минуты", "under a minute") } /// Проценты без дрожания знаков: всегда целое число. @@ -91,28 +98,41 @@ public enum Formatting { /// «12,4 млн», «812 тыс», «431». Токенов за неделю набегают миллионы, и /// точное их число не значит ничего — читается порядок. - public static func tokens(_ count: Int) -> String { + /// + /// Дробный разделитель идёт за языком: «12,4 млн» по-русски и «12.4M» + /// по-английски — запятая там читается как разделитель тысяч. + public static func tokens(_ count: Int, lang: Lang = .ru) -> String { let value = Double(count) if value >= 1_000_000 { let millions = String(format: "%.1f", value / 1_000_000) - return "\(millions.replacingOccurrences(of: ".", with: ",")) млн" + return lang == .ru + ? "\(millions.replacingOccurrences(of: ".", with: ",")) млн" + : "\(millions)M" + } + if value >= 1_000 { + let thousands = Int((value / 1_000).rounded()) + return lang == .ru ? "\(thousands) тыс" : "\(thousands)K" } - if value >= 1_000 { return "\(Int((value / 1_000).rounded())) тыс" } return "\(count)" } /// Условная стоимость: те же доллары, которыми меряется недельный бюджет. /// Настоящих денег это не значит — подписка списывает своё независимо. - public static func cost(_ value: Double) -> String { - String(format: "%.2f $", value).replacingOccurrences(of: ".", with: ",") + public static func cost(_ value: Double, lang: Lang = .ru) -> String { + let text = String(format: "%.2f", value) + return lang == .ru + ? "\(text.replacingOccurrences(of: ".", with: ",")) $" + : "$\(text)" } /// «сброс ПТ 16:00» — час сброса в зоне окна, той же, по которой панель /// считает сутки. Московского хвоста для сверки здесь больше нет: два часа /// подряд читались как спорящие, а нужен всегда только свой. - public static func resetLabel(_ window: WeekWindow) -> String { + public static func resetLabel(_ window: WeekWindow, lang: Lang = .ru) -> String { let end = window.end - return "сброс \(weekdayShort(end, calendar: window.calendar)) \(clock(end, calendar: window.calendar))" + let day = weekdayShort(end, calendar: window.calendar, lang: lang) + let time = clock(end, calendar: window.calendar) + return L10n(lang).pick("сброс \(day) \(time)", "resets \(day) \(time)") } /// Хвост подписи сессии: «через 1 ч 12 мин», «в 14:35» или «через 1 ч 12 @@ -126,16 +146,19 @@ public enum Formatting { at resetsAt: Date, now: Date, display: SessionResetDisplay, - calendar: Calendar + calendar: Calendar, + lang: Lang = .ru ) -> String { - let left = "через \(duration(max(resetsAt.timeIntervalSince(now), 0)))" + let l = L10n(lang) + let remaining = duration(max(resetsAt.timeIntervalSince(now), 0), lang: lang) + let left = l.pick("через \(remaining)", "in \(remaining)") let sameDay = calendar.isDate(resetsAt, inSameDayAs: now) - let day = sameDay ? "" : "\(weekdayShort(resetsAt, calendar: calendar)) " + let day = sameDay ? "" : "\(weekdayShort(resetsAt, calendar: calendar, lang: lang)) " let moment = "\(day)\(clock(resetsAt, calendar: calendar))" switch display { case .relative: return left - case .absolute: return "в \(moment)" + case .absolute: return l.pick("в \(moment)", "at \(moment)") case .both: return "\(left) (\(moment))" } } diff --git a/Sources/ClaudeWeekCore/Keychain.swift b/Sources/ClaudeWeekCore/Keychain.swift index 4473edb..34d499d 100644 --- a/Sources/ClaudeWeekCore/Keychain.swift +++ b/Sources/ClaudeWeekCore/Keychain.swift @@ -133,7 +133,8 @@ public struct KeychainCredentials: CredentialsSource { // диалог сам. Оба случая не про «вы не авторизованы», и текст у них // отдельный. if status == errSecInteractionNotAllowed || status == errSecUserCanceled { - throw UsageError.unavailable("доступ к записи Keychain не разрешён") + throw UsageError.unavailable(Bilingual("доступ к записи Keychain не разрешён", + "access to the Keychain item is not allowed")) } Log.warn("не нашёл креды: Keychain «\(service)» → \(status), файла \(fileURL.path) тоже нет") throw UsageError.unauthorized diff --git a/Sources/ClaudeWeekCore/LocalProvider.swift b/Sources/ClaudeWeekCore/LocalProvider.swift index 07726df..8d21f14 100644 --- a/Sources/ClaudeWeekCore/LocalProvider.swift +++ b/Sources/ClaudeWeekCore/LocalProvider.swift @@ -39,14 +39,17 @@ public enum ModelFamily { haiku: "Haiku", fable: "Fable", mythos: "Mythos", - unknown: "Неизвестно", ] /// Подпись для окна разбивки. Незнакомая модель зовётся своим полным /// именем: придумывать ей семейство мы не вправе, а спрятать под /// «прочее» — потерять единственную подсказку о том, что считалось. - public static func title(_ family: String) -> String { - titles[family] ?? family + /// + /// Имена моделей не переводятся — переводится только «неизвестно»: это + /// единственное слово здесь, а не название. + public static func title(_ family: String, lang: Lang = .ru) -> String { + if family == unknown { return L10n(lang).pick("Неизвестно", "Unknown") } + return titles[family] ?? family } } diff --git a/Sources/ClaudeWeekCore/Localization.swift b/Sources/ClaudeWeekCore/Localization.swift new file mode 100644 index 0000000..51839d8 --- /dev/null +++ b/Sources/ClaudeWeekCore/Localization.swift @@ -0,0 +1,144 @@ +import Foundation + +/// Язык интерфейса, как он записан в конфиге. +/// +/// `system` — следовать за системой; это значение по умолчанию, и оно же +/// достаётся конфигам, заведённым до того, как язык вообще появился. Явные +/// `ru` и `en` перекрывают систему: у человека может быть английская macOS и +/// желание видеть программу по-русски. +public enum Language: String, Codable, Sendable, CaseIterable { + case system + case ru + case en + + /// Что показывать в списке настроек. Названия самих языков не переводятся — + /// «Русский» ищут глазами по-русски, а не по слову «Russian», и так же + /// устроены системные настройки macOS. Переводится только «системный». + public func title(_ lang: Lang) -> String { + switch self { + case .system: lang == .ru ? "Как в системе" : "System" + case .ru: "Русский" + case .en: "English" + } + } + + /// Язык, на котором в итоге говорит интерфейс. + public var resolved: Lang { + switch self { + case .ru: .ru + case .en: .en + case .system: Language.systemLanguage + } + } + + /// Язык системы, сведённый к двум: всё русское — русское, остальное — + /// английское. Третьей ветки нет намеренно: языков в программе два, и + /// притворяться, что их больше, незачем. + static var systemLanguage: Lang { + let preferred = Locale.preferredLanguages.first ?? "en" + return preferred.lowercased().hasPrefix("ru") ? .ru : .en + } +} + +/// Язык, на котором рисуется интерфейс. Отличается от `Language` тем, что +/// `system` здесь уже разрешён в конкретный язык. +public enum Lang: String, Sendable, CaseIterable { + case ru + case en +} + +/// Строки интерфейса — обе версии рядом, в одном месте. +/// +/// Почему не `.strings` в ресурсах: `scripts/make-app.sh` кладёт в бандл один +/// бинарь и ресурсы SwiftPM туда не переносит — переводы работали бы в +/// `swift run` и молча пропадали в собранном приложении. Разбор обоих путей — +/// в [docs/L10N.md](../../docs/L10N.md). +/// +/// Пара `pick(ru, en)` вместо словаря с ключами: компилятор не даёт объявить +/// строку без перевода, а рядом стоящие версии видно одним взглядом — при +/// правке русского английский не остаётся жить своей жизнью. +public struct L10n: Sendable, Equatable { + public let lang: Lang + + public init(_ lang: Lang) { self.lang = lang } + public init(_ language: Language) { self.lang = language.resolved } + + /// Русская версия слева, английская справа. + /// + /// Пары стоят прямо в местах использования, а не собраны в один словарь с + /// ключами: ключ — это третье имя для той же строки, которое надо + /// придумать, найти при правке и не перепутать. Рядом стоящие версии + /// правятся вместе, и компилятор не даёт написать одну без другой. + public func pick(_ ru: String, _ en: String) -> String { lang == .ru ? ru : en } + + /// Множественное число: русскому нужны три формы, английскому две. + /// Число подставляется само — «2 дня», «2 days». + func plural(_ count: Int, _ ruOne: String, _ ruFew: String, _ ruMany: String, + _ enOne: String, _ enMany: String) -> String { + let word: String + if lang == .ru { + let tail = count % 100 + let last = count % 10 + if (11...14).contains(tail) { + word = ruMany + } else if last == 1 { + word = ruOne + } else if (2...4).contains(last) { + word = ruFew + } else { + word = ruMany + } + } else { + word = count == 1 ? enOne : enMany + } + return "\(count) \(word)" + } +} + +// MARK: - Общие + +extension L10n { + public var languageTitle: String { pick("Язык", "Language") } + + public var languageHint: String { + pick(""" + «Как в системе» — русский на русской macOS и английский на любой другой. \ + Выбор применяется сразу, перезапускать не нужно. + """, """ + “System” means Russian on a Russian macOS and English on any other. \ + The choice applies immediately — no restart needed. + """) + } +} + +/// Строка, у которой есть обе версии сразу. +/// +/// Нужна там, где текст рождается глубоко в ядре — отказ Keychain, сорванная +/// установка, — а показывается наверху, в панели или в окне: язык в точке +/// появления ещё неизвестен, а тащить его туда параметром значит протянуть +/// его через полдюжины слоёв, которым он больше нигде не нужен. +/// +/// Литерал остаётся литералом: строка без перевода читается одинаково на обоих +/// языках. Так подробности вроде системного сообщения или пути к файлу +/// подставляются как есть, не заводя себе второй копии. +public struct Bilingual: Sendable, Equatable, ExpressibleByStringLiteral, + ExpressibleByStringInterpolation, CustomStringConvertible { + let ru: String + let en: String + + public init(_ ru: String, _ en: String) { + self.ru = ru + self.en = en + } + + public init(stringLiteral value: String) { + self.ru = value + self.en = value + } + + public func text(_ lang: Lang) -> String { lang == .ru ? ru : en } + + /// Русский — тот же выбор, что и у `errorDescription`: в лог идёт язык, + /// на котором написан весь остальной лог. + public var description: String { ru } +} diff --git a/Sources/ClaudeWeekCore/OfficialProvider.swift b/Sources/ClaudeWeekCore/OfficialProvider.swift index fde0af6..f3edf39 100644 --- a/Sources/ClaudeWeekCore/OfficialProvider.swift +++ b/Sources/ClaudeWeekCore/OfficialProvider.swift @@ -93,12 +93,14 @@ struct UsageResponse: Decodable { let session = limits?.first { $0.kind == UsageResponse.sessionKind } guard let percent = sevenDay?.utilization ?? weekly?.percent else { - throw UsageError.decoding("в ответе нет недельного процента (seven_day/weekly_all)") + throw UsageError.decoding(Bilingual("в ответе нет недельного процента (seven_day/weekly_all)", + "the reply has no weekly percentage (seven_day/weekly_all)")) } guard let resetsText = sevenDay?.resetsAt ?? weekly?.resetsAt, let resetsAt = ISO8601.parse(resetsText) else { - throw UsageError.decoding("в ответе нет разбираемого resets_at для недели") + throw UsageError.decoding(Bilingual("в ответе нет разбираемого resets_at для недели", + "the reply has no parsable resets_at for the week")) } return OfficialUsage( @@ -274,7 +276,7 @@ public actor OfficialProvider: UsageProvider { ] ) } catch { - throw UsageError.network(error.localizedDescription) + throw UsageError.network(Bilingual(stringLiteral: error.localizedDescription)) } switch code { @@ -289,7 +291,7 @@ public actor OfficialProvider: UsageProvider { case 401, 403: throw UsageError.unauthorized case 429: - throw UsageError.unavailable("слишком частые запросы (429)") + throw UsageError.unavailable(Bilingual("слишком частые запросы (429)", "too many requests (429)")) default: throw UsageError.unavailable("HTTP \(code)") } diff --git a/Sources/ClaudeWeekCore/Snapshot.swift b/Sources/ClaudeWeekCore/Snapshot.swift index 996dbad..66ec82c 100644 --- a/Sources/ClaudeWeekCore/Snapshot.swift +++ b/Sources/ClaudeWeekCore/Snapshot.swift @@ -72,7 +72,7 @@ public struct ModelUsage: Sendable, Equatable { self.sharePercent = sharePercent } - public var title: String { ModelFamily.title(family) } + public func title(_ lang: Lang = .ru) -> String { ModelFamily.title(family, lang: lang) } } /// Пятичасовая сессия — второй лимит, живущий рядом с недельным и ничем с ним diff --git a/Sources/ClaudeWeekCore/UpdateInstaller.swift b/Sources/ClaudeWeekCore/UpdateInstaller.swift index 79f5bae..5086c9e 100644 --- a/Sources/ClaudeWeekCore/UpdateInstaller.swift +++ b/Sources/ClaudeWeekCore/UpdateInstaller.swift @@ -18,11 +18,12 @@ public actor UpdateInstaller { case verifying case installing - public var title: String { + public func title(_ lang: Lang) -> String { + let l = L10n(lang) switch self { - case .downloading: "качаю образ…" - case .verifying: "сверяю контрольную сумму…" - case .installing: "ставлю…" + case .downloading: return l.pick("качаю образ…", "downloading the image…") + case .verifying: return l.pick("сверяю контрольную сумму…", "verifying the checksum…") + case .installing: return l.pick("ставлю…", "installing…") } } } @@ -85,10 +86,10 @@ public actor UpdateInstaller { ] ) } catch { - throw UpdateError.network(error.localizedDescription) + throw UpdateError.network(Bilingual(stringLiteral: error.localizedDescription)) } guard code == 200 else { throw UpdateError.http(code) } - guard !data.isEmpty else { throw UpdateError.install("образ скачался пустым") } + guard !data.isEmpty else { throw UpdateError.install(Bilingual("образ скачался пустым", "the image downloaded empty")) } return data } @@ -124,7 +125,7 @@ public actor UpdateInstaller { ] ) } catch { - throw UpdateError.network(error.localizedDescription) + throw UpdateError.network(Bilingual(stringLiteral: error.localizedDescription)) } } @@ -155,7 +156,7 @@ public actor UpdateInstaller { defer { detach(mount) } guard let source = try appBundle(in: mount) else { - throw UpdateError.install("в образе нет ClaudeWeek.app") + throw UpdateError.install(Bilingual("в образе нет ClaudeWeek.app", "the image has no ClaudeWeek.app")) } let staged = staging.appendingPathComponent(source.lastPathComponent) @@ -209,7 +210,8 @@ public actor UpdateInstaller { let signature = Self.run("/usr/bin/codesign", ["--verify", "--strict", app.path]) guard signature.code == 0 else { - throw UpdateError.install("подпись образа не сошлась: \(signature.output)") + throw UpdateError.install(Bilingual("подпись образа не сошлась: \(signature.output)", + "the image signature did not check out: \(signature.output)")) } let plist = app.appendingPathComponent("Contents/Info.plist") @@ -219,10 +221,11 @@ public actor UpdateInstaller { let raw = info["CFBundleShortVersionString"] as? String, let inside = Version(raw) else { - throw UpdateError.install("в образе не читается версия") + throw UpdateError.install(Bilingual("в образе не читается версия", "the image has no readable version")) } guard inside == version else { - throw UpdateError.install("в образе версия \(inside), а релиз обещал \(version)") + throw UpdateError.install(Bilingual("в образе версия \(inside), а релиз обещал \(version)", + "the image holds \(inside) while the release promised \(version)")) } } diff --git a/Sources/ClaudeWeekCore/Updater.swift b/Sources/ClaudeWeekCore/Updater.swift index 05217fa..353ba39 100644 --- a/Sources/ClaudeWeekCore/Updater.swift +++ b/Sources/ClaudeWeekCore/Updater.swift @@ -96,9 +96,9 @@ public enum UpdateCheck: Sendable, Equatable { } public enum UpdateError: Error, LocalizedError, Equatable { - case network(String) + case network(Bilingual) case http(Int) - case decoding(String) + case decoding(Bilingual) /// В релизе нет образа под эту архитектуру. case noImage(String) case checksumMissing(String) @@ -106,30 +106,45 @@ public enum UpdateError: Error, LocalizedError, Equatable { /// Запущено не из бандла — обновлять нечего. case notBundled case notWritable(String) - case install(String) + case install(Bilingual) - public var errorDescription: String? { + /// Русский текст: он же уходит в лог, который читают при разборе поломки. + public var errorDescription: String? { message(.ru) } + + /// То же самое на языке интерфейса — для панели и окон, где это читает + /// не автор, а тот, у кого обновление не встало. + public func message(_ lang: Lang) -> String { + let l = L10n(lang) switch self { case .network(let text): - "не дозвонился до GitHub: \(text)" + return l.pick("не дозвонился до GitHub: \(text.text(lang))", + "could not reach GitHub: \(text.text(lang))") case .http(let code): - code == 403 || code == 429 - ? "GitHub не пустил (\(code)) — слишком часто спрашивали, попробуйте позже" - : "GitHub ответил \(code)" + return code == 403 || code == 429 + ? l.pick("GitHub не пустил (\(code)) — слишком часто спрашивали, попробуйте позже", + "GitHub turned us away (\(code)) — too many requests, try later") + : l.pick("GitHub ответил \(code)", "GitHub replied \(code)") case .decoding(let text): - "не разобрал ответ GitHub: \(text)" + return l.pick("не разобрал ответ GitHub: \(text.text(lang))", + "could not parse GitHub’s reply: \(text.text(lang))") case .noImage(let arch): - "в релизе нет образа под \(arch) — соберите из исходников: ./scripts/install.sh" + return l.pick("в релизе нет образа под \(arch) — соберите из исходников: ./scripts/install.sh", + "the release has no image for \(arch) — build from source: ./scripts/install.sh") case .checksumMissing(let name): - "в SHA256SUMS.txt нет строки про \(name)" + return l.pick("в SHA256SUMS.txt нет строки про \(name)", + "SHA256SUMS.txt has no line for \(name)") case .checksumMismatch(let expected, let got): - "образ скачался повреждённым: сумма \(got.prefix(12))… вместо \(expected.prefix(12))…" + return l.pick("образ скачался повреждённым: сумма \(got.prefix(12))… вместо \(expected.prefix(12))…", + "the image downloaded corrupted: \(got.prefix(12))… instead of \(expected.prefix(12))…") case .notBundled: - "обновлять нечего: программа запущена не из ClaudeWeek.app" + return l.pick("обновлять нечего: программа запущена не из ClaudeWeek.app", + "nothing to update: the app was not launched from ClaudeWeek.app") case .notWritable(let path): - "нет прав переписать \(path) — перетащите новую версию руками" + return l.pick("нет прав переписать \(path) — перетащите новую версию руками", + "no permission to overwrite \(path) — drag the new version in by hand") case .install(let text): - "не поставил обновление: \(text)" + return l.pick("не поставил обновление: \(text.text(lang))", + "could not install the update: \(text.text(lang))") } } } @@ -183,7 +198,7 @@ public struct Updater: Sendable { do { (code, body) = try await transport.get(url: Updater.latestReleaseURL, headers: Updater.headers) } catch { - throw UpdateError.network(error.localizedDescription) + throw UpdateError.network(Bilingual(stringLiteral: error.localizedDescription)) } guard code == 200 else { throw UpdateError.http(code) } @@ -224,10 +239,11 @@ public struct Updater: Sendable { } guard let version = Version(payload.tagName) else { - throw UpdateError.decoding("тег «\(payload.tagName)» не похож на версию") + throw UpdateError.decoding(Bilingual("тег «\(payload.tagName)» не похож на версию", + "tag “\(payload.tagName)” does not look like a version")) } guard let page = URL(string: payload.htmlUrl) else { - throw UpdateError.decoding("страница релиза без адреса") + throw UpdateError.decoding(Bilingual("страница релиза без адреса", "the release page has no address")) } // Ищем по суффиксу имени, а не собираем его из версии: схема diff --git a/Sources/ClaudeWeekCore/UsageProvider.swift b/Sources/ClaudeWeekCore/UsageProvider.swift index a6b2b0c..1076095 100644 --- a/Sources/ClaudeWeekCore/UsageProvider.swift +++ b/Sources/ClaudeWeekCore/UsageProvider.swift @@ -9,22 +9,31 @@ public enum UsageError: Error, LocalizedError { /// Локальный бюджет не подобран — процент считать не из чего. case notCalibrated case unauthorized - case network(String) - case decoding(String) - case unavailable(String) + case network(Bilingual) + case decoding(Bilingual) + case unavailable(Bilingual) - public var errorDescription: String? { + /// Русский текст: он же уходит в лог, который читают при разборе поломки. + public var errorDescription: String? { message(.ru) } + + /// То же на языке интерфейса — эти строки доходят до панели и до кнопки + /// «Проверить сейчас», а не только до лога. + public func message(_ lang: Lang) -> String { + let l = L10n(lang) switch self { case .notCalibrated: - "локальная оценка не откалибрована: укажите weeklyBudget или calibration в конфиге" + return l.pick("локальная оценка не откалибрована: укажите weeklyBudget или calibration в конфиге", + "the local estimate is not calibrated: set weeklyBudget or calibration in the config") case .unauthorized: - "нужна авторизация в Claude Code" + return l.pick("нужна авторизация в Claude Code", "Claude Code authorisation is required") case .network(let text): - "сеть недоступна: \(text)" + return l.pick("сеть недоступна: \(text.text(lang))", "network unavailable: \(text.text(lang))") case .decoding(let text): - "не разобрал ответ: \(text)" + return l.pick("не разобрал ответ: \(text.text(lang))", + "could not parse the reply: \(text.text(lang))") case .unavailable(let text): - "источник недоступен: \(text)" + return l.pick("источник недоступен: \(text.text(lang))", + "source unavailable: \(text.text(lang))") } } } diff --git a/Sources/ClaudeWeekCore/Version.swift b/Sources/ClaudeWeekCore/Version.swift index d2f9a52..a87aa8e 100644 --- a/Sources/ClaudeWeekCore/Version.swift +++ b/Sources/ClaudeWeekCore/Version.swift @@ -7,4 +7,13 @@ public enum ClaudeWeek { /// репозиторий выпускает образы, поэтому имя одно на проверку версии и на /// ссылку «что нового». public static let repository = "Greem4/ClaudeWeek" + + /// Журнал изменений — тот же `CHANGELOG.md`, но на GitHub, где он свёрстан + /// и открывается в браузере. Ссылка идёт на `main`, а не на тег + /// установленной версии: раздел свежей версии закрывает workflow при + /// выпуске, поэтому в `main` журнал всегда полнее — там видно и то, что + /// вышло уже после этой сборки. + public static var changelogURL: URL { + URL(string: "https://github.com/\(repository)/blob/main/CHANGELOG.md")! + } } diff --git a/Sources/ClaudeWeekCore/WorkHours.swift b/Sources/ClaudeWeekCore/WorkHours.swift index 3452cc1..b10a137 100644 --- a/Sources/ClaudeWeekCore/WorkHours.swift +++ b/Sources/ClaudeWeekCore/WorkHours.swift @@ -31,14 +31,15 @@ public struct WorkHours: Codable, Sendable, Equatable, Hashable { /// Название распорядка: по нему его и выбирают, а часы дописаны рядом, /// чтобы не гадать, что кроется за «вечерним». - public var name: String { - if isAllDay { return "Круглосуточно" } + public func name(_ lang: Lang) -> String { + let l = L10n(lang) + if isAllDay { return l.pick("Круглосуточно", "Around the clock") } switch (start, end) { - case (10, 18): return "Рабочий день" - case (10, 22): return "Длинный день" - case (11, 24): return "Вечерний" - case (9, 22): return "С утра до ночи" - default: return "Свои часы" + case (10, 18): return l.pick("Рабочий день", "Office hours") + case (10, 22): return l.pick("Длинный день", "Long day") + case (11, 24): return l.pick("Вечерний", "Evening") + case (9, 22): return l.pick("С утра до ночи", "Dawn to dusk") + default: return l.pick("Свои часы", "Custom hours") } } @@ -46,14 +47,16 @@ public struct WorkHours: Codable, Sendable, Equatable, Hashable { public var range: String { "\(start)–\(end)" } /// Подпись для списка: «Рабочий день 10–18», «Круглосуточно». - public var title: String { - isAllDay ? name : "\(name) \(range)" + public func title(_ lang: Lang) -> String { + isAllDay ? name(lang) : "\(name(lang)) \(range)" } /// Часы словами: «10:00 – 18:00», «11:00 – полуночи». - public var clockRange: String { - if isAllDay { return "круглые сутки" } - return "\(start):00 – \(end >= 24 ? "полуночи" : "\(end):00")" + public func clockRange(_ lang: Lang) -> String { + let l = L10n(lang) + if isAllDay { return l.pick("круглые сутки", "all day and night") } + let close = end >= 24 ? l.pick("полуночи", "midnight") : "\(end):00" + return "\(start):00 – \(close)" } public init(start: Int = 11, end: Int = 24) { diff --git a/Sources/ClaudeWeekTests/LocalProviderTests.swift b/Sources/ClaudeWeekTests/LocalProviderTests.swift index 093820f..e293142 100644 --- a/Sources/ClaudeWeekTests/LocalProviderTests.swift +++ b/Sources/ClaudeWeekTests/LocalProviderTests.swift @@ -342,7 +342,7 @@ func runLocalProviderTests(_ t: Harness) async { t.equal(usage.byModel.count, 1, "незнакомая модель тоже строка разбивки") t.equal(usage.byModel[0].family, "claude-неизвестно-9", "зовётся своим именем, а не «прочим»") - t.equal(usage.byModel[0].title, "claude-неизвестно-9", + t.equal(usage.byModel[0].title(), "claude-неизвестно-9", "подпись у неё та же — придумывать семейство нечем") } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17c5c78..701ca23 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -410,7 +410,12 @@ Silicon и его контрольную сумму. [`release-on-merge.yml`](../.github/workflows/release-on-merge.yml) срабатывает на слияние PR в `main`, поднимает версию через [`scripts/bump-version.sh`](../scripts/bump-version.sh) (разряд — из меток PR), -коммитит `Version.swift`, тегирует и запускает сборку. Запускает явно, через +закрывает раздел «Не выпущено» в журнале через +[`scripts/changelog.sh`](../scripts/changelog.sh), коммитит `Version.swift` +вместе с `CHANGELOG.md`, тегирует и запускает сборку. Тем же скриптом +`release.yml` достаёт закрытый раздел и кладёт его в заметки к релизу: журнал — +единственное место, где сказано, что изменилось для человека, и повторять это +руками в описании выпуска незачем. Запускает явно, через `gh workflow run`: тег, поставленный из workflow токеном `GITHUB_TOKEN`, других workflow не будит — GitHub так защищается от бесконечных цепочек. `Version.swift` здесь единственный источник версии: из него же `make-app.sh` берёт номер для diff --git a/docs/L10N.md b/docs/L10N.md new file mode 100644 index 0000000..f747c8f --- /dev/null +++ b/docs/L10N.md @@ -0,0 +1,284 @@ +# Локализация: план работ + +Программа говорит по-русски целиком — от вкладок настроек до текста баннера. +Задача: показывать английский тому, у кого система английская, русский — тому, +у кого русская, и дать явный выбор тем, кого системный язык не устраивает. + +Документ отвечает на четыре вопроса: **сколько** там текста на самом деле, **где +будут жить** переводы, **что сломается** по дороге и **в каком порядке** это +делать. Кода он не содержит — это план, а не отчёт. + +## Оглавление + +- [Сколько текста на самом деле](#сколько-текста-на-самом-деле) +- [Что переводим, а что остаётся русским](#что-переводим-а-что-остаётся-русским) +- [Как выбирается язык](#как-выбирается-язык) +- [Где будут жить строки](#где-будут-жить-строки) +- [Множественное число](#множественное-число) +- [Что придётся тронуть кроме строк](#что-придётся-тронуть-кроме-строк) +- [Порядок работ](#порядок-работ) +- [Риски](#риски) +- [Чего этот план не делает](#чего-этот-план-не-делает) + +## Сколько текста на самом деле + +В исходниках 331 строковый литерал с кириллицей. Из них 63 уходят только в +лог-файл и в `stderr` — их не видит никто, кроме того, кто открыл лог. Остаётся +**268 строк, которые видит пользователь**: + +| Область | Строк | Где лежит | +|---|---:|---| +| Настройки, шесть вкладок | 105 | `SettingsView.swift` | +| Обновление: окно, шаги, ошибки | 47 | `UpdateController`, `UpdateInstaller`, `Updater` | +| Панель | 36 | `PopoverView`, `DayBar`, `SessionRow`, `SourceDot`, `PanelModel`, `ModelRow` | +| Названия вариантов в настройках | 20 | `Config` (палитры, что в кольце), `WorkHours` (пресеты дня) | +| Форматирование: дни, длительности, единицы | 18 | `Formatting.swift` | +| Меню строки меню и заголовки окон | 15 | `StatusItemController`, `SettingsWindow` | +| Ошибки, которые видно в панели | 10 | `UsageProvider`, `OfficialProvider`, `Keychain` | +| Уведомления | 7 | `NotificationController`, `Alerts` | +| Командная строка (`--help` и ответы) | 6 | `CLI.swift` | +| Прочее | 4 | `main.swift`, `LocalProvider` | + +Настройки — 39 % работы, и ощущение «настройки да пара строк» верное по +существу: остальные 163 строки размазаны тонким слоем по панели, окну +обновления и баннерам. Тонким слоем — но по одиннадцати файлам, и половина из +них живёт в ядре, которое гоняется тестами. + +Строки короткие: подписи, кнопки, названия. Длинных абзацев — пояснения под +переключателями в настройках — около сорока, и они же самые дорогие: их надо не +перевести, а переписать по-английски так же коротко, иначе поедет вёрстка +(см. [Риски](#риски)). + +## Что переводим, а что остаётся русским + +**Переводим** всё, что человек видит в программе: панель, строку меню и её +меню, шесть вкладок настроек, баннеры уведомлений, окна обновления, ошибки, +которые доходят до панели (нет авторизации, источник недоступен), и вывод +командной строки. + +**Остаётся русским:** + +- **лог-файл и `stderr`** — 63 строки. Их читают при разборе поломки, и читает + их автор. Двуязычный лог означает, что одну и ту же ошибку придётся искать по + двум разным строкам. +- **комментарии в коде, сообщения коммитов, документация проекта** — README, + `docs/`, CONTRIBUTING. Английский README — отдельная задача с отдельной ценой + (одних скриншотов десяток), и в этот план она не входит. +- **имена файлов конфигурации и ключи в них.** `config.json` пишется машиной и + читается машиной; переименование ключей ради языка сломало бы конфиги у всех, + кто уже поставил программу. + +## Как выбирается язык + +Три состояния, одно поле в конфиге: + +| Значение | Что показывает | +|---|---| +| `system` (по умолчанию) | язык системы: русский — если первый в списке предпочитаемых русский, иначе английский | +| `ru` | русский всегда | +| `en` | английский всегда | + +Системный язык берётся из `Locale.preferredLanguages.first` и сводится к одному +из двух: всё, что начинается с `ru`, — русский; остальное — английский. Никакой +третьей ветки: языков в программе два, и притворяться, что их больше, незачем. + +Переключатель встаёт на вкладку **«Общие»**, первым пунктом, — там же, где +выбирается источник данных и недельное окно. Отдельной вкладки язык не +заслуживает: это один `Picker` из трёх значений. + +Смена языка применяется **сразу**, без перезапуска. Панель и настройки — SwiftUI +поверх наблюдаемых моделей, и если строки берутся из модели, а не из глобальной +переменной, перерисовка происходит сама. Меню строки меню и окна `NSAlert` — +AppKit: их придётся пересобрать явно, по изменению конфига. + +## Где будут жить строки + +Два пути. Ниже — оба, с ценой каждого; выбирать надо до первой строчки кода, +потому что переделывать придётся все 268 мест. + +### Путь А: стандартный, `.strings` в ресурсах + +`String(localized:)`, каталоги `ru.lproj` и `en.lproj`, `defaultLocalization` +в `Package.swift`. Так это делают все, и так советует Apple. + +Чего он стоит **в этом проекте**: + +- **правка сборки бандла.** `scripts/make-app.sh` кладёт в `.app` ровно три + вещи: бинарь, `Info.plist` и иконку. Ресурсы SwiftPM (`*.bundle` рядом с + бинарём) туда не попадают, и в собранном приложении переводы просто не + найдутся — при том что `swift run` будет работать. Ошибка такого рода + всплывает не на сборке, а у пользователя. +- **`Info.plist`** — сейчас в нём нет ни `CFBundleDevelopmentRegion`, ни + `CFBundleLocalizations`. Без них macOS считает приложение одноязычным. +- **`.stringsdict`** для множественного числа — отдельный XML-формат, который + руками не проверишь. +- **свой `Bundle` для явного выбора языка.** Системный `String(localized:)` + смотрит на язык системы, а не на наш конфиг; чтобы «English» в настройках + работал у человека с русской системой, строку всё равно приходится тянуть + через `Bundle(path: … "\(lang).lproj")`. + +### Путь Б: обе версии строки рядом, в коде + +`Sources/ClaudeWeekCore/Localization.swift`: язык (`Language`, `Lang`), тип +`L10n` с методом `pick(ru, en)` и правилами склонения. Обращение — +`s.pick("Проверить обновления", "Check for updates")` прямо в том месте, где +строка показывается; `s` живёт в конфиге и приходит во вьюхи через окружение +SwiftUI. + +Изначально план предполагал словарь с ключами (`L.checkUpdates`). От ключей +отказались на первом же десятке строк: ключ — это третье имя для той же +строки, которое надо придумать, найти при правке и не перепутать, а полноту +он проверяет тестом. Пара, стоящая на месте использования, полноту проверяет +компилятором — второй аргумент не пропустишь, — и при правке русского +английский виден в той же строке диффа. + +Чего он стоит: + +- **нестандартно.** Переводчик со стороны ждёт `.strings`, а не Swift-файл. + Для проекта, где переводы пишет автор, это цена нулевая; для проекта с + внешними переводчиками — заметная. +- **строки компилируются в бинарь.** Поменять перевод без пересборки нельзя. + +Что он даёт: + +- сборка не меняется вовсе — ни `make-app.sh`, ни SwiftPM (в `Info.plist` + всё же появился `CFBundleLocalizations`, но не ради своих строк: по нему + macOS локализует то, что рисует сама, — запрос разрешения на уведомления и + стандартные кнопки диалогов); +- работает одинаково в `swift run` и в собранном `.app`; +- **полноту проверяет компилятор.** `pick` требует обе версии; строка без + перевода не собирается. В варианте с `.strings` пропущенный ключ + обнаруживается глазами на экране; +- явный выбор языка в настройках получается сам собой: язык берётся из + конфига, без плясок с `Bundle`. + +### Что выбрать + +**Путь Б.** Проект собирается своим скриптом, гоняется своим тест-раннером и +живёт без единой зависимости — таблица строк с тестом на полноту тут честнее, +чем ресурсный механизм, ради которого придётся чинить сборку бандла и который +всё равно не покрывает главное требование, явный выбор языка. + +В [ROADMAP](ROADMAP.md#11-локализация-только-русская) записан путь А +(«`String(localized:)` и `.strings`. День работы») — эта оценка сделана до +того, как выяснилось, что `make-app.sh` не переносит ресурсы SwiftPM в бандл. +Пункт поправлен ссылкой сюда. + +## Множественное число + +В русском три формы («1 час», «2 часа», «5 часов»), в английском две («1 hour», +«2 hours»). Сейчас в `Formatting.plural` зашиты русские правила. + +Мест, где это нужно: длительности (`duration`, `longDuration` — «2 дня 4 часа», +«меньше минуты»), число ответов в разбивке по моделям, единицы токенов («млн», +«тыс»). Всего около десяти строк, но каждая — с числом внутри. + +Решение: правила склонения выбираются языком — русский считает по остатку, +английский сравнивает с единицей. Обе ветки покрываются тестами на границах +(1, 2, 5, 11, 21, 111), которые для русского уже написаны. + +## Что придётся тронуть кроме строк + +- **`Config`** — новое поле `language` со значением `system` по умолчанию. + Старые конфиги без этого поля должны читаться как `system`, а не падать; + в `ConfigTests` это отдельная проверка. +- **`Formatting`** — таблицы дней недели (`ПН` → `MO`), длительности, единицы + токенов, знак валюты. Функции получат язык параметром. +- **Тесты** — `FormattingTests` сверяют русские строки дословно. Их надо + прогнать по обоим языкам, а не переписать на английский: русский вывод + ломать нельзя. +- **Календарь не трогаем.** `Config.calendar` жёстко задаёт `ru_RU` и + понедельник первым днём — это про расчёт недельного окна, а не про язык + интерфейса. Английская локаль сдвинула бы первый день на воскресенье и увела + расчёты; окно недели задаётся своими полями конфига, и от языка зависеть не + должно. +- **`Info.plist`** — `CFBundleLocalizations` со списком `ru`, `en`. На выбор + строк в пути Б это не влияет, но влияет на язык системных диалогов, которые + рисует не программа: запрос разрешения на уведомления, стандартные кнопки + `NSAlert`. +- **Скриншоты** — `--screenshot` снимает панель и настройки по-русски для + README. Понадобится ключ `--lang en`, чтобы снять то же по-английски, когда + дойдёт дело до английской витрины. +- **Документация** — `docs/USAGE.md` описывает вкладку «Общие»: там появится + переключатель языка. Плюс строка в README о том, что язык следует за + системой. + +## Порядок работ + +Пять шагов, каждый — отдельный pull request, каждый оставляет программу +работающей. Русский текст при этом не меняется ни на одном шаге: это +рефакторинг с добавлением второго языка, а не переписывание первого. + +**1. Каркас.** `Strings.swift` с ключами и двумя таблицами, `Config.language`, +выбор языка на вкладке «Общие», тест на полноту таблиц. Переведено на этом шаге +только то, что нужно самому переключателю. Проверка шага: переключатель +работает, английские надписи появляются там, где уже переведены. + +**2. Ядро: форматирование и названия вариантов.** `Formatting` (дни, +длительности, токены, стоимость), палитры, пресеты рабочего дня, тексты +баннеров. Тесты гоняются на обоих языках. ~45 строк. + +**3. Панель и меню.** `PopoverView`, `DayBar`, `SessionRow`, `SourceDot`, +`ModelRow`, `PanelModel`, меню строки меню. ~50 строк, всё видно глазами на +`--screenshot`. + +**4. Настройки.** 105 строк, самая большая часть, и единственная, где +английский может разъехаться по ширине. Проверяется снимками обеих тем на обоих +языках. + +**5. Обновление, уведомления, командная строка.** Окна `NSAlert`, шаги +установки, ошибки, `--help`. ~60 строк. Здесь же — `CFBundleLocalizations` и +ключ `--lang` у `--screenshot`. + +После пятого шага — вычитка английского целиком, одним проходом по живой +программе, и запись в журнал изменений. + +### Что получилось на деле + +Шаги прошли одной веткой, пятью коммитами; русский текст не изменился ни в +одном месте. Сверх плана появились две вещи: + +- **`--lang=system|ru|en`** — язык на один запуск, не трогая настройку. Тем же + ключом снимаются английские картинки: `--screenshot` рисует то, что выбрано, + и вёрстку обоих языков видно, не переключая ничего руками. +- **`Bilingual`** — строка, у которой обе версии лежат рядом. Нужна там, где + текст рождается глубоко в ядре (сорванная установка, отказ Keychain), а + показывается наверху: язык в точке появления ещё неизвестен, а тащить его + туда параметром значит протянуть через полдюжины слоёв, которым он больше + нигде не нужен. Литерал остаётся литералом — системное сообщение или путь к + файлу подставляются как есть, не заводя второй копии. + +Командная строка (`--help`, `--json`, `--calibrate`) осталась русской: это не +интерфейс программы, а её отладочный вывод, и читает его тот же человек, что +и лог. Исключение — шаги установки у `--update`: они идут за `--lang`, потому +что это те же строки, что показывает окно обновления. + +## Риски + +- **Английский длиннее по вёрстке.** Окно настроек шириной 640 pt, у + переключателей длинные пояснения. Английский текст в среднем чуть короче + русского, но заголовки вкладок («Уведомления» → «Notifications») и подписи + кнопок могут не влезть в те же места. Лечится снимками: `--screenshot` на + обоих языках, обе темы, перед каждым слиянием. +- **268 мест — 268 шансов забыть одно.** Тест на полноту таблиц ловит + отсутствующий перевод, но не ловит строку, которую забыли пропустить через + таблицу вовсе. Помогает грубая проверка: после пятого шага в UI-файлах не + должно остаться литералов с кириллицей — это можно повесить в CI отдельной + командой. +- **Качество английского.** Проект написан живым русским, а не канцелярским, и + дословный перевод получится хуже оригинала. Английский надо писать, а не + переводить; часть длинных пояснений придётся сократить. +- **Уведомления, ушедшие до смены языка.** История отправленных баннеров лежит + в `alerts.json` и языка не касается — здесь риска нет, но проверить стоит: + смена языка не должна выглядеть как новое событие и присылать баннер заново. + +## Чего этот план не делает + +- **Английской документации.** README, `docs/`, CHANGELOG остаются русскими. +- **Третьего языка.** Всё, что не русская система, — английский. +- **Перевода лога.** См. [выше](#что-переводим-а-что-остаётся-русским). +- **Локализации форматов чисел и дат под регион.** Время остаётся 24-часовым, + разделитель дробной части — запятая в русском, точка в английском; больше + ничего от региона не зависит, и `DateFormatter` в проект не приходит: имена + дней заданы таблицей намеренно, чтобы вывод не зависел от установленных в + системе локалей и проверялся тестами. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7fdeb09..4244854 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -180,8 +180,17 @@ XCTest без Xcode недоступен, поэтому проверки нап англоязычны, и это единственное, что мешает показать проект за пределами своего стола. -*Что делать:* `String(localized:)` и `.strings`. День работы, из которого -половина — вычитка. +*Что делать:* план расписан отдельно — [L10N.md](L10N.md). Коротко: язык +следует за системным, с явным выбором «Системный / Русский / English» в +настройках; переводить надо 268 строк, которые видит пользователь, — из 331 +литерала с кириллицей остальные уходят только в лог. + +Прежняя оценка («`String(localized:)` и `.strings`, день работы, из которого +половина — вычитка») не учитывала, что `make-app.sh` кладёт в бандл один бинарь +и ресурсы SwiftPM в `.app` не переносит: с `.strings` пришлось бы чинить ещё и +сборку, причём поломка вылезла бы не на сборке, а у пользователя. Поэтому в +плане строки лежат таблицей в коде и проверяются тестом на полноту. Вычитка +по-прежнему половина работы. ### 12. Сборки не нотаризованы diff --git a/docs/USAGE.md b/docs/USAGE.md index 5449817..bbaceea 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -330,6 +330,7 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Настройка | Что делает | |---|---| +| Язык | «Как в системе» (по умолчанию), «Русский» или «English»; применяется сразу, без перезапуска | | Запускать при входе в систему | автозапуск: заводит или сносит launchd-агента | | Источник данных | `auto` — официальный с падением на локальный, `official` — только сервер, `local` — только оценка по транскриптам | | Обновлять раз в | интервал опроса, 1–30 минут (в сеть — не чаще раза в 60 с) | @@ -337,6 +338,13 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 | Таймзона | в какой зоне считать границы суток | | Рабочий день | распорядок из списка («Рабочий день 10–18», «Длинный день 10–22», «Вечерний 11–24», «С утра до ночи 9–22», «Круглосуточно») или свои часы степперами; по ним раскладывается недельный лимит, ночью план стоит. Строка «Получается» показывает выбранное словами: «11:00 – полуночи, 13 ч в сутки» | +«Как в системе» означает русский на русской macOS и английский на любой +другой: язык берётся из первого в списке предпочитаемых языков системы, и всё, +что не начинается с `ru`, считается английским. Явный выбор перекрывает +систему — английская macOS с русской программой и наоборот. Язык меняет только +интерфейс: недельное окно, порядок дней и расчёты от него не зависят, а +лог-файл остаётся русским в любом случае. + Галочка «Запускать при входе в систему» — единственная в окне, что не пишется в `config.json`: она правит того же launchd-агента, что ставит `install.sh`, — файл `~/Library/LaunchAgents/com.greem4.claudeweek.plist`. Включение и @@ -423,11 +431,15 @@ P12_PASSWORD=… ./scripts/signing-cert.sh --import ~/claudeweek-signing.p12 ### О программе -Версия, бюджет недели (с пометкой, задан он вами или подобран программой), -ручная калибровка, [обновление](#обновление) — состояние и единственная кнопка -«Проверить обновления», — пути к конфигу, кешу и логу с кнопками «Открыть», а -также «Сбросить настройки» — с подтверждением, и сброс не трогает подобранный -бюджет и калибровку: их программа набрала по живым данным. +Версия — и рядом с ней ссылка «журнал изменений»: она открывает +[CHANGELOG.md](../CHANGELOG.md) на GitHub, где записано, чем каждая версия +отличалась от прошлой. Ссылка ведёт на `main`, а не на тег установленной +сборки: так видно и то, что вышло уже после неё. Дальше бюджет недели (с +пометкой, задан он вами или подобран программой), ручная калибровка, +[обновление](#обновление) — состояние и единственная кнопка «Проверить +обновления», — пути к конфигу, кешу и логу с кнопками «Открыть», а также +«Сбросить настройки» — с подтверждением, и сброс не трогает подобранный бюджет +и калибровку: их программа набрала по живым данным. ## Уведомления @@ -656,6 +668,7 @@ ClaudeWeek --calibrate=N подогнать оценку под официал ClaudeWeek --screenshot К отрисовать панель во всех темах и иконку в PNG ClaudeWeek --config=ПУТЬ свой файл конфигурации ClaudeWeek --provider=X источник данных: official, local или auto +ClaudeWeek --lang=X язык интерфейса: system, ru или en ClaudeWeek --icon К сгенерировать .iconset (вызывается из make-app.sh) ClaudeWeek --update поставить свежий выпуск с GitHub, если он вышел ClaudeWeek --verbose подробный лог в stderr diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..d770914 --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# Работа с CHANGELOG.md: закрыть версию и достать её раздел. +# +# ./scripts/changelog.sh release 0.1.11 «Не выпущено» → «[0.1.11] — сегодня» +# ./scripts/changelog.sh release 0.1.11 --dry-run показать, ничего не записывая +# ./scripts/changelog.sh section 0.1.11 напечатать раздел версии +# +# Зачем: раздел «Не выпущено» переименовывали руками, и версия 0.1.10 уехала в +# релиз, оставив свои записи под этим заголовком. Теперь заголовок закрывает +# release-on-merge.yml тем же шагом, что поднимает версию, а release.yml берёт +# отсюда готовый текст для заметок к релизу — журнал перестаёт быть отдельной +# обязанностью, о которой надо помнить. +# +# `release` идемпотентна: раздел с такой версией уже есть — файл не трогается, +# повторный запуск workflow ничего не портит. Пустое «Не выпущено» тоже не +# закрывается: пустой раздел в журнале хуже отсутствующего, а release.yml на +# такой случай соберёт заметки из сообщений коммитов, как и раньше. +# +# `section` печатает тело раздела без заголовка. Не найдя версию, отдаёт +# «Не выпущено»: так заметки собираются даже у релиза, выпущенного руками по +# тегу, до того как версия в журнале закрыта. Нечего отдать — выход 1 и пустой +# stdout, вызывающий решает сам. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CHANGELOG="$ROOT/CHANGELOG.md" +UNRELEASED='Не выпущено' + +COMMAND="${1:-}" +VERSION="${2:-}" + +case "$COMMAND" in + release|section) ;; + *) + echo "нужна команда: release <версия> [--dry-run] | section <версия>" >&2 + exit 1 + ;; +esac + +if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "версия должна быть вида X.Y.Z, сейчас «${VERSION}»" >&2 + exit 1 +fi + +# Тело раздела: всё между его заголовком и следующим `## ` или блоком ссылок +# внизу файла, без пустых строк по краям. +section_body() { + awk -v want="$1" ' + /^## \[/ { + name = $0 + sub(/^## \[/, "", name) + sub(/\].*/, "", name) + inside = (name == want) + next + } + /^\[[^]]+\]: / { inside = 0 } + inside { line[++n] = $0; if (NF) last = n } + END { + first = 1 + while (first <= last && line[first] == "") first++ + for (i = first; i <= last; i++) print line[i] + } + ' "$CHANGELOG" +} + +if [ "$COMMAND" = "section" ]; then + BODY="$(section_body "$VERSION")" + if [ -z "$BODY" ]; then + echo "раздела [$VERSION] в журнале нет — беру «${UNRELEASED}»" >&2 + BODY="$(section_body "$UNRELEASED")" + fi + if [ -z "$BODY" ]; then + echo "в журнале нечего взять: ни [$VERSION], ни непустого «${UNRELEASED}»" >&2 + exit 1 + fi + printf '%s\n' "$BODY" + exit 0 +fi + +DRY_RUN=0 +[ "${3:-}" = "--dry-run" ] && DRY_RUN=1 +DATE="$(date +%F)" + +if grep -q "^## \[$VERSION\]" "$CHANGELOG"; then + echo "раздел [$VERSION] в журнале уже закрыт — не трогаю" >&2 + exit 0 +fi + +if ! grep -q "^## \[$UNRELEASED\]" "$CHANGELOG"; then + echo "::warning::в журнале нет раздела «${UNRELEASED}» — закрывать нечего" >&2 + exit 0 +fi + +if [ -z "$(section_body "$UNRELEASED")" ]; then + echo "::warning::раздел «${UNRELEASED}» пуст — версия $VERSION выйдет без записи в журнале" >&2 + exit 0 +fi + +# Прошлая версия нужна для ссылки сравнения; берём её из той же ссылки внизу +# файла (`compare/v0.1.10...HEAD`), а не из тегов — журнал должен закрываться и +# там, где истории git под рукой нет. +PREV="$(sed -n "s|^\[$UNRELEASED\]: .*/compare/v\([0-9.]*\)\.\.\.HEAD *$|\1|p" "$CHANGELOG" | tail -1)" + +TMP="$(mktemp)" +awk -v version="$VERSION" -v date="$DATE" -v prev="$PREV" -v unreleased="$UNRELEASED" ' + # Заголовок раздела: над ним заводим новый пустой «Не выпущено». + $0 == "## [" unreleased "]" { + print "## [" unreleased "]" + print "" + print "## [" version "] — " date + next + } + # Ссылка внизу: «Не выпущено» теперь считается от свежего тега, а под ней + # встаёт строка самой версии. + $0 ~ "^\\[" unreleased "\\]: " { + line = $0 + if (prev != "") { + sub("v" prev "\\.\\.\\.HEAD", "v" version "...HEAD", line) + print line + base = $0 + sub("^\\[" unreleased "\\]: ", "", base) + sub("/compare/.*", "", base) + print "[" version "]: " base "/compare/v" prev "...v" version + } else { + print line + } + next + } + { print } +' "$CHANGELOG" > "$TMP" + +if [ "$DRY_RUN" -eq 1 ]; then + diff -u "$CHANGELOG" "$TMP" || true + rm -f "$TMP" + echo "--dry-run: файл не изменён" >&2 + exit 0 +fi + +mv "$TMP" "$CHANGELOG" +echo "журнал: «${UNRELEASED}» → [$VERSION] — $DATE" >&2