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 @@
[](https://github.com/Greem4/ClaudeWeek/actions/workflows/ci.yml)
[](https://github.com/Greem4/ClaudeWeek/releases/latest)
+[](CHANGELOG.md)
[](#установка)
[](Package.swift)
[](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