From 00db8ea7d6e076820b954a0d1281371fb78a6fd6 Mon Sep 17 00:00:00 2001
From: q <301774981+audit0@users.noreply.github.com>
Date: Sun, 4 Oct 2026 20:02:13 +0700
Subject: [PATCH 1/2] =?UTF-8?q?Mac:=20=D0=B0=D0=BD=D0=B3=D0=BB=D0=B8=D0=B9?=
=?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84?=
=?UTF-8?q?=D0=B5=D0=B9=D1=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Интерфейс Mac теперь на русском и английском: язык выбирает macOS — русским русский, всем остальным английский.
- Русский текст в коде остаётся ключом перевода: tr("…") для строк, которые собираются в коде (модели, ошибки ядра,
подсказки), Text("…") как прежде; trDynamic(…) для rawValue стадий переноса. Английский — в
Localization/en.lproj/Localizable.strings (1080 строк), build-app.sh кладёт en.lproj и ru.lproj в OffLoadAI.app,
язык разработки в Info.plist — английский, запросы доступа macOS переведены в InfoPlist.strings.
- Единицы (ГБ → GB), относительное время и даты — на языке интерфейса; склонения pluralRu по-английски — две формы.
- Помощник пишет пояснения на языке интерфейса; сам промпт, шаблоны поиска паролей, имена файлов на диске
(копия заголовка, КАК-ВОССТАНОВИТЬ.txt), разбор вывода hdiutil и словарь слабых паролей не переводятся.
- scripts/check-l10n.sh собирает ключи компилятором (-emit-localized-strings) и проверяет, что у каждого есть
перевод с теми же форматами; шаг «Переводы» в CI.
Проверки: 807 пройдены (русский текст не изменился); снимки демо-режима на английском и русском просмотрены.
Windows пока на русском.
---
.github/workflows/ci.yml | 4 +
Localization/en.lproj/InfoPlist.strings | 6 +
Localization/en.lproj/Localizable.strings | 1086 +++++++++++++++++
Localization/ru.lproj/InfoPlist.strings | 6 +
Localization/ru.lproj/Localizable.strings | 2 +
README.md | 8 +-
Sources/Offload/AppModel.swift | 24 +-
Sources/Offload/AssistantModel.swift | 74 +-
Sources/Offload/CleanupModel.swift | 62 +-
Sources/Offload/CloudRestoreModel.swift | 30 +-
Sources/Offload/Demo.swift | 48 +-
Sources/Offload/Models.swift | 72 +-
Sources/Offload/OffloadApp.swift | 50 +-
Sources/Offload/ProModel.swift | 12 +-
Sources/Offload/SafeModel.swift | 74 +-
Sources/Offload/UpdatesModel.swift | 6 +-
Sources/Offload/Views/AssistantView.swift | 94 +-
Sources/Offload/Views/BackupView.swift | 48 +-
Sources/Offload/Views/CleanupView.swift | 306 ++---
Sources/Offload/Views/CloudRestoreView.swift | 70 +-
Sources/Offload/Views/Components.swift | 14 +-
Sources/Offload/Views/ContentView.swift | 12 +-
Sources/Offload/Views/DockerView.swift | 106 +-
Sources/Offload/Views/HistoryView.swift | 98 +-
Sources/Offload/Views/OverviewView.swift | 94 +-
Sources/Offload/Views/ProView.swift | 44 +-
Sources/Offload/Views/SafeComponents.swift | 62 +-
Sources/Offload/Views/SafeView.swift | 198 +--
Sources/Offload/Views/SpaceView.swift | 84 +-
Sources/Offload/Views/UTMSheet.swift | 42 +-
Sources/Offload/Views/UpdateViews.swift | 26 +-
Sources/OffloadCore/Assistant.swift | 19 +-
Sources/OffloadCore/AssistantApi.swift | 18 +-
Sources/OffloadCore/AssistantBot.swift | 18 +-
Sources/OffloadCore/AssistantClaudeCode.swift | 14 +-
Sources/OffloadCore/AssistantOllama.swift | 10 +-
Sources/OffloadCore/BackupEngine.swift | 8 +-
Sources/OffloadCore/Cleanup.swift | 82 +-
Sources/OffloadCore/CloudRestore.swift | 20 +-
Sources/OffloadCore/DecisionStore.swift | 6 +-
Sources/OffloadCore/DockerService.swift | 50 +-
Sources/OffloadCore/Duplicates.swift | 2 +-
Sources/OffloadCore/Format.swift | 4 +-
Sources/OffloadCore/Habits.swift | 64 +-
Sources/OffloadCore/License.swift | 22 +-
Sources/OffloadCore/Localization.swift | 30 +
Sources/OffloadCore/MemoryStats.swift | 18 +-
Sources/OffloadCore/PasswordStrength.swift | 22 +-
Sources/OffloadCore/Runner.swift | 6 +-
Sources/OffloadCore/SafeMover.swift | 60 +-
Sources/OffloadCore/Safety.swift | 70 +-
Sources/OffloadCore/SecretsVault.swift | 48 +-
Sources/OffloadCore/UpdateCheck.swift | 8 +-
Sources/OffloadCore/VerifiedCopy.swift | 16 +-
scripts/build-app.sh | 6 +-
scripts/check-l10n.sh | 45 +
56 files changed, 2360 insertions(+), 1168 deletions(-)
create mode 100644 Localization/en.lproj/InfoPlist.strings
create mode 100644 Localization/en.lproj/Localizable.strings
create mode 100644 Localization/ru.lproj/InfoPlist.strings
create mode 100644 Localization/ru.lproj/Localizable.strings
create mode 100644 Sources/OffloadCore/Localization.swift
create mode 100755 scripts/check-l10n.sh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 42fefc0..c84702f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,10 @@ jobs:
OFFLOAD_SKIP_DOCKER: "1"
run: swift run -c release OffloadChecks
+ # У каждой строки интерфейса должен быть английский перевод.
+ - name: Переводы
+ run: scripts/check-l10n.sh
+
# Universal и упаковка — ровно то, что делает release.yml на теге.
# Иначе первая же проверка этих шагов случалась бы на настоящем релизе.
- name: Сборка OffLoadAI.app (universal)
diff --git a/Localization/en.lproj/InfoPlist.strings b/Localization/en.lproj/InfoPlist.strings
new file mode 100644
index 0000000..ad76f07
--- /dev/null
+++ b/Localization/en.lproj/InfoPlist.strings
@@ -0,0 +1,6 @@
+/* Запросы доступа macOS — по-английски. */
+"NSDocumentsFolderUsageDescription" = "OffLoadAI shows what takes up space in your Documents folder and moves what you choose to an external drive.";
+"NSDesktopFolderUsageDescription" = "OffLoadAI shows what takes up space on your desktop and moves what you choose to an external drive.";
+"NSDownloadsFolderUsageDescription" = "OffLoadAI shows what takes up space in Downloads and moves what you choose to an external drive.";
+"NSRemovableVolumesUsageDescription" = "OffLoadAI moves data and makes backups to an external drive.";
+"NSNetworkVolumesUsageDescription" = "OffLoadAI can move data to a network drive.";
diff --git a/Localization/en.lproj/Localizable.strings b/Localization/en.lproj/Localizable.strings
new file mode 100644
index 0000000..188eccb
--- /dev/null
+++ b/Localization/en.lproj/Localizable.strings
@@ -0,0 +1,1086 @@
+/* Английский перевод интерфейса OffLoadAI. Ключ — русский текст из кода (tr, Text и т. п.).
+ Ключи собирает компилятор: swift build -Xswiftc -emit-localized-strings. */
+
+" · зашифрован целиком" = " · fully encrypted";
+" · паролей: %lld" = " · passwords: %lld";
+" Перенесите большое и редко нужное в сейф — вернуть можно в любой момент." = " Move large, rarely used items to the vault — you can restore them anytime.";
+" и " = " and ";
+" — показаны первые 500, уточните запрос" = " — showing the first 500, refine your search";
+"%@ (особый файл пропущен)" = "%@ (special file skipped)";
+"%@ (особый файл)" = "%@ (special file)";
+"%@ · %@ · файлов %lld · %@" = "%@ · %@ · %lld files · %@";
+"%@ · свободно %@" = "%@ · %@ free";
+"%@ · свободно %@ из %@" = "%@ · %@ free of %@";
+"%@ · файлов %lld · папок %lld" = "%@ · %lld files · %lld folders";
+"%@ · ≈%lld бит" = "%@ · ≈%lld bits";
+"%@ занимает %@ — закройте лишние вкладки." = "%@ is using %@ — close tabs you don’t need.";
+"%@ и ещё %lld" = "%@ and %lld more";
+"%@ из %@" = "%@ of %@";
+"%@ не принимает файлы больше 4 ГБ, а самый большой здесь — %@." = "%@ doesn’t accept files larger than 4 GB, and the largest one here is %@.";
+"%@ не хранит символические ссылки, а внутри их %lld. Перенос бы их сломал." = "%@ can’t store symbolic links, and there are %lld inside. Moving would break them.";
+"%@ — .dmg, .pkg и .xip старше недели. Если программа понадобится снова, установщик можно скачать. Посмотрите список ниже: «Разрешить всё» установщики не удаляет, только ответ здесь." = "%@ — .dmg, .pkg and .xip files older than a week. If you need the app again, you can download the installer. Check the list below: “Allow All” doesn’t delete installers — only your answer here does.";
+"%@ — не менялось больше трёх месяцев. Перенесу в сейф со сверкой каждого файла и уберу с Mac; вернуть можно в «Перенесённом»." = "%@ — unchanged for more than three months. I’ll move it to the vault, verifying every file, and remove it from the Mac; you can restore it in “Moved Items”.";
+"%@ — папки с git, которых нет в бэкапе. Ничего не удаляется: они только добавятся в список папок бэкапа." = "%@ — git folders that aren’t in the backup. Nothing is deleted: they’ll just be added to the list of backup folders.";
+"%@ — программы создадут это заново." = "%@ — apps will recreate this.";
+"%@. Вернуть можно здесь же или из Корзины, пока её не очистили." = "%@. You can restore it here or from the Trash until it’s emptied.";
+"%@. Кеш наберётся при следующей сборке. Образы с именем, тома с данными и контейнеры не трогаю; все неиспользуемые образы можно убрать в разделе «Docker»." = "%@. The cache will build up again on the next build. Named images, volumes with data and containers stay untouched; you can remove all unused images in the “Docker” section.";
+"%@/%@: нет доступа" = "%@/%@: no access";
+"%@/%@: такой же путь уже есть в «%@» — пропущен, чтобы не затереть" = "%@/%@: the same path already exists in “%@” — skipped to avoid overwriting";
+"%@: символическая ссылка не на обычный файл — не скопирована" = "%@: symbolic link to something other than a regular file — not copied";
+"%lld %@ одинаковых файлов (%lld %@): %@. У каждого файла останется одна копия — та, что лежит на своём месте, — и каждая лишняя перед удалением сверяется с ней байт в байт." = "%lld %@ of identical files (%lld %@): %@. Each file keeps one copy — the one in its proper place — and every extra copy is compared with it byte for byte before deletion.";
+"%lld %@ — на каждый ответьте «да» или «не сейчас». Удаляемое сначала уходит в Корзину." = "%lld %@ — answer “Yes” or “Not Now” to each. Anything deleted goes to the Trash first.";
+"%lld %@, %@ — прочтёт любой, у кого окажется диск." = "%lld %@, %@ — anyone who gets hold of the drive can read them.";
+"%lld дн. %lld ч." = "%lld d %lld h";
+"%lld из %lld" = "%lld of %lld";
+"%lld из %lld: «%@» — %@" = "%lld of %lld: “%@” — %@";
+"%lld мин" = "%lld min";
+"%lld ч." = "%lld h";
+"**Иначе, чем в VeraCrypt:** нет скрытых томов и правдоподобного отрицания, каскадов шифров и ключевых файлов. Формат образа — родной для macOS: его шифрование написала и проверяет Apple, мы не изобретаем своё. Если нужно именно отрицание существования данных — пользуйтесь VeraCrypt." = "**Unlike VeraCrypt:** no hidden volumes or plausible deniability, no cipher cascades or keyfiles. The disk image format is native to macOS: its encryption is written and vetted by Apple — we don’t invent our own. If you specifically need to deny that the data exists, use VeraCrypt.";
+"**Как в VeraCrypt:** AES-256; пароль не хранится и не пишется на диск, в программы уходит только через stdin; шифрование проверяется у самой macOS до открытия и после; автозакрытие по сну, блокировке и простою; резервная копия заголовка; смена пароля без перешифровки данных; перенос со сверкой SHA-256." = "**Like VeraCrypt:** AES-256; the password is never stored or written to disk and reaches programs only via stdin; encryption is confirmed with macOS itself before and after opening; auto-close on sleep, lock and idle; header backup; password change without re-encrypting the data; moves verified with SHA-256.";
+"**Что сейф не защитит:** открытый сейф — от программ, запущенных под вашей учётной записью; Mac с вредоносной программой — от перехвата пароля при вводе; слабый пароль — от перебора." = "**What the vault won’t protect against:** an open vault — against apps running under your user account; a Mac with malware — against the password being intercepted as you type it; a weak password — against brute force.";
+", освобождено %@" = ", %@ freed";
+", проблем: %lld." = ", problems: %lld.";
+", формат %lld" = ", format %lld";
+"--- иерархия вью для %@ ---\n" = "--- view hierarchy for %@ ---\n";
+".dmg, .pkg и .xip старше недели — только отдельным «да»." = ".dmg, .pkg and .xip files older than a week — only with a separate “Yes”.";
+"1 490 ₽ или $19 — один раз" = "1,490 ₽ or $19 — one-time";
+"Anthropic сейчас не отвечает (ошибка %lld) — попробуйте через минуту." = "Anthropic isn’t responding right now (error %lld) — try again in a minute.";
+"Claude Code на этом Mac" = "Claude Code on this Mac";
+"Claude Code не вошёл в учётную запись. Откройте Терминал, запустите claude и войдите (/login) — затем «Проверить снова»." = "Claude Code isn’t signed in. Open Terminal, run claude and sign in (/login) — then click “Check Again”.";
+"Claude Code не может связаться с Anthropic — проверьте интернет (или прокси, если он нужен)." = "Claude Code can’t reach Anthropic — check your internet connection (or proxy, if you need one).";
+"Claude Code не найден. Установите его (claude.com/claude-code) и войдите своей учётной записью Claude — затем «Проверить снова»." = "Claude Code wasn’t found. Install it (claude.com/claude-code) and sign in with your Claude account — then click “Check Again”.";
+"Claude Code не ответил: " = "Claude Code didn’t respond: ";
+"Claude Code, установленный на этом Mac, под вашей учётной записью Claude. Ключ не нужен." = "Claude Code installed on this Mac, using your Claude account. No key needed.";
+"Claude не ответил за десять минут — попробуйте позже или папку поменьше." = "Claude didn’t respond within ten minutes — try again later or with a smaller folder.";
+"Claude отказался разбирать этот список. Попробуйте другую папку." = "Claude declined to review this list. Try another folder.";
+"Claude по ключу API" = "Claude with an API key";
+"Desktop/Отпуск 2023 (1).mov" = "Desktop/Vacation 2023 (1).mov";
+"Docker не запущен" = "Docker isn’t running";
+"Docker не запущен. Откройте Docker Desktop и повторите." = "Docker isn’t running. Open Docker Desktop and try again.";
+"Docker не сказал, сколько места занято внутри." = "Docker didn’t report how much space is used inside it.";
+"Docker не сообщил, сколько места освободил." = "Docker didn’t report how much space it freed.";
+"Docker не установлен" = "Docker isn’t installed";
+"Docker не установлен." = "Docker isn’t installed.";
+"Docker очистил только часть выбранного (%lld из выбранных пунктов%@), а дальше остановился: %@" = "Docker cleaned only part of the selection (%lld of the selected items%@) and then stopped: %@";
+"Docker очистил только часть, дальше остановился: %@" = "Docker cleaned only part and then stopped: %@";
+"Docker очищен" = "Docker cleaned up";
+"Docker сейчас смотрит не на этот Mac, а на «%@» (контекст Docker или DOCKER_HOST). Очищать и архивировать чужой Docker OffLoadAI не будет: переключитесь на локальный контекст (docker context use desktop-linux или default) и повторите." = "Docker is currently pointed not at this Mac but at “%@” (Docker context or DOCKER_HOST). OffLoadAI won’t clean or archive someone else’s Docker: switch to the local context (docker context use desktop-linux or default) and try again.";
+"Docker считает размеры томов — это может занять минуту" = "Docker is calculating volume sizes — this may take a minute";
+"Docker считает, что занимает место внутри него…" = "Docker is calculating what takes up space inside it…";
+"Docker удалил %@" = "Docker removed %@";
+"Docker удалил %@." = "Docker removed %@.";
+"Docker удалил выбранное." = "Docker removed the selected items.";
+"Docker.raw вырос на %@." = "Docker.raw grew by %@.";
+"Documents/Архив 2019" = "Documents/Archive 2019";
+"Documents/Договор аренды.pdf" = "Documents/Lease Agreement.pdf";
+"Documents/Работа" = "Documents/Work";
+"Documents/Сканы договоров" = "Documents/Scanned Contracts";
+"Downloads/Датасеты" = "Downloads/Datasets";
+"Downloads/Договор аренды (1).pdf" = "Downloads/Lease Agreement (1).pdf";
+"Downloads/Отпуск 2023.mov" = "Downloads/Vacation 2023.mov";
+"Downloads/Установщики" = "Downloads/Installers";
+"Finder не увидит эту резервную копию iPhone, пока вы не вернёте её на место." = "Finder won’t see this iPhone backup until you put it back.";
+"GitHub не ответил на вопрос о версии (код %lld)." = "GitHub didn’t answer the version check (code %lld).";
+"GitHub ответил не так, как ожидалось." = "GitHub responded in an unexpected way.";
+"Mac уходит в сон" = "Mac is going to sleep";
+"Movies/Интервью 2024" = "Movies/Interview 2024";
+"Movies/Отпуск 2023.mov" = "Movies/Vacation 2023.mov";
+"Movies/Съёмки 2023" = "Movies/Footage 2023";
+"OffLoadAI Pro: что в нём и ключ" = "OffLoadAI Pro: What’s Included and Your Key";
+"OffLoadAI забудет ваши ответы по каждой папке и файлу и привычки, выученные на них. Итоги прошлых разборов и то, что вы просили не предлагать, останутся." = "OffLoadAI will forget your answers for each folder and file, and the habits learned from them. Past cleanup results and the items you asked not to suggest will remain.";
+"OffLoadAI на GitHub" = "OffLoadAI on GitHub";
+"OffLoadAI учится на ваших ответах и сам кладёт похожее в нужный вопрос." = "OffLoadAI learns from your answers and puts similar items into the right question on its own.";
+"Ollama не запущена. Установите её (ollama.com), запустите и скачайте модель: ollama pull qwen2.5:7b — затем «Проверить снова»." = "Ollama isn’t running. Install it (ollama.com), start it and download a model: ollama pull qwen2.5:7b — then click “Check Again”.";
+"Ollama ответила ошибкой %lld: %@" = "Ollama returned error %lld: %@";
+"Pictures/Экспорт Lightroom 2022" = "Pictures/Lightroom Export 2022";
+"Pro — ранний пользователь" = "Pro — early adopter";
+"Pro: пробный, %lld %@" = "Pro: trial, %lld %@";
+"Pro: продлите ключ" = "Pro: renew your key";
+"SSH-ключи без парольной фразы: %@. Любой, кто получит сам файл ключа, сразу сможет им пользоваться. Добавьте фразу: ssh-keygen -p -f ~/.ssh/<ключ>" = "SSH keys without a passphrase: %@. Anyone who gets the key file itself can use it right away. Add a passphrase: ssh-keygen -p -f ~/.ssh/";
+"UTM не найден" = "UTM not found";
+"VeraCrypt советует от %lld символов. Проще всего — фраза из 4–6 случайных слов через пробел." = "VeraCrypt recommends at least %lld characters. The easiest option is a phrase of 4–6 random words separated by spaces.";
+"[%lld с] %@\n" = "[%lld s] %@\n";
+"docker завершился с кодом %d" = "docker exited with code %d";
+"hdiutil не сообщил точку монтирования" = "hdiutil didn’t report the mount point";
+"hdiutil не сообщил, каким диском подключился образ" = "hdiutil didn’t report which disk the image was attached as";
+"macOS не нашла в образе пустых участков: диску вернулось %@. Место внутри сейфа при этом свободно и пойдёт под новые данные." = "macOS found no empty areas in the image: %@ was returned to the disk. The free space inside the vault is still available for new data.";
+"macOS создаст рядом служебные файлы ._* — OffLoadAI удалит их после сверки." = "macOS will create ._* helper files next to it — OffLoadAI will remove them after verification.";
+"restic завершился с кодом %d" = "restic exited with code %d";
+"restic ничего не восстановил: «%@» нет в снимке." = "restic restored nothing: “%@” isn’t in the snapshot.";
+"zstd завершился с кодом %d" = "zstd exited with code %d";
+"~/.ssh, учётка GitHub CLI, дотфайлы с токенами (.zshrc, .npmrc, .netrc…), базы KeePass и .env/.key/.pem из папок бэкапа. Складываются с сохранением путей; инструкция по восстановлению лежит в сейфе рядом." = "~/.ssh, GitHub CLI account, dotfiles with tokens (.zshrc, .npmrc, .netrc…), KeePass databases and .env/.key/.pem files from backup folders. Stored with their paths preserved; restore instructions are kept next to them in the vault.";
+"«%@»" = "“%@”";
+"«%@» / " = "“%@” / ";
+"«%@» будет скопирован обратно: каждый файл перечитывается с диска и сверяется по SHA-256 со списком, записанным при переносе. На Mac понадобится %@." = "“%@” will be copied back: each file is re-read from disk and verified with SHA-256 against the list recorded during the move. It needs %@ on the Mac.";
+"«%@» возвращён на место, каждый файл перечитан и сверен по SHA-256." = "“%@” is back in place; every file was re-read and verified with SHA-256.";
+"«%@» возвращён на место, но с оговорками:" = "“%@” is back in place, but with caveats:";
+"«%@» восстановлено и сверено с бэкапом: %@." = "“%@” restored and verified against the backup: %@.";
+"«%@» восстановлено не целиком: %@. Файлы, которые не прочитались из бэкапа, убраны — они были бы испорчены; остальное с бэкапом не сверено: после ошибок restic не сверяет. Не удалось:" = "“%@” was only partly restored: %@. Files that couldn’t be read from the backup were removed — they would have been corrupted; the rest wasn’t verified against the backup, because restic doesn’t verify after errors. Failed:";
+"«%@» завершилась с кодом %d: %@" = "“%@” exited with code %d: %@";
+"«%@» зарегистрирован в приложении (виртуальная машина, медиатека или проект). После переноса приложение его потеряет, даже если данные целы." = "“%@” is registered in an app (virtual machine, library or project). After the move the app will lose track of it, even if the data is intact.";
+"«%@» изменился во время переноса — оригинал не тронут." = "“%@” changed during the move — the original is untouched.";
+"«%@» лежит не на внешнем диске" = "“%@” isn’t on an external drive";
+"«%@» на «%@»" = "“%@” on “%@”";
+"«%@» не удалось отправить в Корзину: %@" = "Couldn’t move “%@” to the Trash: %@";
+"«%@» ограничен %@: для ключей хватит, для больших папок — нет. Предел можно увеличить без потери содержимого." = "“%@” is limited to %@: enough for keys, not for large folders. You can raise the size limit without losing the contents.";
+"«%@» осталось на месте. %@" = "“%@” stayed in place. %@";
+"«%@» осталось на месте: %@ Перенесите вручную в «Освободить место», если уверены." = "“%@” stayed in place: %@ Move it manually in “Free Up Space” if you’re sure.";
+"«%@» осталось на месте: его сейчас использует %@." = "“%@” stayed in place: it’s currently in use by %@.";
+"«%@» осталось на месте: копии, с которой его можно сверить, на месте уже нет." = "“%@” stayed in place: the copy to verify it against is no longer there.";
+"«%@» осталось на месте: после поиска оно изменилось и больше не совпадает с «%@»." = "“%@” stayed in place: it changed after the search and no longer matches “%@”.";
+"«%@» похоже на год — перебор подставляет годы первыми." = "“%@” looks like a year — brute-force attacks try years first.";
+"«%@» сейчас использует %@. Закройте программу и повторите." = "“%@” is currently in use by %@. Quit the app and try again.";
+"«%@» уже существует — перезаписывать не буду." = "“%@” already exists — I won’t overwrite it.";
+"«%@» — в OffLoadAI Pro. %@" = "“%@” is part of OffLoadAI Pro. %@";
+"«%@» — место, откуда программы читают настройки и код, а запись об этом переносе есть только в журнале на диске, не на этом Mac. Если архив ваш, скопируйте его вручную." = "“%@” is a place apps read settings and code from, and the record of this move exists only in the log on the drive, not on this Mac. If the archive is yours, copy it manually.";
+"«%@» — стандартная папка macOS. Переносите её содержимое, а не саму папку." = "“%@” is a standard macOS folder. Move its contents, not the folder itself.";
+"«%@» — уже не обычный файл." = "“%@” is no longer a regular file.";
+"«%@»: %@" = "“%@”: %@";
+"«%@»: в Корзине его уже нет — вернули или Корзину очистили." = "“%@”: it’s no longer in the Trash — it was put back or the Trash was emptied.";
+"«%@»: в Корзине его уже нет." = "“%@”: it’s no longer in the Trash.";
+"«%@»: в Корзине под этим именем теперь другой файл — его не трогаю." = "“%@”: a different file now has this name in the Trash — leaving it alone.";
+"«%@»: на прежнем месте уже есть файл с таким именем — оставил в Корзине." = "“%@”: a file with that name already exists in the original location — left it in the Trash.";
+"«%@»: отменено, созданный том удалён." = "“%@”: canceled, the created volume was deleted.";
+"«%@»: отменено, том не тронут." = "“%@”: canceled, the volume is untouched.";
+"«%@»: подготовка" = "“%@”: preparing";
+"«%@»: сейф закрыт — осталось на месте." = "“%@”: the vault is closed — stayed in place.";
+"«~/%@» — настройки, ключи или инструменты разработки. Им нужно оставаться на месте." = "“~/%@” holds settings, keys or developer tools. It needs to stay where it is.";
+"Автоматическое закрытие" = "Auto-Close";
+"Архив 2015" = "Archive 2015";
+"Архив 2016" = "Archive 2016";
+"Архив 2017" = "Archive 2017";
+"Архив 2018" = "Archive 2018";
+"Архив с личными фото: нужен, но редко — место ему в сейфе." = "Archive of personal photos: needed, but rarely — it belongs in the vault.";
+"Архив тома «%@» не совпал с томом — том не тронут." = "The archive of volume “%@” didn’t match the volume — the volume is untouched.";
+"Архив установки Xcode: скачивается заново с сайта Apple." = "Xcode install archive: can be downloaded again from Apple’s website.";
+"Архивировать выбранные тома?" = "Archive the selected volumes?";
+"Архивировать на диск…" = "Archive to Drive…";
+"Архивы томов" = "Volume Archives";
+"Б" = "B";
+"База и кеш Telegram. Кеш очищается в самом Telegram: Настройки → Данные и память → Использование памяти." = "Telegram database and cache. Clear the cache in Telegram itself: Settings → Data and Storage → Storage Usage.";
+"База решений: %@" = "Decision database: %@";
+"Без него OffLoadAI не видит «Документы», «Рабочий стол», Почту и данные многих приложений, и часть занятого места останется неизвестной. Выдайте доступ в настройках и перезапустите OffLoadAI." = "Without it, OffLoadAI can’t see Documents, Desktop, Mail and the data of many apps, and some of the used space will remain unknown. Grant access in System Settings and restart OffLoadAI.";
+"Без перезагрузки" = "Uptime";
+"Бесплатная версия" = "Free Version";
+"Бесплатно всегда: сейф, перенос со сверкой, возврат перенесённого, очистка мусора и Docker, старые установщики, ключи и токены в сейф, восстановление из iCloud. Вернуть своё OffLoadAI не мешает никогда — ни без ключа, ни после пробы." = "Always free: the vault, verified moves, restoring moved items, junk and Docker cleanup, old installers, keys and tokens in the vault, restoring from iCloud. OffLoadAI never stops you from getting your data back — neither without a key nor after the trial.";
+"Большое и давно не менялось — в сейфе не мешает, а вернуть можно в любой момент." = "Large and unchanged for a long time — it won’t get in the way in the vault, and you can restore it anytime.";
+"Браузеры Playwright — скачаются снова командой «playwright install»." = "Playwright browsers — they’ll download again with “playwright install”.";
+"Будет создан новый том и заполнен из архива со сверкой. Существующий том с таким именем не перезаписывается." = "A new volume will be created and filled from the archive with verification. An existing volume with the same name won’t be overwritten.";
+"Было на Mac в папке" = "Original folder on Mac";
+"Бэкап" = "Backup";
+"Бэкап ляжет на диск открыто: кто получит диск, прочтёт ваши проекты. Чтобы зашифровать, выберите внизу боковой панели «В сейф»." = "The backup will be stored on the drive unencrypted: anyone who gets the drive can read your projects. To encrypt it, choose “To Vault” at the bottom of the sidebar.";
+"Бэкап остановлен. Уже скопированные файлы остались на диске." = "Backup stopped. Files already copied remain on the drive.";
+"Бэкап проектов" = "Project Backup";
+"Бэкап: папок %lld" = "Backup: %lld folders";
+"Бэкапы restic из iCloud Drive — например, копия внешнего диска. OffLoadAI только читает хранилище и ничего в нём не меняет, а пароль держит в памяти, пока хранилище открыто: сон и блокировка экрана его стирают." = "restic backups in iCloud Drive — for example, a copy of an external drive. OffLoadAI only reads the repository and changes nothing in it, and keeps the password in memory only while the repository is open: sleep and screen lock erase it.";
+"Бэкапы/ssd-restic" = "Backups/ssd-restic";
+"В Ollama нет ни одной модели. Скачайте, например: ollama pull qwen2.5:7b — затем «Проверить снова»." = "Ollama has no models. Download one, for example: ollama pull qwen2.5:7b — then click “Check Again”.";
+"В UTM: правый клик по машине → «Удалить…» (Delete…). UTM удалит её вместе с дисками — место освободится сразу." = "In UTM: right-click the machine → “Delete…”. UTM will delete it along with its disks — the space is freed right away.";
+"В UTM: правый клик → «Переместить…» (Move…) и выберите папку в сейфе или на диске. UTM скопирует машину, удалит оригинал и запомнит новое место. Перед запуском такой машины подключите диск и откройте сейф — иначе UTM покажет её недоступной, — а перед сном Mac и закрытием сейфа выключите её: диск машины, у которой отключили сейф, может испортиться." = "In UTM: right-click → “Move…” and choose a folder in the vault or on the drive. UTM will copy the machine, delete the original and remember the new location. Before starting such a machine, connect the drive and open the vault — otherwise UTM will show it as unavailable — and shut it down before the Mac sleeps or the vault closes: the disk of a machine whose vault was disconnected can get corrupted.";
+"В iCloud Drive бэкапов restic не нашлось. Если хранилище лежит в другом месте, выберите его папку." = "No restic backups were found in iCloud Drive. If the repository is somewhere else, choose its folder.";
+"В «Загрузках» около 6 ГБ мусора — архив Xcode и установщики. Видео, архив с фото и старую выгрузку лучше убрать в сейф, документы оставить." = "Downloads has about 6 GB of junk — an Xcode archive and installers. The video, the photo archive and the old export are better moved to the vault; keep the documents.";
+"В Корзине %@ из этого разбора: место на Mac освободится, только когда их удалят оттуда. Остальное в Корзине не трогаю. Вернуть удалённое насовсем будет нельзя." = "%@ from this cleanup is in the Trash: the space on the Mac is freed only once it’s deleted from there. I don’t touch anything else in the Trash. Permanently deleted items can’t be restored.";
+"В Корзине его уже нет." = "It’s no longer in the Trash.";
+"В Корзине лежит %lld объектов, отправленных этим разбором. Кнопки «Вернуть» у вопросов после этого не будет: вернуть их можно будет только вручную из Корзины." = "The Trash holds %lld items sent there by this cleanup. After that, questions won’t have a “Restore” button: you’ll only be able to put them back manually from the Trash.";
+"В Корзине: %@, %lld из %lld" = "In the Trash: %@, %lld of %lld";
+"В Корзину" = "Move to Trash";
+"В архиве не хватает %lld файлов из списка, записанного при переносе (%@). Остальное сверено и возвращено." = "The archive is missing %lld files from the list recorded during the move (%@). Everything else was verified and restored.";
+"В архиве появилось %lld файлов, которых при переносе не было (%@). Они тоже вернулись, но сверить их не с чем — откуда они, программа не знает." = "The archive has %lld files that weren’t there during the move (%@). They were restored too, but there’s nothing to verify them against — the app doesn’t know where they came from.";
+"В бэкап" = "To Backup";
+"В заголовке лежит ключ данных, зашифрованный паролем: испортится он — пропадёт всё, даже при верном пароле. Храните копию заголовка отдельно от диска. Место, освобождённое внутри сейфа, идёт под новые данные, но сам образ на диске не уменьшается; сжатие возвращает его частично, а на больших сейфах macOS может не вернуть ничего — OffLoadAI покажет, сколько вернулось на самом деле." = "The header holds the data key, encrypted with your password: if it gets damaged, everything is lost, even with the right password. Keep a copy of the header separate from the drive. Space freed inside the vault is reused for new data, but the disk image itself doesn’t shrink; compacting gets some of it back, and on large vaults macOS may not return anything — OffLoadAI will show how much was actually returned.";
+"В нём открыты файлы в других программах. Закройте их и повторите — или закройте сейф принудительно: несохранённое в этих программах может пропасть." = "Files in it are open in other apps. Close them and try again — or force the vault to close: unsaved changes in those apps may be lost.";
+"В нём открыты файлы в других программах. Закрыть принудительно? Несохранённое в этих программах может пропасть." = "Files in it are open in other apps. Force close? Unsaved changes in those apps may be lost.";
+"В очереди — начну, как только закончу предыдущее" = "Queued — I’ll start as soon as the previous task is done";
+"В папке UTM машин нет. Машины, открытые из других папок, UTM показывает у себя в списке." = "There are no machines in the UTM folder. Machines opened from other folders are shown in UTM’s own list.";
+"В папке «%@» нет хранилища restic." = "There’s no restic repository in “%@”.";
+"В пароле есть «%@» — такие слова перебор пробует первыми, в том числе с заменами букв на цифры и знаки." = "Your password contains “%@” — password crackers try words like this first, including with letters swapped for digits and symbols.";
+"В пароле есть невидимые управляющие символы (перевод строки, табуляция, нулевой байт) — уберите их." = "Your password contains invisible control characters (line break, tab, null byte) — remove them.";
+"В прошлый раз вы выбрали это же." = "You chose the same thing last time.";
+"В сейф" = "To Vault";
+"В сейф «%@»" = "To Vault “%@”";
+"В сейф перенесено и сверено: %lld из %lld. Открытые копии удалены." = "Moved to the vault and verified: %lld of %lld. The unencrypted copies were deleted.";
+"В сейф сложено файлов: %lld, без изменений: %lld" = "Files added to the vault: %lld, unchanged: %lld";
+"В сейфе не хватает места: нужно %@, свободно %@. Освободите место на диске «%@»." = "Not enough space in the vault: %@ needed, %@ free. Free up space on “%@”.";
+"В сейфе открыты файлы. Закройте их в других программах и повторите." = "Files in the vault are open. Close them in other apps and try again.";
+"В сейф…" = "To Vault…";
+"В хранилище пока нет ни одного снимка." = "The repository has no snapshots yet.";
+"Важно" = "Important";
+"Важное в Корзину не отправляю — только в сейф." = "I don’t send important items to the Trash — only to the vault.";
+"Ваш ключ Anthropic API: платите по счёту API за каждый вопрос. Ключ хранится в связке ключей macOS." = "Your own Anthropic API key: you pay the API bill for each question. The key is stored in the macOS keychain.";
+"Введите пароль" = "Enter Password";
+"Ввести ключ" = "Enter Key";
+"Вернуть" = "Restore";
+"Вернуть в Docker…" = "Restore to Docker…";
+"Вернуть и оставить копию на диске" = "Restore and Keep a Copy on the Drive";
+"Вернуть и удалить с диска" = "Restore and Delete from the Drive";
+"Вернуть из Корзины на прежнее место" = "Put Back from Trash";
+"Вернуть из Корзины на прежние места — и больше не предлагать" = "Put back from the Trash to their original locations — and don’t suggest again";
+"Вернуть место на диск" = "Return Space to Disk";
+"Вернуть можно здесь же или из Корзины, пока её не очистили" = "You can restore it here or from the Trash until it’s emptied";
+"Вернуть на Mac?" = "Restore to Mac?";
+"Вернуть не получилось." = "Couldn’t restore.";
+"Вернуть не удалось: %@" = "Couldn’t restore: %@";
+"Вернуть обратно можно в разделе «Перенесённое»." = "You can restore it in Moved Items.";
+"Вернуть том в Docker" = "Restore Volume to Docker";
+"Вернуть…" = "Restore…";
+"Видео из отпуска с «(1)» в имени — похоже на повторную загрузку; сохранить стоит, но не на диске Mac." = "A vacation video with “(1)” in its name — looks like a repeat download; worth keeping, but not on the Mac disk.";
+"Виртуальная машина приложения Claude — она нужна ему для работы." = "The Claude app’s virtual machine — it needs it to work.";
+"Виртуальные машины (Docker, UTM и др.)" = "Virtual machines (Docker, UTM, etc.)";
+"Виртуальные машины UTM" = "UTM virtual machines";
+"Виртуальные машины UTM. Удаляйте и переносите их через сам UTM, иначе он их потеряет." = "UTM virtual machines. Delete and move them through UTM itself, or it will lose track of them.";
+"Виртуальные машины занимают %@. Закройте Docker Desktop или UTM, если они сейчас не нужны." = "Virtual machines take up %@. Quit Docker Desktop or UTM if you don’t need them right now.";
+"Внешний диск" = "External Drive";
+"Внешний диск «%@»" = "External drive “%@”";
+"Внутри git-репозитории: после переноса с ними можно работать только с диска." = "Contains git repositories: after the move you can only work with them from the drive.";
+"Внутри git-репозиторий — похоже на рабочий проект." = "Contains a git repository — looks like an active project.";
+"Внутри лежит «%@» — пакет, зарегистрированный в приложении (например, виртуальная машина UTM). После переноса приложение его потеряет." = "Contains “%@” — a package registered with an app (for example, a UTM virtual machine). After the move, the app will lose track of it.";
+"Внутри медиатека «Фото» — её перенести нельзя, остальное можно." = "Contains the Photos library — it can’t be moved, but the rest can.";
+"Внутри появится новая папка «Из бэкапа …»: ничего существующего не перезаписывается. Восстановленное сверяется с бэкапом." = "A new folder “From Backup …” will be created inside: nothing existing is overwritten. Restored files are verified against the backup.";
+"Внутри смонтирован другой диск («%@»). Отключите его или переносите по частям." = "Another disk is mounted inside (“%@”). Eject it or move the rest in parts.";
+"Возврат отменён, незаконченная копия удалена. Архив на диске не тронут." = "Restore canceled, the unfinished copy was deleted. The archive on the drive wasn’t touched.";
+"Возвращаю «%@»" = "Restoring “%@”";
+"Возвращаю из Корзины…" = "Putting back from the Trash…";
+"Возвращаю место на диск…" = "Returning space to the disk…";
+"Возвращено" = "Restored";
+"Вопрос помощнику (необязательно): например, «что из этого можно удалить?»" = "Question for the Assistant (optional): for example, “what can I delete here?”";
+"Восстанавливаю «%@»" = "Restoring “%@”";
+"Восстанавливаю заголовок…" = "Restoring header…";
+"Восстановить" = "Restore";
+"Восстановить заголовок" = "Restore Header";
+"Восстановить…" = "Restore…";
+"Восстановление остановлено, недокачанное убрано." = "Restore stopped, partially downloaded files removed.";
+"Временная выгрузка, которую давно не открывали: в сейфе она не мешает, а понадобится — вернёте." = "A temporary export you haven’t opened in a long time: it won’t get in the way in the vault, and you can restore it when you need it.";
+"Все неиспользуемые образы" = "All unused images";
+"Вставьте ключ Anthropic API (console.anthropic.com → API Keys) — он хранится в связке ключей и только на этом Mac." = "Paste your Anthropic API key (console.anthropic.com → API Keys) — it’s stored in the keychain, only on this Mac.";
+"Всё, что записано внутри контейнера, а не в томе, пропадёт вместе с ним. Образы удалённых контейнеров тоже освободятся." = "Anything written inside the container rather than in a volume will be lost with it. Images of deleted containers will be freed too.";
+"Всё-таки положить открыто на диск «%@»" = "Store Unencrypted on “%@” Anyway";
+"Вы пользовались OffLoadAI до Pro — всё открыто навсегда" = "You used OffLoadAI before Pro — everything is unlocked for good";
+"Вы просили не предлагать этот файл — эта копия остаётся." = "You asked not to suggest this file — this copy stays.";
+"Выберите папки с проектами и документами для бэкапа" = "Choose folders with projects and documents to back up";
+"Выбранное для сейфа (%@) при таком пределе поместится не целиком." = "Your vault selection (%@) won’t fit entirely with this size limit.";
+"Выбрано %lld, около %@. Каждый том упаковывается в архив, список всех его файлов сверяется с архивом, и только потом том удаляется из Docker. Тома, подключённые к контейнерам, не трогаются." = "%lld selected, about %@. Each volume is packed into an archive, the list of all its files is verified against the archive, and only then is the volume removed from Docker. Volumes attached to containers aren’t touched.";
+"Выбрать" = "Choose";
+"Выбрать папку хранилища…" = "Choose Repository Folder…";
+"Выбрать…" = "Choose…";
+"Выписка ЕГРН.pdf" = "Property Registry Extract.pdf";
+"Вышла версия %@" = "Version %@ is out";
+"ГБ" = "GB";
+"Где думает помощник" = "Where the Assistant Thinks";
+"Готово" = "Done";
+"Данные вернулись на Mac и сверены, а архив удалить не удалось: %@ Он остался в «%@» — удалите его сами, когда будет удобно." = "The data is back on your Mac and verified, but the archive couldn’t be deleted: %@ It’s still in “%@” — delete it yourself whenever convenient.";
+"Данные не перешифровываются — меняется только заголовок, поэтому это быстро." = "The data isn’t re-encrypted — only the header changes, so it’s quick.";
+"Данные не перешифровываются — меняется только заголовок." = "The data isn’t re-encrypted — only the header changes.";
+"Данные приложений" = "App data";
+"Данные приложений в /Users/Shared/Library (например, движок BlueStacks): приложение перестанет их находить." = "App data in /Users/Shared/Library (for example, the BlueStacks engine): the app will no longer find it.";
+"Данные приложений в ~/Library: приложение перестанет их находить. Отсюда можно переносить только прошивки iPhone, логи и резервные копии iPhone." = "App data in ~/Library: the app will no longer find it. From here, only iPhone firmware, logs, and iPhone backups can be moved.";
+"Данные приложений: перенос сломает программы, которые их используют." = "App data: moving it will break the apps that use it.";
+"Делаю…" = "Working…";
+"Демонстрация" = "Demo";
+"Демонстрация: Docker не тронут." = "Demo: Docker wasn’t touched.";
+"Демонстрация: «%@» восстановился бы в новую папку в «%@»." = "Demo: “%@” would be restored to a new folder in “%@”.";
+"Демонстрация: тома не упакованы, Docker не тронут." = "Demo: volumes weren’t packed, Docker wasn’t touched.";
+"Диск Docker (Docker.raw) занимает %@" = "The Docker disk (Docker.raw) takes up %@";
+"Диск Docker (Docker.raw) занимает на Mac %@" = "The Docker disk (Docker.raw) takes up %@ on the Mac";
+"Диск Docker не найден: за его ростом не слежу, только за свободным местом на Mac" = "Docker disk not found: I’m not tracking its growth, only free space on the Mac";
+"Диск Docker. Место в нём освобождается в разделе «Docker»: очисткой образов и кеша сборки и архивацией неиспользуемых томов." = "The Docker disk. Space inside it is freed in the Docker section: by cleaning up images and the build cache and archiving unused volumes.";
+"Диск Mac" = "Mac Disk";
+"Диск «%@» (%@) не хранит права доступа, а списка прав рядом с архивом нет. Права выставлены только для вас: папки 700, файлы 600, исполняемые 700." = "“%@” (%@) doesn’t store permissions, and there’s no permissions list next to the archive. Permissions are set for you only: folders 700, files 600, executables 700.";
+"Диск «%@» доступен только для чтения (%@)." = "“%@” is read-only (%@).";
+"Диск «%@» — %@: разрежённых файлов там нет, и машина займёт на нём полный объём дисков, а не нынешний размер. Сейф внутри — APFS, он разрежённые файлы хранит." = "“%@” is %@: it has no sparse files, so the machine will take up the full size of its disks there, not its current size. The vault inside is APFS, which does store sparse files.";
+"Диск «%@» — %@: файлы больше 4 ГБ он не принимает, машину туда не перенести. Переносите в сейф." = "“%@” is %@: it doesn’t accept files larger than 4 GB, so the machine can’t be moved there. Move it to the vault instead.";
+"Диск не подключён" = "Drive not connected";
+"Диску возвращено %@. Сейф занимает %@." = "%@ returned to the disk. The vault takes up %@.";
+"Для восстановления нужна программа restic. Установите её: brew install restic — и нажмите «Проверить снова»." = "Restoring requires restic. Install it with brew install restic, then click “Check Again”.";
+"Для папок и файлов, которые вы уже перенесли на внешний диск без OffLoadAI. Запись появится в списке, и вернуть их на Mac можно будет как обычно — со сверкой." = "For folders and files you’ve already moved to an external drive without OffLoadAI. An entry will appear in the list, and you’ll be able to restore them to your Mac as usual — with verification.";
+"До 20 строк — так понятнее, что это за файл. Таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления, не уходят никогда." = "Up to 20 lines — makes it clearer what the file is. Spreadsheets (.csv), key files, and files showing a password, token, card number, or recovery phrase are never sent.";
+"Добавить" = "Add";
+"Добавить вручную…" = "Add Manually…";
+"Добавить папки…" = "Add Folders…";
+"Добавить перенесённое вручную" = "Add Moved Item Manually";
+"Добавить проекты в бэкап?" = "Add projects to backup?";
+"Добавлено в бэкап: %lld. Обновите бэкап, чтобы они в него попали" = "Added to backup: %lld. Update the backup to include them";
+"Добавьте папки с проектами и документами." = "Add folders with projects and documents.";
+"Договор аренды 2026.pdf" = "Lease Agreement 2026.pdf";
+"Документы" = "Documents";
+"Домашнюю папку целиком переносить нельзя." = "You can’t move your entire home folder.";
+"Домашняя папка" = "Home folder";
+"Домашняя папка и общие файлы" = "Home folder and shared files";
+"Другая папка…" = "Other Folder…";
+"Его диск занимает %@. Запустите Docker Desktop и разберите ещё раз — спрошу, что из него можно удалить." = "Its disk takes up %@. Start Docker Desktop and run the cleanup again — I’ll ask what can be deleted from it.";
+"Если в сейф идёт копирование, он закроется сразу после его окончания." = "If something is being copied to the vault, it will close as soon as the copy finishes.";
+"Если внутри гостевой системы удалили много файлов, сам образ диска не уменьшается. В настройках машины, в разделе её диска, есть кнопка «Reclaim Space» — она пересобирает образ без пустых блоков." = "If many files were deleted inside the guest system, the disk image itself doesn’t shrink. In the machine’s settings, in its disk section, there’s a “Reclaim Space” button — it rebuilds the image without empty blocks.";
+"Если выйти, копирование прервётся. Данные не пострадают: оригинал не удаляется, пока копия не сверена, а незаконченная копия будет убрана." = "If you quit, copying will stop. Your data is safe: the original isn’t deleted until the copy is verified, and the unfinished copy will be removed.";
+"Если им не пользоваться" = "When not in use";
+"Если какой-то программой вы пользуетесь прямо с диска (например, моделями LM Studio), после переноса в сейф укажите ей новую папку и держите сейф открытым, пока она нужна. Такие пункты можно снять." = "If you use an app directly from the drive (for example, LM Studio models), after moving to the vault point it to the new folder and keep the vault open while you need it. You can uncheck such items.";
+"Есть оговорки — решите сами." = "Has caveats — your call.";
+"Ещё %lld не показаны — найдите нужное по имени." = "%lld more not shown — search by name to find what you need.";
+"Ещё раз" = "Confirm";
+"Жду iCloud: часть бэкапа хранится только в облаке, и iCloud её ещё не скачал. OffLoadAI попросил скачать и ждёт. Если долго ничего не меняется, проверьте в Finder → iCloud Drive, что синхронизация идёт, — или остановите и попробуйте позже." = "Waiting for iCloud: part of the backup is stored only in the cloud, and iCloud hasn’t downloaded it yet. OffLoadAI asked for it to be downloaded and is waiting. If nothing changes for a long time, check in Finder → iCloud Drive that syncing is in progress — or stop and try again later.";
+"Жду, пока Docker вернёт место Mac" = "Waiting for Docker to return space to the Mac";
+"Забыть" = "Forget";
+"Забыть ваши решения?" = "Forget your decisions?";
+"Забыть мои решения…" = "Forget My Decisions…";
+"Забыть не получилось: %@" = "Couldn’t forget: %@";
+"Заведите сейф" = "Create a vault";
+"Заголовок восстановлен из копии, сейф открывается паролем этой копии." = "The header was restored from the copy; the vault now opens with that copy’s password.";
+"Заголовок не восстановлен: %@" = "Header not restored: %@";
+"Загрузки" = "Downloads";
+"Закончить" = "Finish";
+"Закончить разбор?" = "Finish cleanup?";
+"Закроется после копирования (%@)" = "Will close after copying (%@)";
+"Закройте сейф, чтобы выбрать другой диск" = "Close the vault to choose another drive";
+"Закрываю сейф…" = "Closing vault…";
+"Закрыть" = "Close";
+"Закрыть принудительно" = "Force Close";
+"Закрыть сейф" = "Close Vault";
+"Закрыть сейф (⌘⇧L)" = "Close Vault (⌘⇧L)";
+"Заметка, например «упаковано в tar.gz» (необязательно)" = "Note, for example “packed into tar.gz” (optional)";
+"Запись журнала выглядит небезопасной: %@" = "The log entry looks unsafe: %@";
+"Запустить Docker" = "Start Docker";
+"Зарегистрировать папку или файл, уже перенесённые на внешний диск без OffLoadAI" = "Register a folder or file already moved to an external drive without OffLoadAI";
+"Зашифрованный образ диска — похоже, в нём ваши данные. Удалить его из разбора нельзя." = "An encrypted disk image — it seems to hold your data. It can’t be deleted from cleanup.";
+"Зашифрованный образ на внешнем диске: без пароля его содержимое не прочтёт никто." = "An encrypted disk image on the external drive: without the password, no one can read its contents.";
+"Зашифровать" = "Encrypt";
+"Зашифруйте то, что уже лежит на диске открыто" = "Encrypt what’s already stored unencrypted on the drive";
+"И ещё %lld объектов меньше 1 МБ" = "And %lld more items under 1 MB";
+"Идущий перенос отменится, оригиналы останутся на месте: они удаляются только после сверки копии." = "The move in progress will be canceled; originals stay in place: they’re deleted only after the copy is verified.";
+"Идёт копирование — сейф закроется, как только оно закончится." = "Copying in progress — the vault will close as soon as it’s done.";
+"Из iCloud" = "From iCloud";
+"Из Корзины удалится только то, что туда отправил этот разбор. Вернуть это будет нельзя." = "Only what this cleanup sent to the Trash will be deleted from it. This can’t be undone.";
+"Из бэкапа %@" = "From Backup %@";
+"Изменить" = "Edit";
+"Изображения" = "Images";
+"Имена и пути файлов и папок от домашней папки, их размеры и даты, пометки правил OffLoadAI и несколько имён внутри папок. Содержимое файлов — нет: начало небольших текстовых файлов уходит, только если вы сами это включите, и никогда — таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления. Файлы, которые лежат только в iCloud, не скачиваются. Фото, видео и документы никуда не уходят." = "File and folder names and paths relative to your home folder, their sizes and dates, OffLoadAI rule labels, and a few names inside folders. Not file contents: the beginning of small text files is sent only if you turn this on yourself, and never for spreadsheets (.csv), key files, or files showing a password, token, card number, or recovery phrase. Files stored only in iCloud aren’t downloaded. Photos, videos, and documents are never sent anywhere.";
+"Имя без «(1)» и «копия» — похоже на оригинал, он остаётся." = "A name without “(1)” or “copy” — looks like the original; it stays.";
+"Имя тома" = "Volume Name";
+"Исключения" = "Exclusions";
+"Используется" = "In Use";
+"Источник и назначение на одном диске — место не освободится." = "Source and destination are on the same disk — no space will be freed.";
+"Ищу «%@»…" = "Searching for “%@”…";
+"Ищу бэкапы в iCloud Drive…" = "Looking for backups in iCloud Drive…";
+"Ищу одинаковые файлы" = "Looking for identical files";
+"КБ" = "KB";
+"Как освободить…" = "How to Free Up…";
+"Какой образ — сейф" = "Which image is the vault";
+"Какую папку разобрать" = "Which folder to clean up";
+"Какую папку разобрать с помощником" = "Which folder to clean up with the Assistant";
+"Канал OffLoadAI в Telegram" = "OffLoadAI Telegram channel";
+"Кеш Chrome — страницы подгрузятся снова; закладки, пароли и история не затрагиваются." = "Chrome cache — pages will load again; bookmarks, passwords and history aren’t affected.";
+"Кеш CocoaPods — поды скачаются снова." = "CocoaPods cache — pods will be downloaded again.";
+"Кеш Gradle — зависимости скачаются снова." = "Gradle cache — dependencies will be downloaded again.";
+"Кеш Spotify — музыка подгрузится снова. Скачанное для прослушивания без сети, возможно, придётся скачать заново." = "Spotify cache — music will load again. Songs downloaded for offline listening may need to be downloaded again.";
+"Кеш VS Code — пересоздаётся сам." = "VS Code cache — rebuilt automatically.";
+"Кеш Xcode — пересоздаётся сам." = "Xcode cache — rebuilt automatically.";
+"Кеш Yarn — пакеты скачаются снова." = "Yarn cache — packages will be downloaded again.";
+"Кеш npm — пакеты скачаются снова." = "npm cache — packages will be downloaded again.";
+"Кеш pip — пакеты скачаются снова." = "pip cache — packages will be downloaded again.";
+"Кеш и данные самого UTM, не машины" = "Cache and data of UTM itself, not the virtual machines";
+"Кеш сборки" = "Build cache";
+"Кеш сборки Go — пересоздаётся при следующей сборке." = "Go build cache — rebuilt on the next build.";
+"Кеш сборки и образы без имени. Образы с именем и тома с данными не трогаю." = "Build cache and untagged images. Named images and data volumes stay untouched.";
+"Кеш симулятора iOS — пересоздаётся сам." = "iOS Simulator cache — rebuilt automatically.";
+"Кеши и индексы сред JetBrains — пересоздаются при следующем запуске. Локальная история правок не затрагивается." = "JetBrains IDE caches and indexes — rebuilt on the next launch. Local edit history isn’t affected.";
+"Кеши и скачанные пакеты — программы создадут их заново." = "Caches and downloaded packages — apps will recreate them.";
+"Кеши программ" = "App caches";
+"Клон другой копии: данные у них общие, и удаление места не освободит." = "A clone of another copy: they share data, so deleting it won’t free any space.";
+"Ключ API" = "API Key";
+"Ключ API не подошёл — проверьте, что он скопирован целиком и не отозван." = "The API key didn’t work — check that it was copied in full and hasn’t been revoked.";
+"Ключ верный, но его обновления закончились %@, а эта версия вышла позже. Продлите ключ — или поставьте версию, вышедшую до этой даты: с ней ключ работает всегда." = "The key is valid, but its updates ended on %@, and this version came out later. Renew the key — or install a version released before that date: the key always works with it.";
+"Ключ выпущен для более новой версии OffLoadAI. Обновите программу." = "This key was issued for a newer version of OffLoadAI. Update the app.";
+"Ключ на имя «%@»" = "Key registered to “%@”";
+"Ключ не открывает эту версию — продлите его." = "The key doesn’t unlock this version — renew it.";
+"Ключ не подходит: подпись не сходится. Возможно, в нём опечатка — скопируйте его заново." = "The key doesn’t fit: the signature doesn’t match. It may contain a typo — copy it again.";
+"Ключ работает всегда. Новые версии — год, дальше продление за полцены; не продлили — остаётся последняя версия того года." = "The key works forever. New versions for a year, then renewal at half price; if you don’t renew, you keep the last version from that year.";
+"Ключ сохранён в связке ключей." = "The key is saved in the keychain.";
+"Ключ №%@. Обновления до %@." = "Key #%@. Updates until %@.";
+"Ключ: OFFLOAD-…" = "Key: OFFLOAD-…";
+"Ключи и токены — только в сейф" = "Keys and tokens go only to the vault";
+"Когда Mac уходит в сон" = "When the Mac goes to sleep";
+"Когда экран заблокирован, включилась заставка или сменился пользователь" = "When the screen is locked, the screen saver starts, or the user is switched";
+"Контейнер уже существует." = "The container already exists.";
+"Контейнеры" = "Containers";
+"Копии заголовка, снятые раньше, откроются старым паролем: после смены снимите новую, а старые удалите." = "Header copies made earlier will open with the old password: after changing it, make a new copy and delete the old ones.";
+"Копии заголовка, снятые раньше, по-прежнему открываются старым паролем. Снимите новую копию, а старые удалите." = "Header copies made earlier still open with the old password. Make a new copy and delete the old ones.";
+"Копирование" = "Copying";
+"Копия «%@» не совпала с оригиналом." = "The copy of “%@” didn’t match the original.";
+"Копия заголовка" = "Header Copy";
+"Копия заголовка сохранена: %@" = "Header copy saved: %@";
+"Копия защищена тем же паролем, что и сейф. Без пароля она бесполезна." = "The copy is protected by the same password as the vault. Without the password it’s useless.";
+"Копия лежит на том же диске, что и сейф: при отказе диска пропадут обе. Сохраните ещё одну в другом месте." = "The copy is on the same drive as the vault: if the drive fails, you lose both. Save another one somewhere else.";
+"Копия ляжет на диск открыто: кто получит диск, прочтёт её без пароля. Чтобы зашифровать, выберите внизу боковой панели «В сейф»." = "The copy will be stored unencrypted: anyone who gets the drive can read it without a password. To encrypt it, choose “To Vault” at the bottom of the sidebar.";
+"Копия не выбрана" = "No copy selected";
+"Корень" = "Root";
+"Критическое" = "Critical";
+"Крупное и старое" = "Large and old";
+"Куда" = "Destination";
+"Куда восстановить" = "Restore To";
+"Куда класть" = "Store In";
+"Куда класть восстановленное — внутри появится новая папка" = "Where to put restored files — a new folder will be created inside";
+"Куда пойдут перенос, бэкап и тома Docker" = "Where moves, backups and Docker volumes will go";
+"Куда положить копию заголовка сейфа. Лучше не на тот же диск: если он откажет, пропадут и сейф, и копия." = "Where to save a copy of the vault header. Better not on the same drive: if it fails, you lose both the vault and the copy.";
+"Куда: %@" = "Destination: %@";
+"Купить…" = "Buy…";
+"Лежит в сейфе — зашифровано." = "Stored in the vault — encrypted.";
+"Лежит на своём месте, а не в Загрузках или на Рабочем столе, — эта копия остаётся." = "It’s in its proper place, not in Downloads or on the Desktop, so this copy stays.";
+"Личный документ — оставить на месте." = "Personal document — leave it in place.";
+"Лишние копии" = "Duplicates";
+"Лишняя копия: содержимое то же, что у копии, которая остаётся." = "Duplicate: same contents as the copy that stays.";
+"Локальная модель (Ollama)" = "Local model (Ollama)";
+"Локальная модель не ответила за десять минут — попробуйте модель поменьше или папку поменьше." = "The local model didn’t respond within ten minutes — try a smaller model or a smaller folder.";
+"МБ" = "MB";
+"Мало места" = "Low on space";
+"Медиатека «Фото»" = "Photos Library";
+"Менее важно" = "Less important";
+"Меньшее из свободного внутри образа и на самом диске" = "The smaller of the free space inside the image and on the drive itself";
+"Менялось %@ — возможно, ещё используется." = "Modified %@ — may still be in use.";
+"Менялось не так давно — решите сами." = "Modified not that long ago — your call.";
+"Менялось недавно — похоже, вы этим пользуетесь." = "Modified recently — looks like you’re using it.";
+"Меняю пароль…" = "Changing password…";
+"Места достаточно" = "Enough space";
+"Места занимает немного." = "Doesn’t take up much space.";
+"Места на Mac достаточно" = "Enough space on your Mac";
+"Место внутри сейфа освободилось и пойдёт под новые данные, но сам образ на диске от этого не уменьшится." = "The space inside the vault has been freed and will be used for new data, but the disk image itself won’t get smaller.";
+"Место на диске" = "Disk Space";
+"Много повторов и подряд идущих символов — их перебирают в первую очередь." = "Lots of repeats and sequential characters — these are guessed first.";
+"Модель" = "Model";
+"Модель в Ollama на этом Mac: сведения о файлах не покидают его. Медленнее и проще, чем Claude." = "A model in Ollama on this Mac: file details never leave it. Slower and simpler than Claude.";
+"Можно освободить" = "Can be freed";
+"Можно перенести" = "Safe to move";
+"Музыка" = "Music";
+"Мусор" = "Junk";
+"На %@ macOS хранит служебные данные файла X в файле «._X» и перезаписывает его сама, а у вас такие файлы есть (%@). Целыми они туда не лягут — переносите в сейф или на диск APFS." = "On %@, macOS keeps a file X’s metadata in a file named “._X” and overwrites it on its own, and you have such files (%@). They won’t arrive there intact — move them to the vault or to an APFS drive.";
+"На «%@» свободно %@, а нужно около %@." = "“%@” has %@ free, but about %@ is needed.";
+"На время увеличения сейф ненадолго подключится без открытия: файлы не видны ни Finder, ни программам." = "While the size is increased, the vault is briefly attached without being opened: files aren’t visible to Finder or apps.";
+"На диск «%@» открыто" = "Unencrypted on “%@”";
+"На диске" = "On Disk";
+"На диске Mac свободно всего %@. Когда места мало, macOS тормозит: ей негде держать swap. Откройте «Освободить место»." = "Only %@ is free on the Mac disk. When space is low, macOS slows down: it has nowhere to keep swap. Open “Free Up Space”.";
+"На диске «%@» открыто лежат перенесённые данные: %lld %@, %@. Кто получит диск в руки, прочтёт их без пароля." = "Moved data is stored unencrypted on “%@”: %lld %@, %@. Anyone who gets hold of the drive can read it without a password.";
+"На диске «%@» сейфа нет. Создайте его в разделе «Сейф» и ответьте ещё раз." = "There’s no vault on “%@”. Create one in the “Vault” section and answer again.";
+"На диске «%@» сейфа пока нет" = "No vault on “%@” yet";
+"На диске назначения уже есть «%@» — перезаписывать не буду." = "“%@” already exists on the destination drive — I won’t overwrite it.";
+"На диске нет сейфа — создайте его в разделе «Сейф»." = "There’s no vault on the drive — create one in the “Vault” section.";
+"На диске только шифротекст. Откройте, чтобы класть в сейф или брать из него." = "Only ciphertext is on the drive. Open the vault to put files in or take them out.";
+"На диске только шифротекст. Чтобы класть в сейф или брать из него, откройте его паролем." = "Only ciphertext is on the drive. To put files in the vault or take them out, open it with your password.";
+"На него OffLoadAI переносит то, что не нужно держать на Mac, и там же живёт сейф." = "OffLoadAI moves things you don’t need to keep on your Mac to this drive, and the vault lives there too.";
+"На этом Mac" = "On This Mac";
+"Наверх" = "Up";
+"Надёжный" = "Strong";
+"Найдено: %lld" = "Found: %lld";
+"Найду, что занимает место зря, и спрошу про каждое: удалить или нет. Выбирать файлы и ходить по папкам не нужно — только отвечать «да» или «не сейчас». Без вашего «да» ничего не трогаю." = "I’ll find what’s wasting space and ask you about each item: delete it or not. No need to pick files or browse folders — just answer “Yes” or “Not Now”. Nothing is touched without your “Yes”.";
+"Найти" = "Find";
+"Найти по имени во всём снимке" = "Find by name in the whole snapshot";
+"Например, перед продажей Mac. Ключ остаётся вашим — введите его на новом Mac." = "For example, before selling your Mac. The key stays yours — enter it on the new Mac.";
+"Настройте бэкап проектов и ключей" = "Set up a backup of projects and keys";
+"Начать" = "Start";
+"Не всё получилось:" = "Not everything worked:";
+"Не выходить" = "Don’t Quit";
+"Не копировать папки и файлы с именами" = "Don’t copy folders and files named";
+"Не менялось больше трёх месяцев — со сверкой каждого файла." = "Not modified for more than three months — every file is verified.";
+"Не надо" = "No Thanks";
+"Не найдена программа «%@»." = "App “%@” not found.";
+"Не напоминать об этой" = "Don’t Remind Me About This One";
+"Не подключён" = "Not connected";
+"Не получилось" = "Didn’t work";
+"Не получилось запомнить «не предлагать»: %@" = "Couldn’t remember “Don’t Suggest”: %@";
+"Не получилось: %@" = "Didn’t work: %@";
+"Не предлагать больше" = "Don’t Suggest Again";
+"Не предлагаю — %lld" = "Not Suggesting — %lld";
+"Не сейчас" = "Not Now";
+"Не трогать" = "Don’t touch";
+"Не трогаю. Спрошу в следующий раз." = "Leaving it alone. I’ll ask next time.";
+"Не удалось закрыть сейф: %@" = "Couldn’t close the vault: %@";
+"Не удалось записать «%@»: %@" = "Couldn’t write “%@”: %@";
+"Не удалось открыть сейф: %@" = "Couldn’t open the vault: %@";
+"Не удалось проверить, есть ли уже том «%@»: %@" = "Couldn’t check whether volume “%@” already exists: %@";
+"Не удалось проверить, открыты ли файлы «%@» в других программах. Повторите чуть позже." = "Couldn’t check whether files in “%@” are open in other apps. Try again a bit later.";
+"Не удалось проверить, открыты ли файлы в других приложениях. Закройте приложения, которые могут с ними работать." = "Couldn’t check whether the files are open in other apps. Quit any apps that might be using them.";
+"Не удалось проверить: %@" = "Couldn’t check: %@";
+"Не удалось прочитать «%@»." = "Couldn’t read “%@”.";
+"Не удалось сохранить ключ в связке ключей macOS." = "Couldn’t save the key to the macOS keychain.";
+"Не хватает места: нужно %@, свободно %@." = "Not enough space: %@ needed, %@ free.";
+"Неверный пароль." = "Wrong password.";
+"Недопустимое имя тома: «%@»." = "Invalid volume name: “%@”.";
+"Неизвестно" = "Unknown";
+"Неиспользуемые тома упаковываются в сейф со сверкой каждого файла." = "Unused volumes are packed into the vault, with every file verified.";
+"Нет внешнего диска" = "No external drive";
+"Нет доступа к %lld объектам внутри. Выдайте OffLoadAI полный доступ к диску в Системных настройках." = "No access to %lld items inside. Give OffLoadAI Full Disk Access in System Settings.";
+"Нет папки: %@" = "Folder not found: %@";
+"Нет полного доступа к диску" = "No Full Disk Access";
+"Нет связи с Anthropic — проверьте интернет." = "Can’t reach Anthropic — check your internet connection.";
+"Нет связи с GitHub: %@" = "Can’t reach GitHub: %@";
+"Нет связи с сервером OffLoadAI — проверьте интернет." = "Can’t reach the OffLoadAI server — check your internet connection.";
+"Ничего не нашлось." = "Nothing found.";
+"Ничего не пропускается." = "Nothing is skipped.";
+"Новый пароль" = "New Password";
+"Новый предел" = "New Size Limit";
+"Новый сейф на диске «%@»" = "New vault on “%@”";
+"Нормальное" = "Normal";
+"Нужен OffLoadAI Pro" = "Requires OffLoadAI Pro";
+"Нужен OffLoadAI Pro — откроется окно с ценой и ключом" = "Requires OffLoadAI Pro — a window with the price and key will open";
+"Нужен OffLoadAI Pro, или вопросы на этот месяц закончились." = "Requires OffLoadAI Pro, or this month’s questions have run out.";
+"Нужен подключённый внешний диск" = "Requires a connected external drive";
+"Нужен подключённый внешний диск: выберите его внизу боковой панели" = "Requires a connected external drive: choose it at the bottom of the sidebar";
+"Нужно не меньше %lld символов, сейчас %lld." = "At least %lld characters needed; you have %lld.";
+"Нужно подтверждение: " = "Confirmation needed: ";
+"Нужно, если сейф перестал открываться верным паролем (испортился заголовок). Сейф откроется паролем, который действовал, когда снималась копия. Если пароль к копии не подойдёт, прежний заголовок вернётся как был." = "Use this if the vault stopped opening with the correct password (the header is damaged). The vault will open with the password that was in effect when the copy was made. If the password for the copy doesn’t work, the previous header is put back as it was.";
+"О программе OffLoadAI" = "About OffLoadAI";
+"О чём спрошу" = "What I’ll Ask About";
+"Обзор" = "Overview";
+"Обновить" = "Refresh";
+"Обновить бэкап" = "Update Backup";
+"Обновления" = "Updates";
+"Обновления по ключу закончились" = "Updates for this key have ended";
+"Обновляемая копия папок с проектами в сейф: копируется только изменённое." = "An incremental copy of your project folders in the vault: only changes are copied.";
+"Обновляемая копия проектов и ключи с токенами — в сейф." = "An incremental copy of projects, plus keys and tokens — into the vault.";
+"Образ" = "Disk image";
+"Образ .iso: к нему бывает подключена виртуальная машина. Удалить его из разбора нельзя." = "An .iso image: a virtual machine may be using it. It can’t be deleted from Clean Up.";
+"Образ «%@» не зашифрован или шифрование не подтверждается. OffLoadAI не будет класть в него данные." = "Disk image “%@” isn’t encrypted, or its encryption can’t be confirmed. OffLoadAI won’t put data in it.";
+"Образ не зашифрован" = "Disk image isn’t encrypted";
+"Образ разрежённый: места он занимает ровно столько, сколько в нём лежит, а предел лишь не даёт ему вырасти больше. Предел потом можно увеличить. Придумайте пароль, который не используете больше нигде. Надёжнее всего — фраза из 4–6 случайных слов." = "The image is sparse: it takes up exactly as much space as its contents, and the size limit only keeps it from growing larger. You can raise the limit later. Choose a password you don’t use anywhere else. The strongest option is a phrase of 4–6 random words.";
+"Образ сам не уменьшается, когда из сейфа удаляют файлы." = "The image doesn’t shrink on its own when files are deleted from the vault.";
+"Образы" = "Images";
+"Образы без имени" = "Untagged images";
+"Образы, которые не нужны ни одному контейнеру. Docker скачает их заново, когда понадобятся; собранные вами и никуда не отправленные придётся собрать снова. Отмечайте сами." = "Images no container needs. Docker will download them again when needed; ones you built and never pushed anywhere will have to be rebuilt. Select them yourself.";
+"Объект не найден." = "Item not found.";
+"Один вид символов: добавьте слов или длины — это надёжнее, чем цифры в конце." = "Only one kind of character: add words or length — that’s stronger than digits at the end.";
+"Одинаковые файлы находятся по SHA-256, одна копия остаётся всегда, лишние уходят в Корзину." = "Identical files are found by SHA-256; one copy always stays, the extras go to the Trash.";
+"Одинаковые файлы — одна копия каждого остаётся всегда." = "Identical files. One copy of each is always kept.";
+"Одна копия остаётся — эта." = "This is the copy that stays.";
+"Оперативной памяти не хватает — закройте приложения, которыми сейчас не пользуетесь." = "Your Mac is short on memory. Quit apps you aren’t using right now.";
+"Оригинал не удалить целиком: внутри папки только для чтения или защищённые файлы (%@). Ничего не удалено; перенесите без удаления оригинала или снимите защиту." = "The original can’t be fully deleted: the folder contains read-only or protected files (%@). Nothing was deleted. Move it without deleting the original, or remove the protection.";
+"Оригинал не удалить: нет права записи в «%@». Копия на диске цела, оригинал не тронут." = "Can’t delete the original: no write permission for “%@”. The copy on the drive is intact, and the original is untouched.";
+"Оригинал удаляется только после того, как каждый файл копии перечитан с диска и сверен по SHA-256." = "The original is deleted only after every file in the copy has been re-read from disk and verified with SHA-256.";
+"Оригинал уже удалён с Mac" = "The original has already been deleted from your Mac";
+"Освободилось %@" = "Freed %@";
+"Освободите место на Mac" = "Free up space on your Mac";
+"Освободить" = "Free Up";
+"Освободить место" = "Free Up Space";
+"Освободить место в Docker" = "Free Up Space in Docker";
+"Освободить место…" = "Free Up Space…";
+"Освободить около %@" = "Free up about %@";
+"Оставить" = "Keep";
+"Оставить открытым" = "Keep Open";
+"Остальное место на «%@» остаётся для обычных файлов, пока сейф до него не дорос." = "The rest of the space on “%@” stays available for regular files until the vault grows into it.";
+"Остальной OffLoadAI работает без сети — кроме сообщений о новых версиях, если вы их включили. Согласие — отдельно для каждого варианта; отозвать его можно в любой момент." = "The rest of OffLoadAI works offline, except for new version notices if you turned them on. You give consent separately for each option and can withdraw it at any time.";
+"Останется копия на диске, место на Mac не освободится." = "A copy will stay on the drive, but no space will be freed on your Mac.";
+"Остановить" = "Stop";
+"Остановленные контейнеры" = "Stopped containers";
+"Остановлено, ничего не сделано" = "Stopped. Nothing was done";
+"Остановлено, чтобы не забить диск Mac: %@" = "Stopped to keep the Mac disk from filling up: %@";
+"Остановлено. В сейф перенесено: %lld из %lld, остальное осталось на месте как было." = "Stopped. Moved to the vault: %lld of %lld. The rest stayed where it was.";
+"Остатки пересборок с именем : запустить их не по чему, ни один контейнер их не использует." = "Leftovers from rebuilds, named : there’s nothing to run them by, and no container uses them.";
+"Остаться" = "Stay";
+"Ответил: %@%@. Помощник может ошибаться — решение за вами." = "Answered by: %@%@. The assistant can make mistakes. The decision is yours.";
+"Ответили на всё" = "All answered";
+"Ответить «да» на все вопросы" = "Answer “Yes” to all questions";
+"Ответить «да» на все вопросы, кроме установщиков (их удаляю только по отдельному ответу) и вопросов из OffLoadAI Pro" = "Answer “Yes” to all questions except installers (I delete those only when you answer separately) and questions from OffLoadAI Pro";
+"Ответы запомнены: что вы вернули из Корзины, больше не предложу." = "Answers remembered: I won’t suggest anything you put back from the Trash again.";
+"Откройте Docker Desktop и обновите список." = "Open Docker Desktop and refresh the list.";
+"Откройте сейф" = "Open the vault";
+"Откройте сейф." = "Open the vault.";
+"Открываю сейф…" = "Opening the vault…";
+"Открываю хранилище…" = "Opening the repository…";
+"Открыто" = "Unlocked";
+"Открыто · %lld %@" = "Open · %lld %@";
+"Открыто на диск" = "Unencrypted to Drive";
+"Открытые данные на диске" = "Unencrypted Data on the Drive";
+"Открыть" = "Open";
+"Открыть UTM" = "Open UTM";
+"Открыть «Бэкап»" = "Open “Backup”";
+"Открыть «Сейф»" = "Open “Vault”";
+"Открыть настройки" = "Open Settings";
+"Открыть раздел «Docker»: очистка образов и кеша сборки, архивация томов" = "Open the “Docker” section: clean up images and build cache, archive volumes";
+"Открыть раздел «Сейф»" = "Open the “Vault” section";
+"Открыть сейф" = "Open Vault";
+"Отмена" = "Cancel";
+"Отменить" = "Cancel";
+"Отмечайте остановленные контейнеры, только если они точно не нужны: удалённый контейнер не вернуть." = "Select stopped containers only if you’re sure you don’t need them: a deleted container can’t be restored.";
+"Отозвать согласие на «%@»" = "Withdraw Consent for “%@”";
+"Отправить «%@» в Корзину?" = "Move “%@” to the Trash?";
+"Отпуск 2023 (1).mp4" = "Vacation 2023 (1).mp4";
+"Очистить" = "Clean";
+"Очистить Docker?" = "Clean Up Docker?";
+"Очистка" = "Cleanup";
+"Очистка: %@" = "Cleanup: %@";
+"Очистка: подготовка" = "Cleanup: preparing";
+"ПБ" = "PB";
+"Память" = "Memory";
+"Память по приложениям" = "Memory by App";
+"Папка" = "Folder";
+"Папка бэкапа не может лежать внутри копируемой папки «%@»." = "The backup folder can’t be inside the folder being copied, “%@”.";
+"Папка для бэкапа на диске «%@» — можно указать уже существующую" = "Backup folder on “%@”. You can choose an existing one";
+"Папка должна лежать на диске «%@»." = "The folder must be on “%@”.";
+"Папка на Mac, где это лежало" = "The folder on your Mac where this used to be";
+"Папка пуста — разбирать нечего." = "The folder is empty. There’s nothing to clean up.";
+"Папка пуста." = "The folder is empty.";
+"Папка хранилища restic — в ней лежат config, keys и data" = "The restic repository folder, the one that contains config, keys and data";
+"Папки с git — ничего не удаляется." = "Folders with git. Nothing is deleted.";
+"Пароли не совпадают." = "The passwords don’t match.";
+"Пароль" = "Password";
+"Пароль из «%@»" = "Password from “%@”";
+"Пароль из файла…" = "Password from File…";
+"Пароль не подходит к этому хранилищу." = "The password doesn’t match this repository.";
+"Пароль нужен, чтобы убрать это в сейф. Он уходит в macOS и нигде не сохраняется. Как только сейф откроется, начну." = "The password is needed to move this into the vault. It goes to macOS and isn’t stored anywhere. I’ll start as soon as the vault opens.";
+"Пароль сейфа" = "Vault password";
+"Пароль слишком слабый: нужно не меньше %lld символов и стойкость от %lld бит." = "The password is too weak: it needs at least %lld characters and a strength of at least %lld bits.";
+"Пароль сменён." = "Password changed.";
+"Пароль хранилища" = "Repository password";
+"Пароль этой копии" = "Password for this copy";
+"Пароль, заголовок, место" = "Password, Header, Space";
+"Перед сном сейф закрыть не удалось: %@" = "Couldn’t close the vault before sleep: %@";
+"Перед сном сейф остался открытым: шло копирование. Он закроется, как только оно закончится." = "The vault stayed open before sleep because copying was in progress. It will close as soon as copying finishes.";
+"Передумал" = "Changed My Mind";
+"Перезагрузите Mac: в swap %@, и сам он не освободится. Mac работает без перезагрузки %lld дн." = "Restart your Mac: swap holds %@, and it won’t free up on its own. Uptime: %lld d.";
+"Перенесено до появления сейфа — лежит на диске открыто." = "Moved before the vault existed, so it’s stored unencrypted on the drive.";
+"Перенесено и сверено: %lld файлов, %@. Оригинал удалён, место на Mac освободилось." = "Moved and verified: %lld files, %@. The original was deleted and the space on your Mac is freed.";
+"Перенести" = "Move";
+"Перенести «%@»" = "Move “%@”";
+"Перенести в сейф и удалить открытые копии (%lld)" = "Move to Vault and Delete Unencrypted Copies (%lld)";
+"Перенести на внешний диск" = "Move to External Drive";
+"Перенести…" = "Move…";
+"Перенесённого, лежащего на диске открыто, нет." = "No moved items are stored unencrypted on the drive.";
+"Перенесённое" = "Moved Items";
+"Перенесённые на внешний диск папки и файлы появятся здесь. Вернуть их можно, пока диск подключён. То, что вы перенесли раньше без OffLoadAI, можно добавить вручную." = "Folders and files you move to an external drive will appear here. You can restore them while the drive is connected. Anything you moved earlier without OffLoadAI can be added manually.";
+"Перенесённые на внешний диск папки и файлы появятся здесь. Подключите диск и выберите его внизу боковой панели — тогда можно будет добавить в журнал и то, что вы перенесли раньше без OffLoadAI." = "Folders and files you move to an external drive will appear here. Connect the drive and choose it at the bottom of the sidebar. Then you can also add to the log anything you moved earlier without OffLoadAI.";
+"Перенос отменён. Оригинал не тронут, незаконченная копия удалена." = "Move canceled. The original is untouched, and the unfinished copy was deleted.";
+"Перенос со сверкой каждого файла; вернуть можно в «Перенесённом»" = "Every file is verified as it’s moved. You can restore it in “Moved Items”";
+"Перенос, бэкап и ключи идут в него. Закройте, когда закончите." = "Moves, backups and keys go into it. Close it when you’re done.";
+"Перенос, бэкап и ключи сейчас идут сюда. Закройте после работы." = "Moves, backups and keys go here for now. Close it when you’re done.";
+"Переносить можно только из домашней папки и /Users/Shared." = "You can only move items from your home folder and /Users/Shared.";
+"Пересчитать" = "Recalculate";
+"Повышенное" = "Elevated";
+"Под именем" = "Name";
+"Подготовка" = "Preparing";
+"Подготовка образа с GNU tar" = "Preparing an image with GNU tar";
+"Подключите внешний диск" = "Connect an external drive";
+"Подключите внешний диск с сейфом" = "Connect the external drive with the vault";
+"Подключите внешний диск — сейф живёт на нём." = "Connect the external drive. The vault lives on it.";
+"Подключите внешний диск, на котором лежит сейф, и ответьте ещё раз. Остальные вопросы от этого не зависят." = "Connect the external drive that holds the vault and answer again. The other questions don’t depend on it.";
+"Подключите внешний диск." = "Connect an external drive.";
+"Подключён «%@»" = "“%@” connected";
+"Подробнее…" = "More Info…";
+"Пока ничего не перенесено" = "Nothing moved yet";
+"Пока привычек нет. Привычка появляется, когда вы хотя бы трижды одинаково решаете похожее — например, убираете в сейф старые съёмки из «Фильмов». Тогда похожие папки сами попадут в вопрос о сейфе." = "No habits yet. A habit appears once you make the same decision about similar items at least three times, for example moving old footage from Movies into the vault. After that, similar folders go into the vault question on their own.";
+"Пока сейф открыт, ключ шифрования живёт в памяти Mac, а файлы доступны программам под вашей учётной записью. Поэтому лучше не держать его открытым без нужды. При выходе из OffLoadAI сейф закрывается всегда." = "While the vault is open, the encryption key stays in your Mac’s memory and the files are available to apps running under your user account. So it’s best not to keep it open when you don’t need it. The vault always closes when you quit OffLoadAI.";
+"Показать в Finder" = "Show in Finder";
+"Показать на диске" = "Show on Drive";
+"Показывать помощнику начало небольших текстовых файлов" = "Show the assistant the beginning of small text files";
+"Поместится около %@" = "About %@ will fit";
+"Помню ваши ответы для %lld %@" = "I remember your answers for %lld %@";
+"Помощник" = "Assistant";
+"Помощник не ответил за четыре минуты. Попробуйте папку поменьше." = "The assistant didn’t answer within four minutes. Try a smaller folder.";
+"Помощник ответил не по форме — попробуйте ещё раз." = "The assistant’s answer wasn’t in the expected format. Try again.";
+"Помощник смотрит %lld %@…" = "The assistant is reviewing %lld %@…";
+"Помощник через сервер OffLoadAI входит в OffLoadAI Pro: введите ключ в окне «OffLoadAI Pro»." = "The assistant via the OffLoadAI server is part of OffLoadAI Pro. Enter your key in the “OffLoadAI Pro” window.";
+"Помощник через сервер OffLoadAI ещё не запущен — он появится в одном из обновлений. Пока выберите другой вариант." = "The assistant via the OffLoadAI server isn’t available yet. It will arrive in a future update. For now, choose another option.";
+"Понимаю, переносить всё равно" = "I Understand, Move Anyway";
+"Понятно" = "Got It";
+"Порядок работы" = "How It Works";
+"Последнее изменение" = "Last modified";
+"Похоже на проект (внутри git): его лучше держать в бэкапе, а не переносить." = "Looks like a project (it uses git). It’s better to keep it in the backup than to move it.";
+"Похожее вы обычно %@ (%lld из %lld): %@." = "Similar items you usually %@ (%lld of %lld): %@.";
+"Появилась раньше остальных — похоже на оригинал, он остаётся." = "Appeared before the others, so it looks like the original. It stays.";
+"Правила OffLoadAI запрещают это трогать: " = "OffLoadAI rules don’t allow touching this: ";
+"Правила OffLoadAI не дают отправить это в Корзину: " = "OffLoadAI rules don’t allow moving this to the Trash: ";
+"Предел роста" = "Growth limit";
+"Предел сейфа" = "Vault size limit";
+"Предел сейфа не увеличен: %@" = "The vault size limit wasn’t increased: %@";
+"Предел сейфа — %@. Содержимое на месте, а места на диске образ занимает столько же, сколько занимал." = "The vault size limit is now %@. The contents are intact, and the disk image takes up the same space on the drive as before.";
+"Прервать и выйти" = "Stop and Quit";
+"Прерывать копирование ради закрытия" = "Interrupt copying to close";
+"Привычки" = "Habits";
+"Привычки действуют в OffLoadAI Pro. Ваши ответы запоминаются и сейчас — с Pro похожее сразу начнёт попадать в нужный вопрос." = "Habits work in OffLoadAI Pro. Your answers are remembered even now, so with Pro, similar items will go straight into the right question.";
+"Приложение, которому принадлежит «~/%@», будет искать эти данные по старому пути. Переносите, только если в нём можно указать новую папку (как папку моделей в LM Studio)." = "The app that owns “~/%@” will look for this data at the old path. Move it only if you can set a new folder in that app (like the models folder in LM Studio).";
+"Проблемы" = "Problems";
+"Пробные две недели закончились; всё найденное по-прежнему видно, а «не сейчас» работает как всегда." = "Your two-week trial has ended. Everything found is still visible, and “Not Now” works as always.";
+"Пробный период: осталось %lld %@" = "Trial: %lld %@ left";
+"Проверить" = "Check";
+"Проверить обновления…" = "Check for Updates…";
+"Проверить снова" = "Check Again";
+"Проверка" = "Checking";
+"Проверка отменена. Ничего не скопировано и не удалено." = "Check canceled. Nothing was copied or deleted.";
+"Проверяю содержимое, открытые файлы и диск «%@»…" = "Checking the contents, open files and the drive “%@”…";
+"Программа «%@» не ответила вовремя." = "“%@” didn’t respond in time.";
+"Проекты без бэкапа" = "Projects without backup";
+"Промежуточные слои от docker build. Следующая сборка пойдёт дольше, пока кеш не наберётся заново." = "Intermediate layers from docker build. The next build will take longer until the cache fills up again.";
+"Промежуточные файлы сборки Xcode — пересоздаются при следующей сборке." = "Intermediate Xcode build files — recreated on the next build.";
+"Прочее в папке UTM" = "Other items in the UTM folder";
+"Прошивки iPhone — Finder скачает нужную снова." = "iPhone firmware — Finder will download the one it needs again.";
+"Прошлый разбор — %@" = "Last cleanup — %@";
+"Путь к папке бэкапа проходит через символическую ссылку «%@» — писать по нему не буду: бэкап оказался бы не там, где вы думаете." = "The path to the backup folder goes through the symbolic link “%@”, so I won’t write there: the backup would end up somewhere other than where you think.";
+"Рабочий стол" = "Desktop";
+"Раз в сутки OffLoadAI спросит у GitHub номер последней версии и скажет, если вышла новая: в новых версиях бывают исправления безопасности. В запросе нет ничего о компьютере и файлах — GitHub видит только адрес сети, как при открытии любой страницы. Обновление ставите вы сами. Передумать можно в меню OffLoadAI." = "Once a day, OffLoadAI will ask GitHub for the latest version number and let you know if a new one is out: new versions can include security fixes. The request contains nothing about your computer or files. GitHub only sees your network address, as with opening any web page. You install updates yourself. You can change your mind in the OffLoadAI menu.";
+"Разгрузка диска Mac без риска потерять данные.\nОригинал удаляется только после проверенной копии.\n\n" = "Free up your Mac disk without risking your data.\nThe original is deleted only after the copy is verified.\n\n";
+"Размер" = "Size";
+"Разобрать" = "Clean Up";
+"Разобрать Mac" = "Clean Up Mac";
+"Разобрать Mac: найду, что занимает место зря, и спрошу про каждое — удалить, убрать в сейф или добавить в бэкап. Без вашего «да» ничего не трогаю." = "Clean up your Mac: I’ll find what’s wasting space and ask about each item: delete it, move it to the vault, or add it to the backup. I won’t touch anything without your “Yes”.";
+"Разобрать ещё раз" = "Clean Up Again";
+"Разрежённые или сжатые файлы (%lld) займут на диске полный размер: %@ вместо %@." = "Sparse or compressed files (%lld) will take up their full size on the drive: %@ instead of %@.";
+"Разрешить всё" = "Allow All";
+"Разрешить всё · %@" = "Allow All · %@";
+"Распаковка в том" = "Unpacking into the volume";
+"Резервная копия iPhone. После возврата Finder снова её увидит." = "iPhone backup. Once restored, Finder will see it again.";
+"Решения не запоминаются: %@" = "Decisions aren’t being remembered: %@";
+"Рядом с архивом нет списка контрольных сумм, записанного при переносе, — сверить архив с его прежним состоянием не с чем. Все %lld файлов сверены с тем, что лежит на диске сейчас, и вернулись такими." = "There’s no checksum list saved during the move next to the archive, so there’s nothing to verify the archive’s earlier state against. All %lld files were verified against what’s on the drive now and restored as they are.";
+"С момента переноса на диске изменилось файлов: %lld (%@). Вернулось то, что лежит в архиве сейчас, — оно сверено побайтово." = "Files changed on the drive since the move: %lld (%@). What’s in the archive now was restored and verified byte by byte.";
+"С оговорками" = "With caveats";
+"С оговорками — поэтому не в Корзину, а в сейф: оттуда вернуть проще." = "With caveats — so it goes to the vault, not the Trash: it’s easier to restore from there.";
+"Сам диск «%@» (%@) зашифровать нельзя: у этой файловой системы шифрования нет. Удалённые с SSD и флешек файлы физически могут оставаться в памяти, пока контроллер их не перезапишет. Для защиты всего диска, как в VeraCrypt при шифровании раздела: перенесите данные, отформатируйте диск в «APFS (зашифрованный)» в Дисковой утилите и верните их." = "The drive “%@” (%@) itself can’t be encrypted: this file system has no encryption. Files deleted from SSDs and flash drives may physically remain in memory until the controller overwrites them. To protect the whole drive, as VeraCrypt does when encrypting a partition: move the data off, format the drive as “APFS (Encrypted)” in Disk Utility, and put the data back.";
+"Сам диск «%@» зашифрован целиком." = "The drive “%@” itself is fully encrypted.";
+"Сам диск «%@» не зашифрован. Его можно зашифровать целиком, не стирая: правый щелчок по диску в Finder → «Зашифровать». Тогда защищено будет и то, что лежит вне сейфа." = "The drive “%@” itself isn’t encrypted. You can encrypt the whole drive without erasing it: right-click the drive in Finder → “Encrypt”. Then everything outside the vault will be protected too.";
+"Сбросить" = "Reset";
+"Свадьба Ани" = "Anna’s Wedding";
+"Сведения о файлах больше не уйдут сюда, пока вы снова не согласитесь" = "File details won’t be sent here again until you agree again";
+"Сверка" = "Verification";
+"Сверка с копией" = "Verifying against the copy";
+"Сверка: архив читается на Mac" = "Verification: the archive is readable on the Mac";
+"Сверка: список файлов архива" = "Verification: archive file list";
+"Сверка: список файлов тома" = "Verification: volume file list";
+"Свободно" = "Free";
+"Свободно %@ из %@." = "%@ free of %@.";
+"Свободно на Mac %@." = "%@ free on the Mac.";
+"Свои папки в домашней" = "Your folders in Home";
+"Сейф" = "Vault";
+"Сейф закроется, как только закончится копирование (%@)." = "The vault will close as soon as copying finishes (%@).";
+"Сейф закрыт" = "Vault closed";
+"Сейф закрыт — на диске снова только шифротекст." = "Vault closed — the drive holds only ciphertext again.";
+"Сейф закрыт — откройте его паролем." = "Vault closed — open it with your password.";
+"Сейф закрыт — пароль спрошу, когда ответите «да»" = "Vault closed — I’ll ask for the password when you answer “Yes”";
+"Сейф закрыт: %@." = "Vault closed: %@.";
+"Сейф закрыт: им не пользовались %lld мин." = "Vault closed: it wasn’t used for %lld min.";
+"Сейф мал для переноса" = "Vault too small for the move";
+"Сейф не закрывается" = "The Vault Won’t Close";
+"Сейф открыт" = "Vault open";
+"Сейф открыт · свободно %@" = "Vault open · %@ free";
+"Сейф открыт — увеличить можно только закрытый." = "The vault is open — only a closed vault can be enlarged.";
+"Сейф создан: AES-256, пароль знаете только вы. Если его забыть, данные не восстановит никто — даже OffLoadAI." = "Vault created: AES-256, and only you know the password. If you forget it, no one can recover the data — not even OffLoadAI.";
+"Сейф уже может занять весь диск — увеличивать некуда." = "The vault can already take up the whole drive — there’s no room to enlarge it.";
+"Сейф — зашифрованный образ (AES-256) на внешнем диске. Пока он закрыт, на диске лежит только шифротекст: потерянный или украденный диск ничего не выдаст. Пароль OffLoadAI не хранит и не записывает — если его забыть, данные не восстановит никто." = "The vault is an encrypted disk image (AES-256) on an external drive. While it’s closed, the drive holds only ciphertext: a lost or stolen drive reveals nothing. OffLoadAI doesn’t store or record the password — if you forget it, no one can recover the data.";
+"Сейфа на диске нет" = "No vault on the drive";
+"Сейфа нет" = "No vault";
+"Сейчас %@. Увеличивается без потери содержимого." = "Currently %@. It’s enlarged without losing any contents.";
+"Сейчас идёт копирование" = "Copying in Progress";
+"Сейчас открыт %@: кеш занят. Закройте программу — и его можно будет удалить." = "%@ is open right now, so the cache is in use. Quit the app and it can be deleted.";
+"Сейчас — %@" = "Currently %@";
+"Сервер OffLoadAI" = "OffLoadAI server";
+"Сервер OffLoadAI не ответил за четыре минуты — попробуйте позже." = "The OffLoadAI server didn’t respond within four minutes — try again later.";
+"Сервер OffLoadAI ответил ошибкой %lld." = "The OffLoadAI server returned error %lld.";
+"Сервер OffLoadAI передаёт вопрос Claude и ничего не хранит. Входит в OffLoadAI Pro — ни Claude Code, ни ключа не нужно." = "The OffLoadAI server passes your question to Claude and stores nothing. Included in OffLoadAI Pro — no Claude Code or key needed.";
+"Сервер не принял ключ OffLoadAI Pro." = "The server didn’t accept the OffLoadAI Pro key.";
+"Сжато" = "Compressed";
+"Сжать" = "Compact";
+"Символических ссылок внутри: %lld. На %@ macOS хранит их в своём формате: на Mac они работают, а Windows и Linux могут увидеть вместо них обычные файлы." = "Symbolic links inside: %lld. On %@, macOS stores them in its own format: they work on a Mac, but Windows and Linux may see regular files instead.";
+"Скачанные пакеты Cargo — скачаются снова." = "Downloaded Cargo packages — they’ll be downloaded again.";
+"Скачанные пакеты Homebrew — brew скачает их снова." = "Downloaded Homebrew packages — brew will download them again.";
+"Сколько занимает каждая машина и как освободить место через UTM" = "How much space each machine takes and how to free it up with UTM";
+"Скопировано" = "Copied";
+"Скопировано %lld (%@), без изменений %lld. Секретов пропущено: %lld. Проблем: %lld." = "Copied %lld (%@), unchanged %lld. Secrets skipped: %lld. Problems: %lld.";
+"Скопировано и сверено: %lld файлов, %@. Оригинал на месте." = "Copied and verified: %lld files, %@. The original is still in place.";
+"Скопировать" = "Copy";
+"Скрытая папка приложения целиком: приложение перестанет работать. Переносите отдельные данные внутри неё." = "An entire hidden app folder: the app would stop working. Move individual data inside it instead.";
+"Слабый" = "Weak";
+"Слишком много вопросов подряд — подождите минуту." = "Too many questions in a row — wait a minute.";
+"Слишком много запросов к Claude — подождите минуту и повторите." = "Too many requests to Claude — wait a minute and try again.";
+"Сложить в сейф" = "Put in Vault";
+"Служебная папка git-репозитория. Переносите проект целиком." = "A git repository’s internal folder. Move the whole project instead.";
+"Смена пароля, копия и восстановление заголовка, увеличение и сжатие — на закрытом сейфе." = "Changing the password, backing up and restoring the header, enlarging and compacting are done on a closed vault.";
+"Сменить" = "Change";
+"Сменить пароль сейфа" = "Change Vault Password";
+"Сменить…" = "Change…";
+"Смотрит на папку и говорит, что в ней важно, что менее важно, а что мусор. Сам ничего не удаляет и не переносит — только советует, а решаете вы." = "Looks at a folder and tells you what in it matters, what matters less, and what’s junk. It never deletes or moves anything itself — it only advises, and you decide.";
+"Смотрю, есть ли сейф…" = "Checking for a vault…";
+"Смотрю, что занимает место" = "Checking what takes up space";
+"Смотрю, что на диске «%@»…" = "Checking what’s on “%@”…";
+"Снимок" = "Snapshot";
+"Снова предлагать" = "Suggest Again";
+"Со списком, записанным при переносе, сверено %lld файлов из %lld в архиве." = "%lld of %lld files in the archive verified against the list recorded during the move.";
+"Собираю, что посмотреть…" = "Gathering what to look at…";
+"Согласен — включить «%@»" = "I Agree — Turn On “%@”";
+"Содержимое не совпало с проверкой, ничего не удалено: %@. Проверьте ещё раз." = "The contents didn’t match the check, nothing was deleted: %@. Check again.";
+"Содержимое остаётся на месте, а места на диске образ занимает столько же, сколько занимал: предел лишь разрешает ему расти." = "The contents stay in place, and the image takes up as much disk space as before: the limit only allows it to grow.";
+"Создан" = "Created";
+"Создать" = "Create";
+"Создать другой сейф…" = "Create Another Vault…";
+"Создать настоящий сейф" = "Create a Real Vault";
+"Создать сейф" = "Create Vault";
+"Создать сейф…" = "Create Vault…";
+"Создать…" = "Create…";
+"Создаю сейф…" = "Creating the vault…";
+"Сообщать" = "Notify Me";
+"Сообщать о новых версиях" = "Notify about new versions";
+"Сообщать о новых версиях?" = "Notify You About New Versions?";
+"Сообщить о проблеме" = "Report a Problem";
+"Сохранить ключ" = "Save Key";
+"Сохранить сюда" = "Save Here";
+"Сохранить…" = "Save…";
+"Сохраняю копию заголовка…" = "Saving a header backup…";
+"Список контрольных сумм рядом с архивом не читается — сверить архив с его прежним состоянием не с чем. Все %lld файлов сверены с тем, что лежит на диске сейчас, и вернулись такими." = "The checksum list next to the archive can’t be read, so there’s nothing to verify the archive’s earlier state against. All %lld files were verified against what’s on the drive now and were restored as is.";
+"Спрашивать не о чем: мусора и лишнего нет, а всё крупное либо используется, либо уже на своём месте." = "Nothing to ask about: there’s no junk or clutter, and everything large is either in use or already where it belongs.";
+"Спрашиваю Docker…" = "Asking Docker…";
+"Спросить ещё раз про «%@»" = "Ask Again About “%@”";
+"Старый установщик: если программа уже стоит, его можно скачать снова." = "An old installer: if the app is already installed, it can be downloaded again.";
+"Столько похожих решений за это действие из всех похожих" = "How many similar decisions chose this action, out of all similar ones";
+"Считаю размеры…" = "Calculating sizes…";
+"Считаю размер…" = "Calculating size…";
+"Считаю, сколько занимает каждая машина…" = "Calculating how much space each machine takes…";
+"Считаю, что лежит в папке…" = "Counting what’s in the folder…";
+"Считаю, что лежит в папке… %lld из %lld" = "Counting what’s in the folder… %lld of %lld";
+"Считаю: %lld из %lld" = "Counting: %lld of %lld";
+"Считаю…" = "Counting…";
+"Съёмки 2019" = "Shoots 2019";
+"Съёмки 2020" = "Shoots 2020";
+"Съёмки 2021" = "Shoots 2021";
+"Съёмки 2022" = "Shoots 2022";
+"Сюда попадает то, что вы попросили больше не предлагать: «Что именно» у вопроса → правый щелчок по строке → «Не предлагать больше». Папка — вместе со всем, что внутри." = "This lists what you asked not to suggest again: “Details” on a question → right-click a row → “Don’t Suggest Again”. A folder is excluded along with everything inside it.";
+"ТБ" = "TB";
+"Так себе" = "Fair";
+"Такая же остаётся: %@" = "An identical copy remains: %@";
+"Текущий пароль" = "Current password";
+"Том" = "Volume";
+"Том «%@» используется контейнерами: %@." = "The volume “%@” is used by containers: %@.";
+"Том «%@» уже существует — перезаписывать не буду." = "The volume “%@” already exists — I won’t overwrite it.";
+"Тома" = "Volumes";
+"Тома Docker в сейф" = "Docker volumes to the vault";
+"Тома не трогаются: в них данные баз и проектов. Ненужные тома можно упаковать на диск кнопкой «Архивировать на диск…»." = "Volumes aren’t touched: they hold database and project data. You can pack volumes you don’t need onto a drive with the “Archive to Drive…” button.";
+"У %lld объектов есть метки Finder, комментарии или другие расширенные атрибуты. Данные и права копируются, а эти пометки — нет: после возврата их не будет." = "%lld items have Finder tags, comments, or other extended attributes. Data and permissions are copied, but these markings aren’t: they’ll be gone after restoring.";
+"У вас — %@" = "You have %@";
+"У последнего выпуска на GitHub непонятный номер версии." = "The latest release on GitHub has an unrecognized version number.";
+"У сборки для разработки нет номера версии. Последний выпуск — %@." = "A development build has no version number. The latest release is %@.";
+"Убрано в сейф: %@, %lld из %lld" = "Moved to the vault: %@, %lld of %lld";
+"Убрать в сейф" = "Move to Vault";
+"Убрать в сейф %@" = "Move %@ to Vault";
+"Убрать в сейф крупное и старое?" = "Move large, old items to the vault?";
+"Убрать из бэкапа" = "Remove from Backup";
+"Убрать ключ" = "Remove Key";
+"Убрать ключ с этого Mac" = "Remove Key from This Mac";
+"Увеличиваю сейф…" = "Enlarging the vault…";
+"Увеличить" = "Enlarge";
+"Увеличить предел сейфа" = "Raise Vault Size Limit";
+"Увеличить предел…" = "Raise Limit…";
+"Увеличить…" = "Enlarge…";
+"Удаление оригинала" = "Deleting the original";
+"Удалено из Корзины насовсем: %lld, %@." = "Permanently deleted from the Trash: %lld, %@.";
+"Удалить" = "Delete";
+"Удалить из Корзины насовсем?" = "Permanently Delete from the Trash?";
+"Удалить кеш сборки, неиспользуемые образы и остановленные контейнеры. Тома не трогаются." = "Delete the build cache, unused images, and stopped containers. Volumes aren’t touched.";
+"Удалить копии" = "Delete Copies";
+"Удалить лишние копии?" = "Delete duplicates?";
+"Удалить мусор?" = "Delete junk?";
+"Удалить насовсем" = "Delete Permanently";
+"Удалить насовсем %@?" = "Permanently Delete %@?";
+"Удалить насовсем…" = "Delete Permanently…";
+"Удалить ненужную" = "Delete the ones you don’t need";
+"Удалить оригинал не получится: внутри папки только для чтения или защищённые файлы (%@). Перенести можно только копией, оставив оригинал на месте." = "The original can’t be deleted: the folder contains read-only or locked files (%@). It can only be copied, leaving the original in place.";
+"Удалить оригинал после сверки" = "Delete the original after verification";
+"Удалить старые установщики?" = "Delete old installers?";
+"Удаляемое уходит в Корзину — вернуть можно одной кнопкой у своего вопроса, а в конце можно удалить это из Корзины насовсем. Образы Docker удаляет сам Docker, он скачает их снова. В сейф — со сверкой каждого файла; вернуть можно в «Перенесённом»." = "Deleted items go to the Trash — you can put them back with one button on their question, and at the end you can delete them from the Trash permanently. Docker images are deleted by Docker itself, and it will download them again. Items go to the vault with every file verified; you can restore them in “Moved Items”.";
+"Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это можно убрать в сейф." = "OffLoadAI only allows deleting things that get recreated, and old installers. This can be moved to the vault.";
+"Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это — в сейф: оригинал исчезнет, только когда копия сверена." = "OffLoadAI only allows deleting things that get recreated, and old installers. This goes to the vault: the original is removed only once the copy is verified.";
+"Удалять было нечего: всё выбранное сейчас используется." = "There was nothing to delete: everything selected is in use right now.";
+"Удалять и переносить машины нужно в самом UTM" = "Delete and move machines in UTM itself";
+"Удаляю из Корзины…" = "Deleting from the Trash…";
+"Удалённое лежит в Корзине и занимает место, пока её не очистят, — ниже можно удалить это насовсем." = "Deleted items stay in the Trash and take up space until it’s emptied — you can delete them permanently below.";
+"Удалённые открытые копии физически могут оставаться в памяти SSD или флешки, пока контроллер их не перезапишет. Полную гарантию даёт только диск, зашифрованный целиком." = "Deleted unencrypted copies may physically remain in an SSD’s or flash drive’s memory until the controller overwrites them. Only a fully encrypted drive gives a complete guarantee.";
+"Ужать диск машины QEMU" = "Shrink a QEMU machine’s disk";
+"Упаковать в сейф и убрать из Docker" = "Pack into the Vault and Remove from Docker";
+"Упаковать выбранные тома и убрать их из Docker" = "Pack Selected Volumes and Remove Them from Docker";
+"Упаковать на «%@» открыто и убрать из Docker" = "Pack onto “%@” Unencrypted and Remove from Docker";
+"Упаковка тома" = "Packing the volume";
+"Установлена последняя версия — %@." = "You have the latest version — %@.";
+"Установщик уже поставленной программы." = "An installer for an app that’s already installed.";
+"Установщики" = "Installers";
+"Учусь только на этом Mac и только на ваших ответах. Что вы вернули из Корзины, больше не предлагаю; похожее на то, что вы обычно убираете в сейф, добавляю в вопрос о сейфе. Сам ничего не делаю — только спрашиваю." = "I learn only on this Mac and only from your answers. What you put back from the Trash, I don’t suggest again; things similar to what you usually move to the vault, I add to the vault question. I never do anything on my own — I only ask.";
+"Файл копии заголовка (.offload-header)" = "Header backup file (.offload-header)";
+"Файл медиатеки в ~/%@: приложение найдёт его только на этом месте." = "A library file in ~/%@: the app will only find it in this location.";
+"Файл, в котором записан пароль хранилища" = "The file that holds the repository password";
+"Файлов слишком много, проверка не закончена — переносите по частям." = "Too many files, the check didn’t finish — move them in parts.";
+"Файлов, на которые ведёт несколько имён (жёсткие ссылки): %lld. В копии каждое имя станет отдельным файлом: места займёт больше, а правка одного больше не будет видна в остальных." = "Files with several names pointing to them (hard links): %lld. In the copy, each name becomes a separate file: it will take more space, and editing one will no longer show up in the others.";
+"Файлы" = "Files";
+"Файлы для отладки на iPhone — Xcode скачает их снова, когда понадобятся." = "Files for debugging on iPhone — Xcode will download them again when needed.";
+"Файлы сейчас открыты: %@. Закройте приложение и повторите." = "Files are open right now: %@. Quit the app and try again.";
+"Файлы, удалённые или возвращённые из сейфа, продолжают занимать место на диске: образ сам не уменьшается, хотя внутри это место идёт под новые данные. Сжатие отдаёт диску полностью пустые участки образа. На больших сейфах macOS может не найти таких участков — тогда вернётся мало или ничего, и OffLoadAI так и скажет." = "Files deleted or restored from the vault keep taking up space on the drive: the image doesn’t shrink by itself, though that space is reused for new data inside. Compacting gives completely empty areas of the image back to the drive. On large vaults macOS may not find such areas — then little or nothing is reclaimed, and OffLoadAI will tell you so.";
+"Фильмы" = "Movies";
+"Фото с дачи.zip" = "Country house photos.zip";
+"Хороший" = "Good";
+"Хранилище" = "Repository";
+"Храните отдельно от диска: без заголовка сейф не откроется." = "Keep it separate from the drive: without the header, the vault won’t open.";
+"Часть git-репозитория «%@». Переносите и сохраняйте проект целиком." = "Part of the git repository “%@”. Move and back up the whole project.";
+"Чему научился" = "What I’ve Learned";
+"Читаю «%@»…" = "Reading “%@”…";
+"Что бэкапить" = "What to Back Up";
+"Что в снимке" = "Snapshot Contents";
+"Что защищено, а что нет" = "What’s Protected and What Isn’t";
+"Что именно — %lld" = "Details — %lld";
+"Что лежит на внешнем диске" = "What’s on the External Drive";
+"Что нового" = "What’s New";
+"Что нового и как обновиться" = "What’s New and How to Update";
+"Что стоит сделать" = "What to Do Next";
+"Что уходит помощнику" = "What Goes to the Assistant";
+"Чтобы обновиться, вставьте эту команду в Терминал. Она скачает новую версию, сверит её и поставит на место этой. Если идёт копирование, OffLoadAI сначала спросит, прервать ли его. Настройки, ключ Pro, журнал и сейф останутся как были." = "To update, paste this command into Terminal. It downloads the new version, verifies it and puts it in place of this one. If a copy is in progress, OffLoadAI will first ask whether to stop it. Your settings, Pro key, log and vault stay as they are.";
+"Шифрование" = "Encryption";
+"Шифрование образа на диске не подтверждается — класть в него нельзя. Разберитесь в разделе «Сейф»." = "The disk image’s encryption can’t be confirmed, so nothing can be put in it. Check the “Vault” section.";
+"Шифрование образа не подтверждается" = "Disk image encryption can’t be confirmed";
+"Шифрование образа не подтверждено — складывать в него ключи нельзя." = "The disk image’s encryption isn’t confirmed, so keys can’t be stored in it.";
+"Эти папки восстанавливаются одной командой (npm install, pip install, сборка). Файлы с ключами и токенами — .env, .envrc, *.pem, *.key, *.p8, id_ed25519 — и папки .ssh, .gnupg, .aws в бэкап проектов не попадают никогда: для них раздел «Ключи и токены» ниже, и лежат они только в сейфе." = "These folders can be rebuilt with one command (npm install, pip install, a build). Files with keys and tokens — .env, .envrc, *.pem, *.key, *.p8, id_ed25519 — and the .ssh, .gnupg and .aws folders never go into the project backup: they have their own “Keys and Tokens” section below and are kept only in the vault.";
+"Это не похоже на ключ OffLoadAI Pro. Скопируйте его целиком, вместе с «OFFLOAD-»." = "This doesn’t look like an OffLoadAI Pro key. Copy the whole key, including “OFFLOAD-”.";
+"Этот раздел нужен, только если вы пользуетесь Docker." = "You only need this section if you use Docker.";
+"Этот сейф ограничен %@: для ключей хватит, а для переноса больших папок — нет. Предел можно увеличить — содержимое останется на месте." = "This vault is limited to %@: enough for keys, but not for moving large folders. You can raise the size limit — the contents stay in place.";
+"архив должен лежать на внешнем диске, в /Volumes" = "the archive must be on an external drive, in /Volumes";
+"архив не найден — подключите диск «%@»" = "archive not found — connect the drive “%@”";
+"архив уже в сейфе" = "the archive is already in the vault";
+"архивы" = "archives";
+"база закрыта" = "database is closed";
+"без объяснений" = "no explanation";
+"больше 10 ГБ" = "over 10 GB";
+"в Anthropic (Claude) — по вашему ключу API." = "to Anthropic (Claude), using your API key.";
+"в Anthropic (Claude) — через Claude Code на этом Mac, под вашей учётной записью Claude." = "to Anthropic (Claude), through Claude Code on this Mac, under your Claude account.";
+"в «~/%@»" = "in “~/%@”";
+"в «Библиотеке»" = "in Library";
+"в «Документах»" = "in Documents";
+"в «Загрузках»" = "in Downloads";
+"в «Изображениях»" = "in Pictures";
+"в «Музыке»" = "in Music";
+"в «Фильмах»" = "in Movies";
+"в Корзине" = "in the Trash";
+"в Корзину" = "to Trash";
+"в бэкап" = "to backup";
+"в образе не нашёлся раздел с файловой системой" = "no partition with a file system was found in the disk image";
+"в сейф" = "to the vault";
+"в сейфе" = "in the vault";
+"в сейфе %lld · открыто %lld" = "%lld in the vault · %lld open";
+"в сейфе «%@»" = "in the vault “%@”";
+"в сейфе остался отложенный заголовок от прерванного восстановления (token.offload-previous). Если сейф не открывается, верните его на место token вручную" = "an interrupted restore left a set-aside header in the vault (token.offload-previous). If the vault won’t open, put it back in place of token manually";
+"весь диск (%@)" = "whole drive (%@)";
+"видео" = "videos";
+"внутри не APFS (%@) — такой сейф OffLoadAI не растягивает. Создайте новый сейф нужного размера и перенесите содержимое." = "the file system inside isn’t APFS (%@), so OffLoadAI can’t enlarge this vault. Create a new vault of the size you need and move the contents into it.";
+"возвращено на Mac" = "restored to the Mac";
+"возвращено на место: %lld" = "put back: %lld";
+"вопрос" = "question";
+"вопроса" = "questions";
+"вопросов" = "questions";
+"готово %lld из %lld" = "%lld of %lld done";
+"групп" = "groups";
+"группа" = "group";
+"группы" = "groups";
+"давление памяти" = "memory pressure";
+"день" = "day";
+"дней" = "days";
+"дня" = "days";
+"добавлено в бэкап" = "added to backup";
+"добавляете в бэкап" = "add to the backup";
+"документы" = "documents";
+"занимает на диске" = "used on disk";
+"зашифровано · свободно %@" = "encrypted · %@ free";
+"и ещё %lld" = "and %lld more";
+"имя папки или файла" = "folder or file name";
+"ищу…" = "searching…";
+"как в прошлый раз" = "same as last time";
+"как вы обычно" = "as you usually do";
+"кеш сборки (%@)" = "build cache (%@)";
+"класть в него нельзя" = "can’t be used";
+"копии архивов" = "archive copies";
+"копии видео" = "video copies";
+"копии документов" = "document copies";
+"копии музыки и звука" = "music and audio copies";
+"копии образов дисков и установщиков" = "copies of disk images and installers";
+"копии файлов" = "file copies";
+"копии фото и картинок" = "photo and image copies";
+"копия не подошла, а прежний заголовок вернуть не удалось: %@. Он лежит в token.offload-previous внутри сейфа — переименуйте его в token" = "the copy didn’t match, and the previous header couldn’t be put back: %@. It’s in token.offload-previous inside the vault — rename it to token";
+"копия снята с другого сейфа" = "this copy was made from a different vault";
+"лежит на внешних дисках" = "on external drives";
+"лишние копии" = "duplicates";
+"лишних копий" = "duplicates";
+"лишняя копия" = "duplicate";
+"машин" = "machines";
+"машина" = "machine";
+"машины" = "machines";
+"меньше 100 МБ" = "under 100 MB";
+"менялась %@" = "modified %@";
+"менялись 1–3 месяца назад" = "modified 1–3 months ago";
+"менялись в последний месяц" = "modified in the last month";
+"можно убрать" = "can be cleared";
+"музыка и звук" = "music and audio";
+"на Mac осталось %@." = "only %@ left on the Mac.";
+"на «%@»" = "on “%@”";
+"на «Рабочем столе»" = "on the Desktop";
+"на диске нет «%@»" = "“%@” isn’t on the drive";
+"на сервер OffLoadAI, а он передаёт их Claude (Anthropic). Вместе с ними уходит ваш ключ OffLoadAI Pro — в нём номер ключа и имя, которое вы назвали при покупке." = "to the OffLoadAI server, which passes them to Claude (Anthropic). Your OffLoadAI Pro key goes with them — it contains the key number and the name you gave at purchase.";
+"не всё доступно" = "partly accessible";
+"не выбрано" = "not selected";
+"не закрывать" = "never";
+"не зашифровано · свободно %@" = "not encrypted · %@ free";
+"не менялись больше года" = "not modified for over a year";
+"не менялись от 3 месяцев до года" = "not modified for 3 months to a year";
+"не открывается" = "can’t be opened";
+"не подключены" = "not attached";
+"не удалось закрыть сейф от других пользователей этого Mac (chmod 700)" = "couldn’t lock the vault against other users of this Mac (chmod 700)";
+"неизвестно" = "unknown";
+"нет доступа" = "no access";
+"никуда: их читает модель в Ollama на этом Mac, в интернет они не уходят." = "nowhere: a model in Ollama on this Mac reads them, and they never go to the internet.";
+"образ остался подключённым как %@. Отключите его: hdiutil detach -force %@" = "the disk image is still attached as %@. Detach it: hdiutil detach -force %@";
+"образы без имени — остатки пересборок" = "unnamed images — leftovers from rebuilds";
+"образы дисков и установщики" = "disk images and installers";
+"объект" = "item";
+"объекта" = "items";
+"объектов" = "items";
+"обычно %@" = "you usually %@";
+"окно #%lld «%@» %lld×%lld видимо=%@ класс=%@\n" = "window #%lld “%@” %lld×%lld visible=%@ class=%@\n";
+"окно «OffLoadAI Pro» не нашлось" = "the “OffLoadAI Pro” window wasn’t found";
+"оставить" = "keep";
+"оставляете" = "keep";
+"остальное остановлено" = "the rest was stopped";
+"от 1 до 10 ГБ" = "1 to 10 GB";
+"от 100 МБ до 1 ГБ" = "100 MB to 1 GB";
+"открыто на «%@»" = "open on “%@”";
+"открыто на диске" = "open on the drive";
+"ошибка" = "error";
+"ошибка %lld." = "error %lld.";
+"папка" = "folder";
+"папки" = "folders";
+"папок" = "folders";
+"пароль к этой копии не подходит" = "the password doesn’t match this copy";
+"перенесённый объект" = "moved item";
+"перенесённых объекта" = "moved items";
+"перенесённых объектов" = "moved items";
+"перенос невозможен" = "can’t be moved";
+"по вашей команде" = "at your request";
+"пока шёл возврат, в архиве что-то изменилось, и вернулось не всё новое." = "something in the archive changed during the restore, so not all new changes came back.";
+"полный объём дисков %@" = "full disk size %@";
+"после замера" = "after measuring";
+"предел роста" = "size limit";
+"проверка насчитала файлов %lld, папок %lld, ссылок %lld, а обход — %lld, %lld и %lld" = "the check counted %lld files, %lld folders and %lld links, but the scan found %lld, %lld and %lld";
+"проекты с git" = "git projects";
+"пути должны быть абсолютными и без «..»" = "paths must be absolute and must not contain “..”";
+"путь к архиву проходит через символическую ссылку" = "the path to the archive goes through a symbolic link";
+"раздел %@" = "section %@";
+"раздел занимает %lld байт из %lld. " = "the partition takes %lld of %lld bytes. ";
+"разработка" = "development";
+"свободно %@" = "%@ free";
+"свободно внутри" = "free inside";
+"свободно из %@" = "free of %@";
+"сейф не открыт" = "the vault isn’t open";
+"символическая ссылка не создана: %@" = "symbolic link not created: %@";
+"сменился пользователь" = "user switched";
+"снимка" = "snapshots";
+"снимков" = "snapshots";
+"снимок" = "snapshot";
+"снимок %@" = "snapshot %@";
+"убираете в сейф" = "move to the vault";
+"убрано в сейф" = "moved to the vault";
+"удалено" = "deleted";
+"удалит Docker" = "deleted by Docker";
+"удаляете" = "delete";
+"уменьшать сейф нельзя — только увеличивать" = "a vault can’t be shrunk, only enlarged";
+"файл" = "file";
+"файл не похож на копию заголовка OffLoadAI" = "the file doesn’t look like an OffLoadAI header copy";
+"файла" = "files";
+"файлов" = "files";
+"файлы" = "files";
+"фото и картинки" = "photos and images";
+"экран заблокирован" = "screen locked";
+"✓ %@ Диск Docker на Mac уменьшился на %@." = "✓ %@ Docker’s disk on the Mac shrank by %@.";
+"✓ %@ Место на Mac Docker вернёт чуть позже — иногда только после перезапуска Docker Desktop." = "✓ %@ Docker will free the space on the Mac a bit later — sometimes only after Docker Desktop restarts.";
+"✓ «%@» упакован в %@ (%@), сверен и убран из Docker." = "✓ “%@” packed into %@ (%@), verified and removed from Docker.";
+"✓ Том «%@» восстановлен из %@ и сверен. Архив остался на диске." = "✓ Volume “%@” restored from %@ and verified. The archive stays on the drive.";
+"✗ «%@»: %@" = "✗ “%@”: %@";
diff --git a/Localization/ru.lproj/InfoPlist.strings b/Localization/ru.lproj/InfoPlist.strings
new file mode 100644
index 0000000..108daba
--- /dev/null
+++ b/Localization/ru.lproj/InfoPlist.strings
@@ -0,0 +1,6 @@
+/* Запросы доступа macOS — по-русски (основной Info.plist тоже на русском, но язык разработки — английский). */
+"NSDocumentsFolderUsageDescription" = "OffLoadAI показывает, что занимает место в папке «Документы», и переносит выбранное на внешний диск.";
+"NSDesktopFolderUsageDescription" = "OffLoadAI показывает, что занимает место на рабочем столе, и переносит выбранное на внешний диск.";
+"NSDownloadsFolderUsageDescription" = "OffLoadAI показывает, что занимает место в «Загрузках», и переносит выбранное на внешний диск.";
+"NSRemovableVolumesUsageDescription" = "OffLoadAI переносит данные и делает бэкап на внешний диск.";
+"NSNetworkVolumesUsageDescription" = "OffLoadAI может переносить данные на сетевой диск.";
diff --git a/Localization/ru.lproj/Localizable.strings b/Localization/ru.lproj/Localizable.strings
new file mode 100644
index 0000000..eb18683
--- /dev/null
+++ b/Localization/ru.lproj/Localizable.strings
@@ -0,0 +1,2 @@
+/* Русский — язык, на котором написан интерфейс: ключи и есть русский текст, переводить нечего.
+ Файл нужен, чтобы macOS знала, что русский у программы есть, и выбирала его русским пользователям. */
diff --git a/README.md b/README.md
index b8c268d..96fc162 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,12 @@ XCTest и swift-testing входят только в Xcode, поэтому пр
Переменные `OFFLOAD_SKIP_INTEGRATION=1` и `OFFLOAD_SKIP_DOCKER=1` отключают соответствующие части.
+### Переводы
+
+Интерфейс Mac — на русском и английском, язык выбирает macOS: русским — русский, всем остальным — английский. Русский текст в коде и есть ключ перевода: `Text("…")`, `tr("…")` для строк, которые собираются в коде, `trDynamic(…)` для значений вроде `rawValue`. Английский — в `Localization/en.lproj/Localizable.strings`. `scripts/check-l10n.sh` собирает ключи компилятором и проверяет, что у каждого есть перевод с теми же форматами (`%@`, `%lld`); его гоняет CI. Имена файлов на диске, шаблоны поиска и текст для модели помощника не переводятся.
+
+Запустить на другом языке, не меняя системный: `dist/OffLoadAI.app/Contents/MacOS/OffLoadAI -AppleLanguages '(en)'`.
+
### Снимки экранов
Для проверки интерфейса и скриншотов в README у приложения есть режим снимков: оно проходит по всем разделам, сохраняет каждый в PNG и завершается. Работает только вместе с демонстрационным режимом (`OFFLOAD_DEMO=1`, см. ниже): иначе любая программа могла бы запустить OffLoadAI с полным доступом к диску и получить снимки ваших настоящих папок.
@@ -258,4 +264,4 @@ Install:
curl -fsSL https://raw.githubusercontent.com/audit0/Offload/main/scripts/install.sh | zsh
```
-Build from source with Xcode Command Line Tools: `swift run OffloadChecks`, then `scripts/install-local.sh`. With Command Line Tools 27+ and no Xcode, plain `swift build` cannot build the SwiftUI layer (the `@State` macro plugin ships with Xcode); `scripts/build-app.sh` falls back to the SDK 26 that ships with the same tools. A Windows version (WPF, .NET 10, BitLocker-encrypted VHDX vault) lives in `windows/`; install it with `irm https://raw.githubusercontent.com/audit0/Offload/main/windows/scripts/install.ps1 | iex`. The UI is in Russian. See [SECURITY.md](SECURITY.md) for the threat model. MIT licensed.
+Build from source with Xcode Command Line Tools: `swift run OffloadChecks`, then `scripts/install-local.sh`. With Command Line Tools 27+ and no Xcode, plain `swift build` cannot build the SwiftUI layer (the `@State` macro plugin ships with Xcode); `scripts/build-app.sh` falls back to the SDK 26 that ships with the same tools. A Windows version (WPF, .NET 10, BitLocker-encrypted VHDX vault) lives in `windows/`; install it with `irm https://raw.githubusercontent.com/audit0/Offload/main/windows/scripts/install.ps1 | iex`. The Mac UI is in English and Russian and follows the system language (Russian for Russian, English for everyone else); the Windows UI is in Russian for now. See [SECURITY.md](SECURITY.md) for the threat model. MIT licensed.
diff --git a/Sources/Offload/AppModel.swift b/Sources/Offload/AppModel.swift
index ed7d095..30d8f43 100644
--- a/Sources/Offload/AppModel.swift
+++ b/Sources/Offload/AppModel.swift
@@ -11,14 +11,14 @@ enum SidebarSection: String, CaseIterable, Identifiable, Hashable {
var title: String {
switch self {
- case .overview: return "Обзор"
- case .cleanup: return "Разобрать"
- case .assistant: return "Помощник"
- case .safe: return "Сейф"
- case .space: return "Освободить место"
- case .history: return "Перенесённое"
- case .backup: return "Бэкап"
- case .icloud: return "Из iCloud"
+ case .overview: return tr("Обзор")
+ case .cleanup: return tr("Разобрать")
+ case .assistant: return tr("Помощник")
+ case .safe: return tr("Сейф")
+ case .space: return tr("Освободить место")
+ case .history: return tr("Перенесённое")
+ case .backup: return tr("Бэкап")
+ case .icloud: return tr("Из iCloud")
case .docker: return "Docker"
}
}
@@ -43,7 +43,7 @@ enum StoreMode: String, CaseIterable, Identifiable {
case safe, open
var id: Self { self }
- var title: String { self == .safe ? "В сейф" : "Открыто на диск" }
+ var title: String { self == .safe ? tr("В сейф") : tr("Открыто на диск") }
}
@MainActor
@@ -99,9 +99,9 @@ final class AppModel {
/// Почему класть некуда — одной фразой, с тем, что сделать.
var targetProblem: String? {
- guard destination != nil else { return "Подключите внешний диск." }
+ guard destination != nil else { return tr("Подключите внешний диск.") }
guard storeMode == .safe, safeVolume == nil else { return nil }
- return safe.exists ? "Сейф закрыт — откройте его паролем." : "На диске нет сейфа — создайте его в разделе «Сейф»."
+ return safe.exists ? tr("Сейф закрыт — откройте его паролем.") : tr("На диске нет сейфа — создайте его в разделе «Сейф».")
}
/// Где искать журналы переносов: подключённые диски и открытый сейф.
@@ -250,6 +250,8 @@ func relativeToHome(_ path: String, home: URL) -> String {
/// 1 объект, 3 объекта, 5 объектов.
func pluralRu(_ count: Int, _ one: String, _ few: String, _ many: String) -> String {
+ // По-английски форм две: перевод «одного» для 1 и перевод «многих» для остального.
+ if !AppLanguage.isRussian { return abs(count) == 1 ? trDynamic(one) : trDynamic(many) }
let hundreds = abs(count) % 100
let tens = hundreds % 10
if (11...14).contains(hundreds) { return many }
diff --git a/Sources/Offload/AssistantModel.swift b/Sources/Offload/AssistantModel.swift
index 1f23355..952c323 100644
--- a/Sources/Offload/AssistantModel.swift
+++ b/Sources/Offload/AssistantModel.swift
@@ -19,28 +19,28 @@ final class AssistantModel {
var title: String {
switch self {
case .claudeCode: return "Claude Code"
- case .apiKey: return "Ключ API"
- case .local: return "На этом Mac"
- case .server: return "Сервер OffLoadAI"
+ case .apiKey: return tr("Ключ API")
+ case .local: return tr("На этом Mac")
+ case .server: return tr("Сервер OffLoadAI")
}
}
var detail: String {
switch self {
- case .claudeCode: return "Claude Code, установленный на этом Mac, под вашей учётной записью Claude. Ключ не нужен."
- case .apiKey: return "Ваш ключ Anthropic API: платите по счёту API за каждый вопрос. Ключ хранится в связке ключей macOS."
- case .local: return "Модель в Ollama на этом Mac: сведения о файлах не покидают его. Медленнее и проще, чем Claude."
- case .server: return "Сервер OffLoadAI передаёт вопрос Claude и ничего не хранит. Входит в OffLoadAI Pro — ни Claude Code, ни ключа не нужно."
+ case .claudeCode: return tr("Claude Code, установленный на этом Mac, под вашей учётной записью Claude. Ключ не нужен.")
+ case .apiKey: return tr("Ваш ключ Anthropic API: платите по счёту API за каждый вопрос. Ключ хранится в связке ключей macOS.")
+ case .local: return tr("Модель в Ollama на этом Mac: сведения о файлах не покидают его. Медленнее и проще, чем Claude.")
+ case .server: return tr("Сервер OffLoadAI передаёт вопрос Claude и ничего не хранит. Входит в OffLoadAI Pro — ни Claude Code, ни ключа не нужно.")
}
}
/// Куда уходят сведения о файлах — для согласия: у каждого варианта своё.
var destination: String {
switch self {
- case .claudeCode: return "в Anthropic (Claude) — через Claude Code на этом Mac, под вашей учётной записью Claude."
- case .apiKey: return "в Anthropic (Claude) — по вашему ключу API."
- case .local: return "никуда: их читает модель в Ollama на этом Mac, в интернет они не уходят."
- case .server: return "на сервер OffLoadAI, а он передаёт их Claude (Anthropic). Вместе с ними уходит ваш ключ OffLoadAI Pro — в нём номер ключа и имя, которое вы назвали при покупке."
+ case .claudeCode: return tr("в Anthropic (Claude) — через Claude Code на этом Mac, под вашей учётной записью Claude.")
+ case .apiKey: return tr("в Anthropic (Claude) — по вашему ключу API.")
+ case .local: return tr("никуда: их читает модель в Ollama на этом Mac, в интернет они не уходят.")
+ case .server: return tr("на сервер OffLoadAI, а он передаёт их Claude (Anthropic). Вместе с ними уходит ваш ключ OffLoadAI Pro — в нём номер ключа и имя, которое вы назвали при покупке.")
}
}
}
@@ -116,7 +116,7 @@ final class AssistantModel {
func saveApiKey(_ key: String?) {
guard !Demo.isOn else { return }
if !AssistantKeychain.save(key) {
- error = "Не удалось сохранить ключ в связке ключей macOS."
+ error = tr("Не удалось сохранить ключ в связке ключей macOS.")
} else {
error = nil
}
@@ -166,7 +166,7 @@ final class AssistantModel {
done = [:]
trashed = [:]
stage = .scanning
- status = "Считаю, что лежит в папке…"
+ status = tr("Считаю, что лежит в папке…")
let rules = app.rules
let asked = provider(app: app)
let previews = sendsPreviews
@@ -185,14 +185,14 @@ final class AssistantModel {
let count = found.all.count
Task { @MainActor in
guard self.stage == .scanning else { return }
- self.status = "Считаю, что лежит в папке… \(count) из \(total)"
+ self.status = tr("Считаю, что лежит в папке… \(count) из \(total)")
}
}
measured = found.all
}
try Task.checkCancellation()
guard !measured.isEmpty else {
- fail("Папка пуста — разбирать нечего.")
+ fail(tr("Папка пуста — разбирать нечего."))
return
}
let picked = AssistantFacts.pick(measured)
@@ -206,7 +206,7 @@ final class AssistantModel {
}.value
try Task.checkCancellation()
stage = .thinking
- status = "Помощник смотрит \(facts.count) \(pluralRu(facts.count, "объект", "объекта", "объектов"))…"
+ status = tr("Помощник смотрит \(facts.count) \(pluralRu(facts.count, tr("объект"), tr("объекта"), tr("объектов")))…")
let trimmed = question.trimmingCharacters(in: .whitespacesAndNewlines)
answer = Demo.isOn ? Self.demoAnswer(facts) : try await asked.ask(facts, question: trimmed.isEmpty ? nil : trimmed)
stage = .done
@@ -231,14 +231,14 @@ final class AssistantModel {
/// В Корзину — вернуть можно здесь же или из Корзины, пока её не очистили. Правила OffLoadAI проверяются ещё раз:
/// помощник мог ошибиться, а объект — измениться с тех пор. Ответ — что помешало (nil — получилось).
func trash(_ id: String, app: AppModel) async -> String? {
- guard let item = item(id) else { return "Объект не найден." }
+ guard let item = item(id) else { return tr("Объект не найден.") }
if Demo.isOn {
- markDone(id, "в Корзине")
+ markDone(id, tr("в Корзине"))
return nil
}
let verdict = app.rules.pathVerdict(for: item.url)
guard verdict == .safe else {
- return "Правила OffLoadAI не дают отправить это в Корзину: " + verdict.notes.joined(separator: " ")
+ return tr("Правила OffLoadAI не дают отправить это в Корзину: ") + verdict.notes.joined(separator: " ")
}
let url = item.url
let home = app.rules.home
@@ -248,19 +248,19 @@ final class AssistantModel {
let current = SpaceItem(url: url, bytes: item.bytes, modified: modified, isDirectory: item.isDirectory,
accessDenied: item.accessDenied, verdict: verdict, isMeasured: item.isMeasured)
guard await Task.detached(priority: .userInitiated, operation: { AssistantTrash.current(home: home).allows(current) }).value else {
- return "Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это можно убрать в сейф."
+ return tr("Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это можно убрать в сейф.")
}
// Как и в «Разобрать»: то, что сейчас открыто в программе, на ходу не удаляем.
if let holders = await Task.detached(priority: .userInitiated, operation: { SafeMover.openFiles(in: url) }).value,
!holders.isEmpty {
- return "«\(url.lastPathComponent)» сейчас использует \(holders.prefix(3).joined(separator: ", ")). Закройте программу и повторите."
+ return tr("«\(url.lastPathComponent)» сейчас использует \(holders.prefix(3).joined(separator: ", ")). Закройте программу и повторите.")
}
do {
let trashedAt = try await Task.detached(priority: .userInitiated) { try CleanupModel.trash(url) }.value
if let trashedAt {
trashed[id] = CleanupModel.TrashedItem(original: url, inTrash: trashedAt.url, bytes: item.bytes, identity: trashedAt.identity)
}
- markDone(id, "в Корзине")
+ markDone(id, tr("в Корзине"))
app.space.invalidateAll()
return nil
} catch {
@@ -270,9 +270,9 @@ final class AssistantModel {
/// Вернуть из Корзины на прежнее место то, что туда отправил совет. Ответ — что помешало (nil — получилось).
func putBack(_ id: String, app: AppModel) async -> String? {
- guard let item = trashed[id] else { return "В Корзине его уже нет." }
+ guard let item = trashed[id] else { return tr("В Корзине его уже нет.") }
let (back, problems) = await CleanupModel.putBack([item])
- guard !back.isEmpty else { return problems.first ?? "Вернуть не получилось." }
+ guard !back.isEmpty else { return problems.first ?? tr("Вернуть не получилось.") }
trashed[id] = nil
done[id] = nil
app.space.invalidateAll()
@@ -290,10 +290,10 @@ final class AssistantModel {
verdict: .safe, isMeasured: true)
}
return [
- item("Отпуск 2023 (1).mp4", 4.1, daysAgo: 380), item("Фото с дачи.zip", 2.3, daysAgo: 410), item("Xcode_16.4.xip", 5.8, daysAgo: 290),
+ item(tr("Отпуск 2023 (1).mp4"), 4.1, daysAgo: 380), item(tr("Фото с дачи.zip"), 2.3, daysAgo: 410), item("Xcode_16.4.xip", 5.8, daysAgo: 290),
item("temp-export", 0.8, daysAgo: 200, isFolder: true), item("node-v22.11.0.pkg", 0.03, daysAgo: 320),
- item("googlechrome.dmg", 0.23, daysAgo: 500), item("Договор аренды 2026.pdf", 0.002, daysAgo: 40),
- item("Выписка ЕГРН.pdf", 0.001, daysAgo: 95),
+ item("googlechrome.dmg", 0.23, daysAgo: 500), item(tr("Договор аренды 2026.pdf"), 0.002, daysAgo: 40),
+ item(tr("Выписка ЕГРН.pdf"), 0.001, daysAgo: 95),
]
}
@@ -301,21 +301,21 @@ final class AssistantModel {
static func demoAnswer(_ facts: [FileFact]) -> AssistantAnswer {
let advice = facts.map { fact -> Advice in
switch (fact.path as NSString).lastPathComponent {
- case "Отпуск 2023 (1).mp4":
- return Advice(id: fact.id, importance: .minor, action: .safe, reason: "Видео из отпуска с «(1)» в имени — похоже на повторную загрузку; сохранить стоит, но не на диске Mac.")
- case "Фото с дачи.zip":
- return Advice(id: fact.id, importance: .minor, action: .safe, reason: "Архив с личными фото: нужен, но редко — место ему в сейфе.")
+ case tr("Отпуск 2023 (1).mp4"):
+ return Advice(id: fact.id, importance: .minor, action: .safe, reason: tr("Видео из отпуска с «(1)» в имени — похоже на повторную загрузку; сохранить стоит, но не на диске Mac."))
+ case tr("Фото с дачи.zip"):
+ return Advice(id: fact.id, importance: .minor, action: .safe, reason: tr("Архив с личными фото: нужен, но редко — место ему в сейфе."))
case "Xcode_16.4.xip":
- return Advice(id: fact.id, importance: .junk, action: .trash, reason: "Архив установки Xcode: скачивается заново с сайта Apple.")
+ return Advice(id: fact.id, importance: .junk, action: .trash, reason: tr("Архив установки Xcode: скачивается заново с сайта Apple."))
case "temp-export":
- return Advice(id: fact.id, importance: .minor, action: .safe, reason: "Временная выгрузка, которую давно не открывали: в сейфе она не мешает, а понадобится — вернёте.")
+ return Advice(id: fact.id, importance: .minor, action: .safe, reason: tr("Временная выгрузка, которую давно не открывали: в сейфе она не мешает, а понадобится — вернёте."))
case "node-v22.11.0.pkg", "googlechrome.dmg":
- return Advice(id: fact.id, importance: .junk, action: .trash, reason: "Установщик уже поставленной программы.")
+ return Advice(id: fact.id, importance: .junk, action: .trash, reason: tr("Установщик уже поставленной программы."))
default:
- return Advice(id: fact.id, importance: .important, action: .keep, reason: "Личный документ — оставить на месте.")
+ return Advice(id: fact.id, importance: .important, action: .keep, reason: tr("Личный документ — оставить на месте."))
}
}
- return AssistantAnswer(summary: "В «Загрузках» около 6 ГБ мусора — архив Xcode и установщики. Видео, архив с фото и старую выгрузку лучше убрать в сейф, документы оставить.",
- items: advice, provider: "Демонстрация")
+ return AssistantAnswer(summary: tr("В «Загрузках» около 6 ГБ мусора — архив Xcode и установщики. Видео, архив с фото и старую выгрузку лучше убрать в сейф, документы оставить."),
+ items: advice, provider: tr("Демонстрация"))
}
}
diff --git a/Sources/Offload/CleanupModel.swift b/Sources/Offload/CleanupModel.swift
index 15285dc..ce1445b 100644
--- a/Sources/Offload/CleanupModel.swift
+++ b/Sources/Offload/CleanupModel.swift
@@ -273,7 +273,7 @@ final class CleanupModel {
workToken = token
// Выход во время работы спросит и доведёт остановку до конца, как при переносе.
let operation = app.beginOperation { token.cancel() }
- answers[kind] = .running(Progress(index: 0, count: max(question.items.count, 1), item: "", phase: "Подготовка", fraction: 0))
+ answers[kind] = .running(Progress(index: 0, count: max(question.items.count, 1), item: "", phase: tr("Подготовка"), fraction: 0))
let home = app.rules.home
Task {
if freeBefore == nil { freeBefore = await Self.freeSpace(home: home) }
@@ -347,10 +347,10 @@ final class CleanupModel {
guard let reference = question.keepers.first(where: {
$0.duplicateGroup == item.duplicateGroup && FileManager.default.fileExists(atPath: $0.url.path)
}) else {
- outcome.problems.append("«\(name)» осталось на месте: копии, с которой его можно сверить, на месте уже нет.")
+ outcome.problems.append(tr("«\(name)» осталось на месте: копии, с которой его можно сверить, на месте уже нет."))
continue items
}
- answers[kind] = .running(Progress(index: index + 1, count: items.count, item: name, phase: "Сверка с копией", fraction: 0))
+ answers[kind] = .running(Progress(index: index + 1, count: items.count, item: name, phase: tr("Сверка с копией"), fraction: 0))
let url = item.url
let total = max(item.bytes, 1)
let compared = Counter()
@@ -371,7 +371,7 @@ final class CleanupModel {
return .some(try Self.trash(url))
}.value
guard let trashedAt else {
- outcome.problems.append("«\(name)» осталось на месте: после поиска оно изменилось и больше не совпадает с «\(reference.url.lastPathComponent)».")
+ outcome.problems.append(tr("«\(name)» осталось на месте: после поиска оно изменилось и больше не совпадает с «\(reference.url.lastPathComponent)»."))
continue items
}
outcome.done += 1
@@ -384,7 +384,7 @@ final class CleanupModel {
outcome.cancelled = true
break items
} catch {
- outcome.problems.append("«\(name)» не удалось отправить в Корзину: \(error.localizedDescription)")
+ outcome.problems.append(tr("«\(name)» не удалось отправить в Корзину: \(error.localizedDescription)"))
}
case .trash:
if Demo.isOn {
@@ -395,14 +395,14 @@ final class CleanupModel {
let url = item.url
// Программу могли открыть уже после поиска: кеш занятой программы на ходу не удаляем.
if let busy = CleanupPlanner.busy(home: rules.home, running: Self.runningApplications())[url.path] {
- outcome.problems.append("«\(name)» осталось на месте. \(busy)")
+ outcome.problems.append(tr("«\(name)» осталось на месте. \(busy)"))
continue items
}
// Кеши Homebrew, pip, npm, Gradle, Go держат не программы с окном, а консольные процессы
// (brew install, демон Gradle, xcodebuild) — их видно только по открытым файлам.
if let holders = await Task.detached(priority: .userInitiated, operation: { SafeMover.openFiles(in: url) }).value,
!holders.isEmpty {
- outcome.problems.append("«\(name)» осталось на месте: его сейчас использует \(holders.prefix(3).joined(separator: ", ")).")
+ outcome.problems.append(tr("«\(name)» осталось на месте: его сейчас использует \(holders.prefix(3).joined(separator: ", "))."))
continue items
}
do {
@@ -413,11 +413,11 @@ final class CleanupModel {
outcome.trashedItems.append(TrashedItem(original: url, inTrash: trashedAt.url, bytes: item.bytes, identity: trashedAt.identity))
}
} catch {
- outcome.problems.append("«\(name)» не удалось отправить в Корзину: \(error.localizedDescription)")
+ outcome.problems.append(tr("«\(name)» не удалось отправить в Корзину: \(error.localizedDescription)"))
}
case .safe:
guard let volume = app.safeVolume ?? (Demo.isOn ? Demo.safeVolume : nil) else {
- outcome.problems.append("«\(name)»: сейф закрыт — осталось на месте.")
+ outcome.problems.append(tr("«\(name)»: сейф закрыт — осталось на месте."))
continue items
}
if Demo.isOn {
@@ -435,14 +435,14 @@ final class CleanupModel {
break items
}
guard plan.canProceed else {
- let reason = (plan.verdict.isBlocked ? plan.verdict.notes : plan.check.blockers).first ?? "перенос невозможен"
- outcome.problems.append("«\(name)»: \(reason)")
+ let reason = (plan.verdict.isBlocked ? plan.verdict.notes : plan.check.blockers).first ?? tr("перенос невозможен")
+ outcome.problems.append(tr("«\(name)»: \(reason)"))
continue items
}
// Оговорки, которых человек не видел, когда разрешал (например, что папку меняли вчера), —
// повод спросить отдельно, а не перенести молча.
if case .caution(let notes) = plan.verdict, let unseen = notes.first(where: { !shown.contains($0) }) {
- outcome.problems.append("«\(name)» осталось на месте: \(unseen) Перенесите вручную в «Освободить место», если уверены.")
+ outcome.problems.append(tr("«\(name)» осталось на месте: \(unseen) Перенесите вручную в «Освободить место», если уверены."))
continue items
}
do {
@@ -452,7 +452,7 @@ final class CleanupModel {
guard throttle.ready() else { return }
Task { @MainActor in
if case .running(var current) = self.answers[kind], current.item == name {
- current.phase = progress.phase.rawValue
+ current.phase = trDynamic(progress.phase.rawValue)
current.fraction = progress.fraction
self.answers[kind] = .running(current)
}
@@ -465,7 +465,7 @@ final class CleanupModel {
outcome.cancelled = true
break items
} catch {
- outcome.problems.append("«\(name)»: \(error.localizedDescription)")
+ outcome.problems.append(tr("«\(name)»: \(error.localizedDescription)"))
}
}
}
@@ -476,7 +476,7 @@ final class CleanupModel {
private func performDocker(_ question: CleanupQuestion, app: AppModel) async -> Outcome {
let kind = question.kind
var outcome = Outcome()
- answers[kind] = .running(Progress(index: 1, count: 1, item: "Docker", phase: "Очистка", fraction: 0))
+ answers[kind] = .running(Progress(index: 1, count: 1, item: "Docker", phase: tr("Очистка"), fraction: 0))
if Demo.isOn {
outcome.done = 1
outcome.bytes = question.bytes
@@ -487,16 +487,16 @@ final class CleanupModel {
let targets = Set(question.docker.keys)
do {
let reclaimed = try await Task.detached(priority: .userInitiated) { try service.prune(targets) }.value
- answers[kind] = .running(Progress(index: 1, count: 1, item: "Docker", phase: "Жду, пока Docker вернёт место Mac", fraction: 1))
+ answers[kind] = .running(Progress(index: 1, count: 1, item: "Docker", phase: tr("Жду, пока Docker вернёт место Mac"), fraction: 1))
_ = await DockerModel.settle(service, before: rawBefore)
outcome.done = 1
// Не сказал, сколько освободил, — так и пишем, а не подставляем оценку из вопроса.
outcome.bytes = reclaimed ?? 0
- if reclaimed == nil { outcome.problems.append("Docker не сообщил, сколько места освободил.") }
+ if reclaimed == nil { outcome.problems.append(tr("Docker не сообщил, сколько места освободил.")) }
} catch DockerError.pruneIncomplete(let done, let reclaimed, let message) {
outcome.done = done
outcome.bytes = reclaimed ?? 0
- outcome.problems.append("Docker очистил только часть, дальше остановился: \(message)")
+ outcome.problems.append(tr("Docker очистил только часть, дальше остановился: \(message)"))
} catch {
outcome.problems.append("Docker: \(error.localizedDescription)")
}
@@ -633,7 +633,7 @@ final class CleanupModel {
let observation = CleanupObservation(
url: item.url, bytes: item.bytes, modified: item.modified, isDirectory: item.isDirectory,
verdict: gitRoots.contains(item.url.deletingLastPathComponent().standardizedFileURL.path)
- ? .blocked("Часть git-репозитория «\(item.url.deletingLastPathComponent().lastPathComponent)». Переносите и сохраняйте проект целиком.")
+ ? .blocked(tr("Часть git-репозитория «\(item.url.deletingLastPathComponent().lastPathComponent)». Переносите и сохраняйте проект целиком."))
: item.verdict,
isProject: item.isDirectory
&& FileManager.default.fileExists(atPath: item.url.appendingPathComponent(".git").path),
@@ -755,7 +755,7 @@ final class CleanupModel {
/// Для вернутого запоминается «оставить»: о нём больше не спрошу.
func restore(_ kind: CleanupQuestion.Kind, app: AppModel) {
guard case .done(var outcome) = answer(for: kind), finishing == nil, !outcome.trashedItems.isEmpty else { return }
- finishing = "Возвращаю из Корзины…"
+ finishing = tr("Возвращаю из Корзины…")
let items = outcome.trashedItems
let home = app.rules.home
Task {
@@ -778,7 +778,7 @@ final class CleanupModel {
func eraseTrashed(app: AppModel) {
let items = trashedItems
guard finishing == nil, !items.isEmpty else { return }
- finishing = "Удаляю из Корзины…"
+ finishing = tr("Удаляю из Корзины…")
let home = app.rules.home
Task {
let (gone, missing, problems) = await Self.erase(items)
@@ -807,15 +807,15 @@ final class CleanupModel {
for item in items {
let name = item.original.lastPathComponent
guard fm.fileExists(atPath: item.inTrash.path) else {
- problems.append("«\(name)»: в Корзине его уже нет.")
+ problems.append(tr("«\(name)»: в Корзине его уже нет."))
continue
}
guard item.isStillInTrash else {
- problems.append("«\(name)»: в Корзине под этим именем теперь другой файл — его не трогаю.")
+ problems.append(tr("«\(name)»: в Корзине под этим именем теперь другой файл — его не трогаю."))
continue
}
guard !fm.fileExists(atPath: item.original.path) else {
- problems.append("«\(name)»: на прежнем месте уже есть файл с таким именем — оставил в Корзине.")
+ problems.append(tr("«\(name)»: на прежнем месте уже есть файл с таким именем — оставил в Корзине."))
continue
}
do {
@@ -823,7 +823,7 @@ final class CleanupModel {
try fm.moveItem(at: item.inTrash, to: item.original)
back.append(item)
} catch {
- problems.append("«\(name)»: \(error.localizedDescription)")
+ problems.append(tr("«\(name)»: \(error.localizedDescription)"))
}
}
return (back, problems)
@@ -841,17 +841,17 @@ final class CleanupModel {
// выброшенный потом с тем же именем, мог лечь на тот же путь.
guard FileManager.default.fileExists(atPath: item.inTrash.path) else {
missing.append(item)
- problems.append("«\(item.original.lastPathComponent)»: в Корзине его уже нет — вернули или Корзину очистили.")
+ problems.append(tr("«\(item.original.lastPathComponent)»: в Корзине его уже нет — вернули или Корзину очистили."))
continue
}
guard item.isStillInTrash else {
- problems.append("«\(item.original.lastPathComponent)»: в Корзине под этим именем теперь другой файл — его не трогаю.")
+ problems.append(tr("«\(item.original.lastPathComponent)»: в Корзине под этим именем теперь другой файл — его не трогаю."))
continue
}
try FileManager.default.removeItem(at: item.inTrash)
gone.append(item)
} catch {
- problems.append("«\(item.original.lastPathComponent)»: \(error.localizedDescription)")
+ problems.append(tr("«\(item.original.lastPathComponent)»: \(error.localizedDescription)"))
}
}
return (gone, missing, problems)
@@ -871,9 +871,9 @@ final class CleanupModel {
private static func phase(_ action: CleanupAction) -> String {
switch action {
- case .trash: return "В Корзину"
- case .safe: return "Подготовка"
- case .backup: return "В бэкап"
+ case .trash: return tr("В Корзину")
+ case .safe: return tr("Подготовка")
+ case .backup: return tr("В бэкап")
case .keep: return ""
}
}
diff --git a/Sources/Offload/CloudRestoreModel.swift b/Sources/Offload/CloudRestoreModel.swift
index a2e22c3..d3806f2 100644
--- a/Sources/Offload/CloudRestoreModel.swift
+++ b/Sources/Offload/CloudRestoreModel.swift
@@ -98,8 +98,8 @@ final class CloudRestoreModel {
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.directoryURL = CloudRestore.iCloudDrive
- panel.prompt = "Выбрать"
- panel.message = "Папка хранилища restic — в ней лежат config, keys и data"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Папка хранилища restic — в ней лежат config, keys и data")
guard panel.runModal() == .OK, let url = panel.url else { return }
guard CloudRestore.isRepository(url) else {
error = CloudRestore.RestoreError.notARepository(url.path).localizedDescription
@@ -122,8 +122,8 @@ final class CloudRestoreModel {
panel.canChooseFiles = true
panel.canChooseDirectories = false
panel.showsHiddenFiles = true
- panel.prompt = "Выбрать"
- panel.message = "Файл, в котором записан пароль хранилища"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Файл, в котором записан пароль хранилища")
guard panel.runModal() == .OK else { return }
file = panel.url
}
@@ -134,7 +134,7 @@ final class CloudRestoreModel {
private func unlock(with candidate: CloudRestore.Password) {
guard let repository, loading == nil else { return }
if Demo.isOn { return }
- loading = "Открываю хранилище…"
+ loading = tr("Открываю хранилище…")
error = nil
Task {
do {
@@ -149,7 +149,7 @@ final class CloudRestoreModel {
}
snapshots = found
loading = nil
- if found.isEmpty { error = "В хранилище пока нет ни одного снимка." }
+ if found.isEmpty { error = tr("В хранилище пока нет ни одного снимка.") }
snapshotID = found.first?.id
} catch {
loading = nil
@@ -180,7 +180,7 @@ final class CloudRestoreModel {
if Demo.isOn { directory = path }
return
}
- loading = "Читаю «\((path as NSString).lastPathComponent)»…"
+ loading = tr("Читаю «\((path as NSString).lastPathComponent)»…")
error = nil
Task {
do {
@@ -202,7 +202,7 @@ final class CloudRestoreModel {
let text = query.trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { searchResults = nil; return }
guard let repository, let password, let snapshotID else { return }
- loading = "Ищу «\(text)»…"
+ loading = tr("Ищу «\(text)»…")
error = nil
Task {
do {
@@ -231,8 +231,8 @@ final class CloudRestoreModel {
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.directoryURL = destination
- panel.prompt = "Выбрать"
- panel.message = "Куда класть восстановленное — внутри появится новая папка"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Куда класть восстановленное — внутри появится новая папка")
guard panel.runModal() == .OK, let url = panel.url else { return }
destination = url
if !Demo.isOn { UserDefaults.standard.set(url.path, forKey: Self.destinationKey) }
@@ -241,7 +241,7 @@ final class CloudRestoreModel {
func restore(_ entry: Entry, app: AppModel) {
guard restoring == nil else { return }
if Demo.isOn {
- restoreMessage = Notice.Message(.success, "Демонстрация: «\(entry.name)» восстановился бы в новую папку в «\(destination.lastPathComponent)».")
+ restoreMessage = Notice.Message(.success, tr("Демонстрация: «\(entry.name)» восстановился бы в новую папку в «\(destination.lastPathComponent)»."))
return
}
guard let repository, let password, let snapshot else { return }
@@ -275,14 +275,14 @@ final class CloudRestoreModel {
}.value
restoredItem = report.item
let what = entry.isDirectory
- ? "\(report.files) \(pluralRu(report.files, "файл", "файла", "файлов")), \(Format.bytes(report.bytes))"
+ ? "\(report.files) \(pluralRu(report.files, tr("файл"), tr("файла"), tr("файлов"))), \(Format.bytes(report.bytes))"
: Format.bytes(report.bytes)
restoreMessage = report.verified && report.problems.isEmpty
- ? Notice.Message(.success, "«\(entry.name)» восстановлено и сверено с бэкапом: \(what).")
- : Notice.Message(.warning, "«\(entry.name)» восстановлено не целиком: \(what). Файлы, которые не прочитались из бэкапа, убраны — они были бы испорчены; остальное с бэкапом не сверено: после ошибок restic не сверяет. Не удалось:",
+ ? Notice.Message(.success, tr("«\(entry.name)» восстановлено и сверено с бэкапом: \(what)."))
+ : Notice.Message(.warning, tr("«\(entry.name)» восстановлено не целиком: \(what). Файлы, которые не прочитались из бэкапа, убраны — они были бы испорчены; остальное с бэкапом не сверено: после ошибок restic не сверяет. Не удалось:"),
details: Array(report.problems.prefix(10)))
} catch is CancellationError {
- restoreMessage = Notice.Message(.info, "Восстановление остановлено, недокачанное убрано.")
+ restoreMessage = Notice.Message(.info, tr("Восстановление остановлено, недокачанное убрано."))
} catch {
restoreMessage = Notice.Message(.error, error.localizedDescription)
}
diff --git a/Sources/Offload/Demo.swift b/Sources/Offload/Demo.swift
index 93cbde5..d03d57b 100644
--- a/Sources/Offload/Demo.swift
+++ b/Sources/Offload/Demo.swift
@@ -44,7 +44,7 @@ enum Demo {
AppMemory(name: "Figma", bytes: 1_300 << 20, processes: 5),
AppMemory(name: "Slack", bytes: 900 << 20, processes: 6),
AppMemory(name: "Telegram", bytes: 610 << 20, processes: 2),
- AppMemory(name: "Музыка", bytes: 380 << 20, processes: 1),
+ AppMemory(name: tr("Музыка"), bytes: 380 << 20, processes: 1),
])
static func spaceItems() -> [SpaceItem] {
@@ -55,10 +55,10 @@ enum Demo {
}
return [
item("Movies", 142.6, .safe, daysAgo: 210),
- item("Library", 96.1, .blocked("Данные приложений: перенос сломает программы, которые их используют."), daysAgo: 0),
+ item("Library", 96.1, .blocked(tr("Данные приложений: перенос сломает программы, которые их используют.")), daysAgo: 0),
item("Downloads", 58.3, .safe, daysAgo: 3),
- item("Pictures", 41.7, .caution(["Внутри медиатека «Фото» — её перенести нельзя, остальное можно."]), daysAgo: 12),
- item("Projects", 31.2, .caution(["Внутри git-репозитории: после переноса с ними можно работать только с диска."]), daysAgo: 1),
+ item("Pictures", 41.7, .caution([tr("Внутри медиатека «Фото» — её перенести нельзя, остальное можно.")]), daysAgo: 12),
+ item("Projects", 31.2, .caution([tr("Внутри git-репозитории: после переноса с ними можно работать только с диска.")]), daysAgo: 1),
item("Music", 12.4, .safe, daysAgo: 400),
item("Documents", 17.5, .safe, daysAgo: 5),
item("Desktop", 2.9, .safe, daysAgo: 0),
@@ -76,15 +76,15 @@ enum Demo {
originalRemoved: true, restored: restored, note: note, inSafe: inSafe ? true : nil)
}
return [
- record("Movies/Съёмки 2023", 86.4, files: 412, daysAgo: 1),
+ record(tr("Movies/Съёмки 2023"), 86.4, files: 412, daysAgo: 1),
record("Library/Application Support/MobileSync/Backup/iPhone 15", 48.2, files: 9_811, daysAgo: 2,
- note: "Резервная копия iPhone. После возврата Finder снова её увидит."),
- record("Downloads/Установщики", 21.7, files: 64, daysAgo: 2),
- record("Pictures/Экспорт Lightroom 2022", 12.9, files: 1_840, daysAgo: 30, inSafe: false,
- note: "Перенесено до появления сейфа — лежит на диске открыто."),
+ note: tr("Резервная копия iPhone. После возврата Finder снова её увидит.")),
+ record(tr("Downloads/Установщики"), 21.7, files: 64, daysAgo: 2),
+ record(tr("Pictures/Экспорт Lightroom 2022"), 12.9, files: 1_840, daysAgo: 30, inSafe: false,
+ note: tr("Перенесено до появления сейфа — лежит на диске открыто.")),
record("Music/Logic Projects", 9.8, files: 2_377, daysAgo: 6),
record("Projects/old-prototypes", 6.3, files: 18_204, daysAgo: 9),
- record("Documents/Сканы договоров", 1.2, files: 146, daysAgo: 14, restored: true),
+ record(tr("Documents/Сканы договоров"), 1.2, files: 146, daysAgo: 14, restored: true),
]
}
@@ -103,13 +103,13 @@ enum Demo {
DuplicateCopy(url: home.appendingPathComponent(relative), allocated: Int64(gb * Double(gigabyte)),
modified: now.addingTimeInterval(-daysAgo * 86_400), created: now.addingTimeInterval(-daysAgo * 86_400))
}
- let library = Verdict.blocked("Данные приложений")
+ let library = Verdict.blocked(tr("Данные приложений"))
let regenerable = Dictionary(uniqueKeysWithValues: CleanupPlanner.regenerableLocations
.filter { ["Library/Developer/Xcode/DerivedData", "Library/Caches/Homebrew", "Library/Caches/Google/Chrome",
".npm/_cacache"].contains($0.path) }
.map { (path($0.path), $0.reason) })
var remembered = memory
- remembered[path("Downloads/Датасеты")] = .safe
+ remembered[path(tr("Downloads/Датасеты"))] = .safe
let planner = CleanupPlanner(now: now, home: home, regenerable: regenerable, memory: remembered, habits: habits,
busy: CleanupPlanner.busy(home: home, running: ["com.google.Chrome": "Google Chrome"]))
return planner.suggestions([
@@ -117,21 +117,21 @@ enum Demo {
item("Library/Caches/Homebrew", 3.4, daysAgo: 5, verdict: library),
item("Library/Caches/Google/Chrome", 1.6, daysAgo: 0, verdict: library),
item(".npm/_cacache", 1.1, daysAgo: 7, verdict: library),
- item("Movies/Съёмки 2023", 86.4, daysAgo: 210),
- item("Downloads/Датасеты", 24.1, daysAgo: 150),
- item("Movies/Интервью 2024", 12.6, daysAgo: 50),
- item("Documents/Архив 2019", 9.4, daysAgo: 900),
- item("Documents/Работа", 8.1, daysAgo: 2),
- item("Pictures/Photos Library.photoslibrary", 41.7, daysAgo: 1, verdict: .blocked("Медиатека «Фото»")),
+ item(tr("Movies/Съёмки 2023"), 86.4, daysAgo: 210),
+ item(tr("Downloads/Датасеты"), 24.1, daysAgo: 150),
+ item(tr("Movies/Интервью 2024"), 12.6, daysAgo: 50),
+ item(tr("Documents/Архив 2019"), 9.4, daysAgo: 900),
+ item(tr("Documents/Работа"), 8.1, daysAgo: 2),
+ item("Pictures/Photos Library.photoslibrary", 41.7, daysAgo: 1, verdict: .blocked(tr("Медиатека «Фото»"))),
item("Downloads/Xcode_16.xip", 7.9, daysAgo: 60, directory: false),
item("Downloads/Figma.dmg", 0.3, daysAgo: 40, directory: false),
item("Projects/offload-site", 1.2, daysAgo: 120, project: true),
], duplicates: [
DuplicateGroup(id: "demo-video", bytes: Int64(2.4 * Double(gigabyte)), copies: [
- copy("Movies/Отпуск 2023.mov", 2.4, daysAgo: 300), copy("Downloads/Отпуск 2023.mov", 2.4, daysAgo: 40),
- copy("Desktop/Отпуск 2023 (1).mov", 2.4, daysAgo: 12)]),
+ copy(tr("Movies/Отпуск 2023.mov"), 2.4, daysAgo: 300), copy(tr("Downloads/Отпуск 2023.mov"), 2.4, daysAgo: 40),
+ copy(tr("Desktop/Отпуск 2023 (1).mov"), 2.4, daysAgo: 12)]),
DuplicateGroup(id: "demo-pdf", bytes: 14_000_000, copies: [
- copy("Documents/Договор аренды.pdf", 0.014, daysAgo: 90), copy("Downloads/Договор аренды (1).pdf", 0.014, daysAgo: 30)]),
+ copy(tr("Documents/Договор аренды.pdf"), 0.014, daysAgo: 90), copy(tr("Downloads/Договор аренды (1).pdf"), 0.014, daysAgo: 30)]),
])
}
@@ -147,10 +147,10 @@ enum Demo {
}
// Отснятое в «Фильмах» убирает в сейф; старые папки в «Документах» оставляет, хотя OffLoadAI
// предлагал сейф; проекты добавляет в бэкап, как и советуют правила.
- return ["Съёмки 2019", "Съёмки 2020", "Свадьба Ани", "Съёмки 2021", "Съёмки 2022"].enumerated().map { index, name in
+ return [tr("Съёмки 2019"), tr("Съёмки 2020"), tr("Свадьба Ани"), tr("Съёмки 2021"), tr("Съёмки 2022")].enumerated().map { index, name in
decision("Movies/\(name)", .safe, suggested: .safe, kind: .folder, 18 + Double(index) * 11,
daysAgo: 200 + Double(index) * 60, decidedDaysAgo: 20 + Double(index) * 25)
- } + ["Архив 2015", "Архив 2016", "Архив 2017", "Архив 2018"].enumerated().map { index, name in
+ } + [tr("Архив 2015"), tr("Архив 2016"), tr("Архив 2017"), tr("Архив 2018")].enumerated().map { index, name in
decision("Documents/\(name)", .keep, suggested: .safe, kind: .folder, 2.5 + Double(index) * 1.5,
daysAgo: 400 + Double(index) * 200, decidedDaysAgo: 10 + Double(index) * 30)
} + ["landing", "telegram-bot", "scripts"].enumerated().map { index, name in
@@ -205,7 +205,7 @@ enum Demo {
// MARK: - Из iCloud
static var cloudRepositories: [CloudRestore.Repository] {
- [CloudRestore.Repository(url: CloudRestore.iCloudDrive.appendingPathComponent("Бэкапы/ssd-restic", isDirectory: true))]
+ [CloudRestore.Repository(url: CloudRestore.iCloudDrive.appendingPathComponent(tr("Бэкапы/ssd-restic"), isDirectory: true))]
}
static var cloudSnapshots: [CloudRestore.Snapshot] {
diff --git a/Sources/Offload/Models.swift b/Sources/Offload/Models.swift
index e28ecac..39e2b0d 100644
--- a/Sources/Offload/Models.swift
+++ b/Sources/Offload/Models.swift
@@ -32,7 +32,7 @@ final class OverviewModel {
static func diskAdvice(_ disk: VolumeInfo?) -> [String] {
guard let disk, disk.totalBytes > 0,
Double(disk.availableBytes) / Double(disk.totalBytes) < 0.15 else { return [] }
- return ["На диске Mac свободно всего \(Format.bytes(disk.availableBytes)). Когда места мало, macOS тормозит: ей негде держать swap. Откройте «Освободить место»."]
+ return [tr("На диске Mac свободно всего \(Format.bytes(disk.availableBytes)). Когда места мало, macOS тормозит: ей негде держать swap. Откройте «Освободить место».")]
}
}
@@ -61,7 +61,7 @@ final class SpaceModel {
var largest: Int64 { max(items.map(\.bytes).max() ?? 0, 1) }
func title(home: URL) -> String {
- guard let location else { return "Домашняя папка и общие файлы" }
+ guard let location else { return tr("Домашняя папка и общие файлы") }
return relativeToHome(location.path, home: home)
}
@@ -208,7 +208,7 @@ final class MoveModel {
// Без этой ветки отмена оставляла окно навсегда в состоянии «Проверяю…»:
// спиннер крутится, кнопка «Отменить» уже ничего не делает, закрыть нечем.
guard !token.isCancelled else {
- stage = .failed("Проверка отменена. Ничего не скопировано и не удалено.")
+ stage = .failed(tr("Проверка отменена. Ничего не скопировано и не удалено."))
return
}
stage = .ready(plan)
@@ -239,7 +239,7 @@ final class MoveModel {
stage = .done(record)
didMove = true
} catch is CancellationError {
- stage = .failed("Перенос отменён. Оригинал не тронут, незаконченная копия удалена.")
+ stage = .failed(tr("Перенос отменён. Оригинал не тронут, незаконченная копия удалена."))
} catch {
stage = .failed(error.localizedDescription)
}
@@ -320,19 +320,19 @@ final class HistoryModel {
// приходит всегда, и чистый возврат выглядел бы бедой.
var notes = outcome.notes
if record.isEncrypted, deleteArchive {
- notes.append("Место внутри сейфа освободилось и пойдёт под новые данные, но сам образ на диске от этого не уменьшится.")
+ notes.append(tr("Место внутри сейфа освободилось и пойдёт под новые данные, но сам образ на диске от этого не уменьшится."))
}
message = outcome.needsAttention
- ? Notice.Message(.warning, "«\(name)» возвращён на место, но с оговорками:", details: notes)
- : Notice.Message(.success, "«\(name)» возвращён на место, каждый файл перечитан и сверен по SHA-256.",
+ ? Notice.Message(.warning, tr("«\(name)» возвращён на место, но с оговорками:"), details: notes)
+ : Notice.Message(.success, tr("«\(name)» возвращён на место, каждый файл перечитан и сверен по SHA-256."),
details: notes)
} catch is CancellationError {
- message = Notice.Message(.info, "Возврат отменён, незаконченная копия удалена. Архив на диске не тронут.")
+ message = Notice.Message(.info, tr("Возврат отменён, незаконченная копия удалена. Архив на диске не тронут."))
} catch {
// Без приписок про архив: удаление архива идёт последним шагом, и сорваться
// могло именно оно — данные уже вернулись бы, а «архив не тронут» оказалось
// бы ложью. Что на самом деле случилось, знает только ядро.
- message = Notice.Message(.error, "Вернуть не удалось: \(error.localizedDescription)")
+ message = Notice.Message(.error, tr("Вернуть не удалось: \(error.localizedDescription)"))
}
busyID = nil
progress = nil
@@ -403,11 +403,11 @@ final class BackupModel {
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = volume.mountPoint
- panel.prompt = "Выбрать"
- panel.message = "Папка для бэкапа на диске «\(volume.name)» — можно указать уже существующую"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Папка для бэкапа на диске «\(volume.name)» — можно указать уже существующую")
guard panel.runModal() == .OK, let url = panel.url else { return }
guard url.path.hasPrefix(volume.mountPoint.path + "/") else {
- error = "Папка должна лежать на диске «\(volume.name)»."
+ error = tr("Папка должна лежать на диске «\(volume.name)».")
return
}
destinationPath = url.path
@@ -426,8 +426,8 @@ final class BackupModel {
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
- panel.prompt = "Добавить"
- panel.message = "Выберите папки с проектами и документами для бэкапа"
+ panel.prompt = tr("Добавить")
+ panel.message = tr("Выберите папки с проектами и документами для бэкапа")
guard panel.runModal() == .OK else { return }
for url in panel.urls where !sources.contains(url) { sources.append(url) }
}
@@ -462,7 +462,7 @@ final class BackupModel {
}
}.value
} catch is CancellationError {
- self.error = "Бэкап остановлен. Уже скопированные файлы остались на диске."
+ self.error = tr("Бэкап остановлен. Уже скопированные файлы остались на диске.")
} catch {
self.error = error.localizedDescription
}
@@ -494,7 +494,7 @@ final class BackupModel {
SecretsVault.fill(mount, home: home, projectRoots: roots, isCancelled: { token.isCancelled })
}.value
keysReport = report
- let text = "В сейф сложено файлов: \(report.copied), без изменений: \(report.unchanged)" + (report.problems.isEmpty ? "." : ", проблем: \(report.problems.count).")
+ let text = tr("В сейф сложено файлов: \(report.copied), без изменений: \(report.unchanged)") + (report.problems.isEmpty ? "." : tr(", проблем: \(report.problems.count)."))
keysMessage = Notice.Message(report.problems.isEmpty ? .success : .warning, text, details: Array(report.problems.prefix(5)))
keysBusy = false
app.endOperation(operationID)
@@ -609,12 +609,12 @@ final class DockerModel {
guard !targets.isEmpty, busy == nil else { return }
// В демонстрации кнопки ничего не удаляют: демо запускают, чтобы посмотреть, а Docker настоящий.
guard !Demo.isOn else {
- messages = ["Демонстрация: Docker не тронут."]
+ messages = [tr("Демонстрация: Docker не тронут.")]
return
}
let service = service
let rawBefore = service.rawDiskBytes()
- busy = "Очистка: подготовка"
+ busy = tr("Очистка: подготовка")
pruning = true
messages = []
Task {
@@ -622,11 +622,11 @@ final class DockerModel {
let reclaimed = try await Task.detached(priority: .userInitiated) {
try service.prune(targets) { target in
Task { @MainActor in
- if self.busy != nil { self.busy = "Очистка: \(target.title.lowercased())" }
+ if self.busy != nil { self.busy = tr("Очистка: \(target.title.lowercased())") }
}
}
}.value
- busy = "Жду, пока Docker вернёт место Mac"
+ busy = tr("Жду, пока Docker вернёт место Mac")
let rawAfter = await Self.settle(service, before: rawBefore)
messages.append(Self.pruneReport(reclaimed: reclaimed, rawBefore: rawBefore, rawAfter: rawAfter))
} catch {
@@ -654,12 +654,12 @@ final class DockerModel {
}
static func pruneReport(reclaimed: Int64?, rawBefore: Int64?, rawAfter: Int64?) -> String {
- if reclaimed == 0 { return "Удалять было нечего: всё выбранное сейчас используется." }
- let inside = reclaimed.map { "Docker удалил \(Format.bytes($0))." } ?? "Docker удалил выбранное."
+ if reclaimed == 0 { return tr("Удалять было нечего: всё выбранное сейчас используется.") }
+ let inside = reclaimed.map { tr("Docker удалил \(Format.bytes($0)).") } ?? tr("Docker удалил выбранное.")
if let rawBefore, let rawAfter, rawBefore - rawAfter >= 64 << 20 {
- return "✓ \(inside) Диск Docker на Mac уменьшился на \(Format.bytes(rawBefore - rawAfter))."
+ return tr("✓ \(inside) Диск Docker на Mac уменьшился на \(Format.bytes(rawBefore - rawAfter)).")
}
- return "✓ \(inside) Место на Mac Docker вернёт чуть позже — иногда только после перезапуска Docker Desktop."
+ return tr("✓ \(inside) Место на Mac Docker вернёт чуть позже — иногда только после перезапуска Docker Desktop.")
}
func checkActivity(_ names: [String]) {
@@ -681,7 +681,7 @@ final class DockerModel {
// Упаковать — в Pro; вернуть том из архива можно всегда.
guard app.pro.allows(.dockerVolumes) else { return app.pro.offer(.dockerVolumes) }
guard !Demo.isOn else {
- messages = ["Демонстрация: тома не упакованы, Docker не тронут."]
+ messages = [tr("Демонстрация: тома не упакованы, Docker не тронут.")]
return
}
let token = CancelToken()
@@ -695,23 +695,23 @@ final class DockerModel {
Task {
for name in names {
if token.isCancelled { break }
- busy = "«\(name)»: подготовка"
+ busy = tr("«\(name)»: подготовка")
do {
let archive = try await Task.detached(priority: .userInitiated) {
let url = try service.archive(name, into: folder, isCancelled: { token.isCancelled }) { status in
Task { @MainActor in
- if self.busy != nil { self.busy = "«\(name)»: \(status.lowercased())" }
+ if self.busy != nil { self.busy = tr("«\(name)»: \(status.lowercased())") }
}
}
try service.removeVolume(name)
return url
}.value
let size = (try? archive.resourceValues(forKeys: [.fileSizeKey]))?.fileSize.map { Format.bytes(Int64($0)) } ?? ""
- messages.append("✓ «\(name)» упакован в \(archive.lastPathComponent) (\(size)), сверен и убран из Docker.")
+ messages.append(tr("✓ «\(name)» упакован в \(archive.lastPathComponent) (\(size)), сверен и убран из Docker."))
} catch is CancellationError {
- messages.append("«\(name)»: отменено, том не тронут.")
+ messages.append(tr("«\(name)»: отменено, том не тронут."))
} catch {
- messages.append("✗ «\(name)»: \(error.localizedDescription)")
+ messages.append(tr("✗ «\(name)»: \(error.localizedDescription)"))
}
}
busy = nil
@@ -725,7 +725,7 @@ final class DockerModel {
func restore(_ archive: URL, name: String, app: AppModel) {
guard !Demo.isOn else {
- messages = ["Демонстрация: Docker не тронут."]
+ messages = [tr("Демонстрация: Docker не тронут.")]
return
}
let token = CancelToken()
@@ -734,22 +734,22 @@ final class DockerModel {
let id = app.beginOperation { token.cancel() }
tokens[id] = token
let service = service
- busy = "«\(name)»: подготовка"
+ busy = tr("«\(name)»: подготовка")
messages = []
Task {
do {
try await Task.detached(priority: .userInitiated) {
try service.restore(archive: archive, as: name, isCancelled: { token.isCancelled }) { status in
Task { @MainActor in
- if self.busy != nil { self.busy = "«\(name)»: \(status.lowercased())" }
+ if self.busy != nil { self.busy = tr("«\(name)»: \(status.lowercased())") }
}
}
}.value
- messages.append("✓ Том «\(name)» восстановлен из \(archive.lastPathComponent) и сверен. Архив остался на диске.")
+ messages.append(tr("✓ Том «\(name)» восстановлен из \(archive.lastPathComponent) и сверен. Архив остался на диске."))
} catch is CancellationError {
- messages.append("«\(name)»: отменено, созданный том удалён.")
+ messages.append(tr("«\(name)»: отменено, созданный том удалён."))
} catch {
- messages.append("✗ «\(name)»: \(error.localizedDescription)")
+ messages.append(tr("✗ «\(name)»: \(error.localizedDescription)"))
}
busy = nil
tokens.removeValue(forKey: id)
diff --git a/Sources/Offload/OffloadApp.swift b/Sources/Offload/OffloadApp.swift
index d33e998..fb4706c 100644
--- a/Sources/Offload/OffloadApp.swift
+++ b/Sources/Offload/OffloadApp.swift
@@ -21,25 +21,25 @@ struct OffloadApp: App {
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
- Button("О программе OffLoadAI") { AboutPanel.show() }
- Button("Проверить обновления…") { model.updates.checkNow() }
+ Button(tr("О программе OffLoadAI")) { AboutPanel.show() }
+ Button(tr("Проверить обновления…")) { model.updates.checkNow() }
.disabled(model.updates.isChecking)
- Toggle("Сообщать о новых версиях", isOn: Binding(get: { model.updates.enabled == true },
+ Toggle(tr("Сообщать о новых версиях"), isOn: Binding(get: { model.updates.enabled == true },
set: { model.updates.setEnabled($0) }))
Button("OffLoadAI Pro…") { model.pro.offer() }
}
// Как «Dismount All» в VeraCrypt: закрыть сейф из любого места одним сочетанием.
- CommandMenu("Сейф") {
- Button("Закрыть сейф") { model.safe.close(app: model) }
+ CommandMenu(tr("Сейф")) {
+ Button(tr("Закрыть сейф")) { model.safe.close(app: model) }
.keyboardShortcut("l", modifiers: [.command, .shift])
.disabled(!model.safe.isOpen)
- Button("Открыть раздел «Сейф»") { model.section = .safe }
+ Button(tr("Открыть раздел «Сейф»")) { model.section = .safe }
.keyboardShortcut("0", modifiers: [.command])
}
CommandGroup(replacing: .help) {
- Link("OffLoadAI на GitHub", destination: Self.repositoryURL)
- Link("Канал OffLoadAI в Telegram", destination: URL(string: "https://t.me/OffLoadAI")!)
- Link("Сообщить о проблеме", destination: Self.repositoryURL.appendingPathComponent("issues"))
+ Link(tr("OffLoadAI на GitHub"), destination: Self.repositoryURL)
+ Link(tr("Канал OffLoadAI в Telegram"), destination: URL(string: "https://t.me/OffLoadAI")!)
+ Link(tr("Сообщить о проблеме"), destination: Self.repositoryURL.appendingPathComponent("issues"))
}
}
}
@@ -48,8 +48,8 @@ struct OffloadApp: App {
enum AboutPanel {
static func show() {
let info = Bundle.main.infoDictionary ?? [:]
- let version = info["CFBundleShortVersionString"] as? String ?? "разработка"
- let credits = NSMutableAttributedString(string: "Разгрузка диска Mac без риска потерять данные.\nОригинал удаляется только после проверенной копии.\n\n")
+ let version = info["CFBundleShortVersionString"] as? String ?? tr("разработка")
+ let credits = NSMutableAttributedString(string: tr("Разгрузка диска Mac без риска потерять данные.\nОригинал удаляется только после проверенной копии.\n\n"))
credits.append(NSAttributedString(string: "github.com/audit0/Offload", attributes: [.link: OffloadApp.repositoryURL]))
credits.addAttribute(.font, value: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize), range: NSRange(location: 0, length: credits.length))
NSApp.orderFrontStandardAboutPanel(options: [
@@ -92,10 +92,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return .terminateNow
}
let alert = NSAlert()
- alert.messageText = "Сейчас идёт копирование"
- alert.informativeText = "Если выйти, копирование прервётся. Данные не пострадают: оригинал не удаляется, пока копия не сверена, а незаконченная копия будет убрана."
- alert.addButton(withTitle: "Прервать и выйти")
- alert.addButton(withTitle: "Не выходить")
+ alert.messageText = tr("Сейчас идёт копирование")
+ alert.informativeText = tr("Если выйти, копирование прервётся. Данные не пострадают: оригинал не удаляется, пока копия не сверена, а незаконченная копия будет убрана.")
+ alert.addButton(withTitle: tr("Прервать и выйти"))
+ alert.addButton(withTitle: tr("Не выходить"))
guard alert.runModal() == .alertFirstButtonReturn else { return .terminateCancel }
model.cancelEverything()
Task { @MainActor in
@@ -120,10 +120,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
try SecretsVault.detach(mount)
} catch {
let alert = NSAlert()
- alert.messageText = "Сейф не закрывается"
- alert.informativeText = "В нём открыты файлы в других программах. Закрыть принудительно? Несохранённое в этих программах может пропасть."
- alert.addButton(withTitle: "Закрыть принудительно")
- alert.addButton(withTitle: "Оставить открытым")
+ alert.messageText = tr("Сейф не закрывается")
+ alert.informativeText = tr("В нём открыты файлы в других программах. Закрыть принудительно? Несохранённое в этих программах может пропасть.")
+ alert.addButton(withTitle: tr("Закрыть принудительно"))
+ alert.addButton(withTitle: tr("Оставить открытым"))
if alert.runModal() == .alertFirstButtonReturn { try? SecretsVault.detach(mount, force: true) }
}
}
@@ -139,7 +139,7 @@ enum Snapshots {
try? await Task.sleep(for: .seconds(1))
for candidate in NSApp.windows {
let f = candidate.frame
- FileHandle.standardError.write(Data("окно #\(candidate.windowNumber) «\(candidate.title)» \(Int(f.width))×\(Int(f.height)) видимо=\(candidate.isVisible) класс=\(type(of: candidate))\n".utf8))
+ FileHandle.standardError.write(Data(tr("окно #\(candidate.windowNumber) «\(candidate.title)» \(Int(f.width))×\(Int(f.height)) видимо=\(candidate.isVisible) класс=\(type(of: candidate))\n").utf8))
}
guard let window = NSApp.windows.first(where: { $0.isVisible && $0.styleMask.contains(.titled) }), let model = delegate.model else {
NSApp.terminate(nil)
@@ -150,11 +150,11 @@ enum Snapshots {
// Первое программное переключение раздела до окончания начальной раскладки боковой панели теряется.
try? await Task.sleep(for: .seconds(2))
let started = Date()
- func log(_ text: String) { FileHandle.standardError.write(Data("[\(Int(Date().timeIntervalSince(started))) с] \(text)\n".utf8)) }
+ func log(_ text: String) { FileHandle.standardError.write(Data(tr("[\(Int(Date().timeIntervalSince(started))) с] \(text)\n").utf8)) }
let wanted = ProcessInfo.processInfo.environment["OFFLOAD_SNAPSHOT_SECTIONS"]?
.split(separator: ",").compactMap { SidebarSection(rawValue: String($0)) }
for section in wanted?.isEmpty == false ? wanted! : SidebarSection.allCases {
- log("раздел \(section.rawValue)")
+ log(tr("раздел \(section.rawValue)"))
model.section = section
try? await Task.sleep(for: .seconds(3))
await focus(window, model: model, section: section)
@@ -167,7 +167,7 @@ enum Snapshots {
await focus(window, model: model, section: section)
save(window, to: directory.appendingPathComponent("\(section.rawValue).png"))
}
- log("снимок \(section.rawValue)")
+ log(tr("снимок \(section.rawValue)"))
}
// OFFLOAD_SNAPSHOT_PRO=1 — ещё и окно «OffLoadAI Pro», открытое «да» на лишние копии.
if ProcessInfo.processInfo.environment["OFFLOAD_SNAPSHOT_PRO"] == "1" {
@@ -176,7 +176,7 @@ enum Snapshots {
if let sheet = window.attachedSheet ?? NSApp.windows.first(where: { $0.sheetParent == window || ($0 != window && $0.isVisible && $0.title.isEmpty) }) {
save(sheet, to: directory.appendingPathComponent("pro.png"))
} else {
- log("окно «OffLoadAI Pro» не нашлось")
+ log(tr("окно «OffLoadAI Pro» не нашлось"))
}
// С открытым листом программа не завершается: сначала закрыть.
model.pro.isPresented = false
@@ -213,7 +213,7 @@ enum Snapshots {
/// в PNG стал бы белым, и светлый текст тёмной темы пропал бы на нём целиком.
static func save(_ window: NSWindow, to url: URL) {
if ProcessInfo.processInfo.environment["OFFLOAD_SNAPSHOT_DEBUG"] == "1", let root = window.contentView {
- FileHandle.standardError.write(Data("--- иерархия вью для \(url.lastPathComponent) ---\n".utf8))
+ FileHandle.standardError.write(Data(tr("--- иерархия вью для \(url.lastPathComponent) ---\n").utf8))
dump(root)
}
guard let root = window.contentView, let layer = root.layer else { return }
diff --git a/Sources/Offload/ProModel.swift b/Sources/Offload/ProModel.swift
index b5e6fee..760db2b 100644
--- a/Sources/Offload/ProModel.swift
+++ b/Sources/Offload/ProModel.swift
@@ -77,7 +77,7 @@ final class ProModel {
do {
let checked = try LicenseCodec.verify(text)
guard checked.covers(release: Self.releaseDate) else {
- keyProblem = "Ключ верный, но его обновления закончились \(Self.day(checked.updatesUntil)), а эта версия вышла позже. Продлите ключ — или поставьте версию, вышедшую до этой даты: с ней ключ работает всегда."
+ keyProblem = tr("Ключ верный, но его обновления закончились \(Self.day(checked.updatesUntil)), а эта версия вышла позже. Продлите ключ — или поставьте версию, вышедшую до этой даты: с ней ключ работает всегда.")
return false
}
license = checked
@@ -107,15 +107,15 @@ final class ProModel {
var summary: String {
switch status {
case .licensed: return "OffLoadAI Pro"
- case .early: return "Pro — ранний пользователь"
- case .trial(let days): return "Pro: пробный, \(days) \(pluralRu(days, "день", "дня", "дней"))"
- case .expired: return "Pro: продлите ключ"
- case .free: return "Бесплатная версия"
+ case .early: return tr("Pro — ранний пользователь")
+ case .trial(let days): return tr("Pro: пробный, \(days) \(pluralRu(days, tr("день"), tr("дня"), tr("дней")))")
+ case .expired: return tr("Pro: продлите ключ")
+ case .free: return tr("Бесплатная версия")
}
}
static func day(_ date: Date) -> String {
- date.formatted(.dateTime.day().month(.wide).year().locale(Locale(identifier: "ru_RU")))
+ date.formatted(.dateTime.day().month(.wide).year().locale(AppLanguage.locale))
}
/// Остались ли следы прежних версий: база решений, журнал переносов или настройки.
diff --git a/Sources/Offload/SafeModel.swift b/Sources/Offload/SafeModel.swift
index 239b894..0146573 100644
--- a/Sources/Offload/SafeModel.swift
+++ b/Sources/Offload/SafeModel.swift
@@ -57,7 +57,7 @@ final class SafeModel {
/// Раньше такое событие терялось — сейф был «ещё закрыт», — и после ввода пароля
/// оставался открытым при заблокированном экране.
private var closeAfterOpening: String?
- static let openingTitle = "Открываю сейф…"
+ static let openingTitle = tr("Открываю сейф…")
var closeOnSleep: Bool { didSet { persist() } }
var closeOnLock: Bool { didSet { persist() } }
@@ -206,9 +206,9 @@ final class SafeModel {
guard let host = app.destination else { return }
let vault = SecretsVault(imageURL: host.mountPoint.appendingPathComponent(SecretsVault.safeImageName, isDirectory: true))
let limit = min(max(limit, 1 << 30), host.totalBytes)
- perform("Создаю сейф…", app: app, {
+ perform(tr("Создаю сейф…"), app: app, {
try vault.create(password: password, maxBytes: limit, volumeName: SecretsVault.safeVolumeName)
- return Notice.Message(.success, "Сейф создан: AES-256, пароль знаете только вы. Если его забыть, данные не восстановит никто — даже OffLoadAI.")
+ return Notice.Message(.success, tr("Сейф создан: AES-256, пароль знаете только вы. Если его забыть, данные не восстановит никто — даже OffLoadAI."))
}, after: { [weak self] in
self?.preferredImages[host.id] = vault.imageURL.path
})
@@ -218,9 +218,9 @@ final class SafeModel {
func grow(to limit: Int64, password: String, app: AppModel) {
guard let state, state.exists, state.mount == nil else { return }
let vault = SecretsVault(imageURL: state.imageURL)
- perform("Увеличиваю сейф…", app: app) {
+ perform(tr("Увеличиваю сейф…"), app: app) {
try vault.grow(to: limit, password: password)
- return Notice.Message(.success, "Предел сейфа — \(Format.bytes(vault.sizeLimit ?? limit)). Содержимое на месте, а места на диске образ занимает столько же, сколько занимал.")
+ return Notice.Message(.success, tr("Предел сейфа — \(Format.bytes(vault.sizeLimit ?? limit)). Содержимое на месте, а места на диске образ занимает столько же, сколько занимал."))
}
}
@@ -270,15 +270,15 @@ final class SafeModel {
func close(app: AppModel, force: Bool = false, reason: String? = nil) {
guard let mount = state?.mount else { return }
if app.isBusy, !force {
- pendingClose = reason ?? "по вашей команде"
- message = Notice.Message(.info, "Идёт копирование — сейф закроется, как только оно закончится.")
+ pendingClose = reason ?? tr("по вашей команде")
+ message = Notice.Message(.info, tr("Идёт копирование — сейф закроется, как только оно закончится."))
return
}
pendingClose = nil
closeBlocked = false
- perform("Закрываю сейф…", app: app, {
+ perform(tr("Закрываю сейф…"), app: app, {
try SecretsVault.detach(mount, force: force)
- return Notice.Message(.success, reason.map { "Сейф закрыт: \($0)." } ?? "Сейф закрыт — на диске снова только шифротекст.")
+ return Notice.Message(.success, reason.map { tr("Сейф закрыт: \($0).") } ?? tr("Сейф закрыт — на диске снова только шифротекст."))
}, failed: { [weak self] error in
// Открытые файлы — не ошибка, а вопрос: закрыть ли принудительно (см. ContentView).
if (error as? VaultError) == .busy {
@@ -301,13 +301,13 @@ final class SafeModel {
observers.append((workspace, workspace.addObserver(forName: NSWorkspace.willSleepNotification, object: nil, queue: .main) { [weak self, weak app] _ in
MainActor.assumeIsolated {
guard let self, let app, self.closeOnSleep else { return }
- self.closeNow(reason: "Mac уходит в сон", app: app)
+ self.closeNow(reason: tr("Mac уходит в сон"), app: app)
}
}))
observers.append((workspace, workspace.addObserver(forName: NSWorkspace.sessionDidResignActiveNotification, object: nil, queue: .main) { [weak self, weak app] _ in
MainActor.assumeIsolated {
guard let self, let app, self.closeOnLock else { return }
- self.trigger("сменился пользователь", app: app)
+ self.trigger(tr("сменился пользователь"), app: app)
}
}))
let distributed = DistributedNotificationCenter.default()
@@ -315,7 +315,7 @@ final class SafeModel {
observers.append((distributed, distributed.addObserver(forName: Notification.Name(name), object: nil, queue: .main) { [weak self, weak app] _ in
MainActor.assumeIsolated {
guard let self, let app, self.closeOnLock else { return }
- self.trigger("экран заблокирован", app: app)
+ self.trigger(tr("экран заблокирован"), app: app)
}
}))
}
@@ -347,7 +347,7 @@ final class SafeModel {
if interruptOperations {
app.cancelEverything()
} else {
- message = Notice.Message(.info, "Сейф закроется, как только закончится копирование (\(reason)).")
+ message = Notice.Message(.info, tr("Сейф закроется, как только закончится копирование (\(reason))."))
}
return
}
@@ -365,16 +365,16 @@ final class SafeModel {
guard let mount = state?.mount else { return }
if app.isBusy, !interruptOperations {
pendingClose = reason
- message = Notice.Message(.warning, "Перед сном сейф остался открытым: шло копирование. Он закроется, как только оно закончится.")
+ message = Notice.Message(.warning, tr("Перед сном сейф остался открытым: шло копирование. Он закроется, как только оно закончится."))
return
}
if app.isBusy { app.cancelEverything() }
do {
try SecretsVault.detach(mount, force: interruptOperations)
state?.mount = nil
- message = Notice.Message(.success, "Сейф закрыт: \(reason).")
+ message = Notice.Message(.success, tr("Сейф закрыт: \(reason)."))
} catch {
- message = Notice.Message(.warning, "Перед сном сейф закрыть не удалось: \(error.localizedDescription)")
+ message = Notice.Message(.warning, tr("Перед сном сейф закрыть не удалось: \(error.localizedDescription)"))
}
app.refreshVolumes()
}
@@ -389,7 +389,7 @@ final class SafeModel {
Task {
let closed = await Task.detached { (try? SecretsVault.detach(mount)) != nil }.value
if closed {
- message = Notice.Message(.success, "Сейф закрыт: им не пользовались \(idleMinutes) мин.")
+ message = Notice.Message(.success, tr("Сейф закрыт: им не пользовались \(idleMinutes) мин."))
}
app.refreshVolumes()
refresh(app: app)
@@ -401,10 +401,10 @@ final class SafeModel {
func changePassword(old: String, new: String, app: AppModel) {
guard let state, state.exists, state.mount == nil else { return }
let vault = SecretsVault(imageURL: state.imageURL)
- perform("Меняю пароль…", app: app) {
+ perform(tr("Меняю пароль…"), app: app) {
try vault.changePassword(old: old, new: new)
- return Notice.Message(.success, "Пароль сменён.", details: [
- "Копии заголовка, снятые раньше, по-прежнему открываются старым паролем. Снимите новую копию, а старые удалите.",
+ return Notice.Message(.success, tr("Пароль сменён."), details: [
+ tr("Копии заголовка, снятые раньше, по-прежнему открываются старым паролем. Снимите новую копию, а старые удалите."),
])
}
}
@@ -413,14 +413,14 @@ final class SafeModel {
guard let state, state.exists, state.mount == nil else { return }
let vault = SecretsVault(imageURL: state.imageURL)
let before = state.allocated
- perform("Возвращаю место на диск…", app: app) {
+ perform(tr("Возвращаю место на диск…"), app: app) {
try vault.compact(password: password)
let after = vault.allocatedBytes
let returned = max(0, before - after)
if returned < 16 << 20 {
- return Notice.Message(.info, "macOS не нашла в образе пустых участков: диску вернулось \(Format.bytes(returned)). Место внутри сейфа при этом свободно и пойдёт под новые данные.")
+ return Notice.Message(.info, tr("macOS не нашла в образе пустых участков: диску вернулось \(Format.bytes(returned)). Место внутри сейфа при этом свободно и пойдёт под новые данные."))
}
- return Notice.Message(.success, "Диску возвращено \(Format.bytes(returned)). Сейф занимает \(Format.bytes(after)).")
+ return Notice.Message(.success, tr("Диску возвращено \(Format.bytes(returned)). Сейф занимает \(Format.bytes(after))."))
}
}
@@ -430,25 +430,25 @@ final class SafeModel {
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
- panel.prompt = "Сохранить сюда"
- panel.message = "Куда положить копию заголовка сейфа. Лучше не на тот же диск: если он откажет, пропадут и сейф, и копия."
+ panel.prompt = tr("Сохранить сюда")
+ panel.message = tr("Куда положить копию заголовка сейфа. Лучше не на тот же диск: если он откажет, пропадут и сейф, и копия.")
guard panel.runModal() == .OK, let directory = panel.url else { return }
let vault = SecretsVault(imageURL: state.imageURL)
let sameDisk = directory.path.hasPrefix(state.imageURL.deletingLastPathComponent().path + "/")
- perform("Сохраняю копию заголовка…", app: app) {
+ perform(tr("Сохраняю копию заголовка…"), app: app) {
let url = try vault.backupHeader(to: directory)
- var details = ["Копия защищена тем же паролем, что и сейф. Без пароля она бесполезна."]
- if sameDisk { details.append("Копия лежит на том же диске, что и сейф: при отказе диска пропадут обе. Сохраните ещё одну в другом месте.") }
- return Notice.Message(.success, "Копия заголовка сохранена: \(url.path)", details: details)
+ var details = [tr("Копия защищена тем же паролем, что и сейф. Без пароля она бесполезна.")]
+ if sameDisk { details.append(tr("Копия лежит на том же диске, что и сейф: при отказе диска пропадут обе. Сохраните ещё одну в другом месте.")) }
+ return Notice.Message(.success, tr("Копия заголовка сохранена: \(url.path)"), details: details)
}
}
func restoreHeader(from file: URL, password: String, app: AppModel) {
guard let state, state.exists, state.mount == nil else { return }
let vault = SecretsVault(imageURL: state.imageURL)
- perform("Восстанавливаю заголовок…", app: app) {
+ perform(tr("Восстанавливаю заголовок…"), app: app) {
try vault.restoreHeader(from: file, password: password)
- return Notice.Message(.success, "Заголовок восстановлен из копии, сейф открывается паролем этой копии.")
+ return Notice.Message(.success, tr("Заголовок восстановлен из копии, сейф открывается паролем этой копии."))
}
}
@@ -470,13 +470,13 @@ final class SafeModel {
for (index, record) in records.enumerated() {
if token.isCancelled { break }
let name = URL(fileURLWithPath: record.originalPath).lastPathComponent
- migration = Migration(index: index + 1, count: records.count, item: name, phase: "Подготовка", bytesDone: 0, bytesTotal: 0)
+ migration = Migration(index: index + 1, count: records.count, item: name, phase: tr("Подготовка"), bytesDone: 0, bytesTotal: 0)
do {
_ = try await Task.detached(priority: .userInitiated) {
try SafeMover(rules: rules).relocate(record, into: safe, isCancelled: { token.isCancelled }) { progress in
guard throttle.ready() else { return }
Task { @MainActor in
- self.migration?.phase = progress.phase.rawValue
+ self.migration?.phase = trDynamic(progress.phase.rawValue)
self.migration?.bytesDone = progress.bytesDone
self.migration?.bytesTotal = progress.bytesTotal
}
@@ -486,18 +486,18 @@ final class SafeModel {
} catch is CancellationError {
break
} catch {
- failures.append("«\(name)»: \(error.localizedDescription)")
+ failures.append(tr("«\(name)»: \(error.localizedDescription)"))
}
}
migration = nil
migrationToken = nil
app.endOperation(operationID)
var details = failures
- details.append("Удалённые открытые копии физически могут оставаться в памяти SSD или флешки, пока контроллер их не перезапишет. Полную гарантию даёт только диск, зашифрованный целиком.")
+ details.append(tr("Удалённые открытые копии физически могут оставаться в памяти SSD или флешки, пока контроллер их не перезапишет. Полную гарантию даёт только диск, зашифрованный целиком."))
message = Notice.Message(failures.isEmpty ? .success : .warning,
token.isCancelled
- ? "Остановлено. В сейф перенесено: \(done) из \(records.count), остальное осталось на месте как было."
- : "В сейф перенесено и сверено: \(done) из \(records.count). Открытые копии удалены.",
+ ? tr("Остановлено. В сейф перенесено: \(done) из \(records.count), остальное осталось на месте как было.")
+ : tr("В сейф перенесено и сверено: \(done) из \(records.count). Открытые копии удалены."),
details: details)
app.history.reload(volumes: app.historyVolumes)
app.refreshVolumes()
diff --git a/Sources/Offload/UpdatesModel.swift b/Sources/Offload/UpdatesModel.swift
index 2bceabc..8bd2844 100644
--- a/Sources/Offload/UpdatesModel.swift
+++ b/Sources/Offload/UpdatesModel.swift
@@ -68,18 +68,18 @@ final class UpdatesModel {
do {
let release = try await UpdateCheck.fetch()
guard let current = Self.currentVersion else {
- if manual { checkResult = "У сборки для разработки нет номера версии. Последний выпуск — \(release.version)." }
+ if manual { checkResult = tr("У сборки для разработки нет номера версии. Последний выпуск — \(release.version).") }
return
}
guard UpdateCheck.isNewer(release.version, than: current) else {
available = nil
- if manual { checkResult = "Установлена последняя версия — \(current)." }
+ if manual { checkResult = tr("Установлена последняя версия — \(current).") }
return
}
if manual || UserDefaults.standard.string(forKey: Self.postponedKey) != release.version { available = release }
if manual { isPresented = true }
} catch {
- if manual { checkResult = "Не удалось проверить: \(error.localizedDescription)" }
+ if manual { checkResult = tr("Не удалось проверить: \(error.localizedDescription)") }
}
}
diff --git a/Sources/Offload/Views/AssistantView.swift b/Sources/Offload/Views/AssistantView.swift
index e2c31f9..8aecdba 100644
--- a/Sources/Offload/Views/AssistantView.swift
+++ b/Sources/Offload/Views/AssistantView.swift
@@ -5,9 +5,9 @@ import SwiftUI
extension Importance {
var groupTitle: String {
switch self {
- case .junk: return "Мусор"
- case .minor: return "Менее важно"
- case .important: return "Важно"
+ case .junk: return tr("Мусор")
+ case .minor: return tr("Менее важно")
+ case .important: return tr("Важно")
}
}
@@ -41,8 +41,8 @@ struct AssistantView: View {
let model = app.assistant
PageScroll {
VStack(alignment: .leading, spacing: 8) {
- Text("Помощник").font(Theme.display(30))
- Text("Смотрит на папку и говорит, что в ней важно, что менее важно, а что мусор. Сам ничего не удаляет и не переносит — только советует, а решаете вы.")
+ Text(tr("Помощник")).font(Theme.display(30))
+ Text(tr("Смотрит на папку и говорит, что в ней важно, что менее важно, а что мусор. Сам ничего не удаляет и не переносит — только советует, а решаете вы."))
.foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
// Сначала — где думает помощник: от этого зависит, куда уйдут сведения, и согласие даётся на него.
@@ -55,7 +55,7 @@ struct AssistantView: View {
if let answer = model.answer { answerSection(answer) }
}
}
- .navigationTitle("Помощник")
+ .navigationTitle(tr("Помощник"))
.task(id: model.kind) { await model.check(app: app) }
.task {
// Снимки для README показывают ответ: в демонстрации он вымышленный и без сети.
@@ -72,21 +72,21 @@ struct AssistantView: View {
}) { target in
MoveSheet(source: target.item.url, onMoved: {
moved = true
- app.assistant.markDone(target.id, "в сейфе")
+ app.assistant.markDone(target.id, tr("в сейфе"))
})
}
- .confirmationDialog(trashing.map { "Отправить «\($0.item.url.lastPathComponent)» в Корзину?" } ?? "",
+ .confirmationDialog(trashing.map { tr("Отправить «\($0.item.url.lastPathComponent)» в Корзину?") } ?? "",
isPresented: Binding(get: { trashing != nil }, set: { if !$0 { trashing = nil } }),
presenting: trashing) { target in
- Button("В Корзину", role: .destructive) {
+ Button(tr("В Корзину"), role: .destructive) {
Task { trashProblem = await app.assistant.trash(target.id, app: app) }
}
- Button("Отмена", role: .cancel) {}
+ Button(tr("Отмена"), role: .cancel) {}
} message: { target in
- Text("\(Format.bytes(target.item.bytes)). Вернуть можно здесь же или из Корзины, пока её не очистили.")
+ Text(tr("\(Format.bytes(target.item.bytes)). Вернуть можно здесь же или из Корзины, пока её не очистили."))
}
- .alert("Не получилось", isPresented: Binding(get: { trashProblem != nil }, set: { if !$0 { trashProblem = nil } })) {
- Button("Понятно", role: .cancel) {}
+ .alert(tr("Не получилось"), isPresented: Binding(get: { trashProblem != nil }, set: { if !$0 { trashProblem = nil } })) {
+ Button(tr("Понятно"), role: .cancel) {}
} message: {
Text(trashProblem ?? "")
}
@@ -97,14 +97,14 @@ struct AssistantView: View {
private var consentCard: some View {
let model = app.assistant
return Card(spacing: 12) {
- CardTitle("Что уходит помощнику", systemImage: "hand.raised")
- Text("Имена и пути файлов и папок от домашней папки, их размеры и даты, пометки правил OffLoadAI и несколько имён внутри папок. Содержимое файлов — нет: начало небольших текстовых файлов уходит, только если вы сами это включите, и никогда — таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления. Файлы, которые лежат только в iCloud, не скачиваются. Фото, видео и документы никуда не уходят.")
+ CardTitle(tr("Что уходит помощнику"), systemImage: "hand.raised")
+ Text(tr("Имена и пути файлов и папок от домашней папки, их размеры и даты, пометки правил OffLoadAI и несколько имён внутри папок. Содержимое файлов — нет: начало небольших текстовых файлов уходит, только если вы сами это включите, и никогда — таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления. Файлы, которые лежат только в iCloud, не скачиваются. Фото, видео и документы никуда не уходят."))
.fixedSize(horizontal: false, vertical: true)
- Text("Куда: \(model.kind.destination)")
+ Text(tr("Куда: \(model.kind.destination)"))
.fontWeight(.medium).fixedSize(horizontal: false, vertical: true)
- Text("Остальной OffLoadAI работает без сети — кроме сообщений о новых версиях, если вы их включили. Согласие — отдельно для каждого варианта; отозвать его можно в любой момент.")
+ Text(tr("Остальной OffLoadAI работает без сети — кроме сообщений о новых версиях, если вы их включили. Согласие — отдельно для каждого варианта; отозвать его можно в любой момент."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
- Button("Согласен — включить «\(model.kind.title)»") { model.setConsent(true) }
+ Button(tr("Согласен — включить «\(model.kind.title)»")) { model.setConsent(true) }
.prominentButton()
.padding(.top, 4)
}
@@ -116,7 +116,7 @@ struct AssistantView: View {
let model = app.assistant
@Bindable var bindable = model
return Card(spacing: 12) {
- CardTitle("Где думает помощник", systemImage: "cpu")
+ CardTitle(tr("Где думает помощник"), systemImage: "cpu")
FlowLayout(spacing: 8, lineSpacing: 8) {
ForEach(AssistantModel.Kind.allCases) { kind in
if kind == model.kind {
@@ -132,9 +132,9 @@ struct AssistantView: View {
if model.kind == .apiKey, !Demo.isOn {
if model.hasApiKey {
HStack(spacing: 10) {
- Label("Ключ сохранён в связке ключей.", systemImage: "key.fill").font(.callout)
+ Label(tr("Ключ сохранён в связке ключей."), systemImage: "key.fill").font(.callout)
Spacer(minLength: 8)
- Button("Убрать ключ") {
+ Button(tr("Убрать ключ")) {
model.saveApiKey(nil)
Task { await model.check(app: app) }
}
@@ -143,7 +143,7 @@ struct AssistantView: View {
HStack(spacing: 8) {
SecureField("sk-ant-…", text: $apiKey)
.textFieldStyle(.roundedBorder)
- Button("Сохранить ключ") {
+ Button(tr("Сохранить ключ")) {
model.saveApiKey(apiKey)
apiKey = ""
Task { await model.check(app: app) }
@@ -153,7 +153,7 @@ struct AssistantView: View {
}
}
if model.kind == .local, !model.localModels.isEmpty {
- Picker("Модель", selection: Binding(get: { OllamaAssistant.choose(from: model.localModels, preferred: model.localModel) ?? "" },
+ Picker(tr("Модель"), selection: Binding(get: { OllamaAssistant.choose(from: model.localModels, preferred: model.localModel) ?? "" },
set: { bindable.localModel = $0 })) {
ForEach(model.localModels, id: \.self) { Text($0).tag($0) }
}
@@ -162,14 +162,14 @@ struct AssistantView: View {
}
if let problem = model.problem, !Demo.isOn {
Notice(.warning, problem)
- Button("Проверить снова") { Task { await model.check(app: app) } }
+ Button(tr("Проверить снова")) { Task { await model.check(app: app) } }
.disabled(model.isChecking)
}
if model.consent, !Demo.isOn {
- Button("Отозвать согласие на «\(model.kind.title)»") { model.setConsent(false) }
+ Button(tr("Отозвать согласие на «\(model.kind.title)»")) { model.setConsent(false) }
.buttonStyle(.link)
.font(.callout)
- .help("Сведения о файлах больше не уйдут сюда, пока вы снова не согласитесь")
+ .help(tr("Сведения о файлах больше не уйдут сюда, пока вы снова не согласитесь"))
}
}
}
@@ -178,29 +178,29 @@ struct AssistantView: View {
private var places: [(title: String, url: URL)] {
let home = app.rules.home
- return [("Загрузки", "Downloads"), ("Рабочий стол", "Desktop"), ("Документы", "Documents"), ("Фильмы", "Movies")]
- .map { ($0.0, home.appendingPathComponent($0.1, isDirectory: true)) } + [("Домашняя папка", home)]
+ return [(tr("Загрузки"), "Downloads"), (tr("Рабочий стол"), "Desktop"), (tr("Документы"), "Documents"), (tr("Фильмы"), "Movies")]
+ .map { ($0.0, home.appendingPathComponent($0.1, isDirectory: true)) } + [(tr("Домашняя папка"), home)]
}
private var askCard: some View {
let model = app.assistant
@Bindable var bindable = model
return Card(spacing: 12) {
- CardTitle("Какую папку разобрать", systemImage: "folder")
+ CardTitle(tr("Какую папку разобрать"), systemImage: "folder")
FlowLayout(spacing: 8, lineSpacing: 8) {
ForEach(places, id: \.title) { place in
Button(place.title) { model.run(place.url, question: question, app: app) }
}
- Button("Другая папка…", action: chooseFolder)
+ Button(tr("Другая папка…"), action: chooseFolder)
}
.disabled(model.isBusy)
- TextField("Вопрос помощнику (необязательно): например, «что из этого можно удалить?»", text: $question)
+ TextField(tr("Вопрос помощнику (необязательно): например, «что из этого можно удалить?»"), text: $question)
.textFieldStyle(.roundedBorder)
.disabled(model.isBusy)
VStack(alignment: .leading, spacing: 3) {
- Toggle("Показывать помощнику начало небольших текстовых файлов", isOn: $bindable.sendsPreviews)
+ Toggle(tr("Показывать помощнику начало небольших текстовых файлов"), isOn: $bindable.sendsPreviews)
.disabled(model.isBusy)
- Text("До 20 строк — так понятнее, что это за файл. Таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления, не уходят никогда.")
+ Text(tr("До 20 строк — так понятнее, что это за файл. Таблицы (.csv), файлы с ключами и те, где видны пароль, токен, номер карты или фраза восстановления, не уходят никогда."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
if model.isBusy {
@@ -208,10 +208,10 @@ struct AssistantView: View {
ProgressView().controlSize(.small)
Text(model.status ?? "").font(.callout).foregroundStyle(Theme.muted).lineLimit(1)
Spacer(minLength: 8)
- Button("Отменить") { model.cancel() }
+ Button(tr("Отменить")) { model.cancel() }
}
} else if let folder = model.folder, model.answer != nil {
- Button("Спросить ещё раз про «\(title(of: folder))»") { model.run(folder, question: question, app: app) }
+ Button(tr("Спросить ещё раз про «\(title(of: folder))»")) { model.run(folder, question: question, app: app) }
}
}
}
@@ -227,8 +227,8 @@ struct AssistantView: View {
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.directoryURL = app.rules.home
- panel.message = "Какую папку разобрать с помощником"
- panel.prompt = "Разобрать"
+ panel.message = tr("Какую папку разобрать с помощником")
+ panel.prompt = tr("Разобрать")
guard panel.runModal() == .OK, let url = panel.url else { return }
app.assistant.run(url, question: question, app: app)
}
@@ -244,7 +244,7 @@ struct AssistantView: View {
IconTile(systemImage: "lightbulb.fill", size: 32)
Text(answer.summary).fixedSize(horizontal: false, vertical: true).textSelection(.enabled)
}
- Text("Ответил: \(answer.provider)\(answer.costUSD.map { String(format: " · $%.2f", $0) } ?? ""). Помощник может ошибаться — решение за вами.")
+ Text(tr("Ответил: \(answer.provider)\(answer.costUSD.map { String(format: " · $%.2f", $0) } ?? ""). Помощник может ошибаться — решение за вами."))
.font(.caption).foregroundStyle(Theme.muted)
.padding(.leading, 44)
}
@@ -288,7 +288,7 @@ struct AssistantView: View {
}
.rowPadding()
.contextMenu {
- if let item { Button("Показать в Finder") { revealInFinder(item.url) } }
+ if let item { Button(tr("Показать в Finder")) { revealInFinder(item.url) } }
}
}
@@ -299,25 +299,25 @@ struct AssistantView: View {
VStack(alignment: .trailing, spacing: 4) {
Label(outcome, systemImage: "checkmark").font(.callout).foregroundStyle(Theme.ok)
if model.canPutBack(advice.id) {
- Button("Вернуть") { Task { trashProblem = await model.putBack(advice.id, app: app) } }
+ Button(tr("Вернуть")) { Task { trashProblem = await model.putBack(advice.id, app: app) } }
.controlSize(.small)
- .help("Вернуть из Корзины на прежнее место")
+ .help(tr("Вернуть из Корзины на прежнее место"))
}
}
} else if let item, advice.action != .keep {
if advice.action == .safe {
- Button("В сейф…") {
- if Demo.isOn { model.markDone(advice.id, "в сейфе") } else { moving = Target(id: advice.id, item: item) }
+ Button(tr("В сейф…")) {
+ if Demo.isOn { model.markDone(advice.id, tr("в сейфе")) } else { moving = Target(id: advice.id, item: item) }
}
.controlSize(.small)
- .help("Перенос со сверкой каждого файла; вернуть можно в «Перенесённом»")
+ .help(tr("Перенос со сверкой каждого файла; вернуть можно в «Перенесённом»"))
} else {
- Button("В Корзину") { trashing = Target(id: advice.id, item: item) }
+ Button(tr("В Корзину")) { trashing = Target(id: advice.id, item: item) }
.controlSize(.small)
- .help("Вернуть можно здесь же или из Корзины, пока её не очистили")
+ .help(tr("Вернуть можно здесь же или из Корзины, пока её не очистили"))
}
} else {
- Text("оставить").font(.caption).foregroundStyle(Theme.faint).padding(.top, 3)
+ Text(tr("оставить")).font(.caption).foregroundStyle(Theme.faint).padding(.top, 3)
}
}
}
diff --git a/Sources/Offload/Views/BackupView.swift b/Sources/Offload/Views/BackupView.swift
index 40fc713..065740a 100644
--- a/Sources/Offload/Views/BackupView.swift
+++ b/Sources/Offload/Views/BackupView.swift
@@ -7,28 +7,28 @@ struct BackupView: View {
var body: some View {
PageScroll {
- CardSection(title: "Что бэкапить", number: 1) {
+ CardSection(title: tr("Что бэкапить"), number: 1) {
sourcesSection
}
- CardSection(title: "Исключения",
- footer: "Эти папки восстанавливаются одной командой (npm install, pip install, сборка). Файлы с ключами и токенами — .env, .envrc, *.pem, *.key, *.p8, id_ed25519 — и папки .ssh, .gnupg, .aws в бэкап проектов не попадают никогда: для них раздел «Ключи и токены» ниже, и лежат они только в сейфе.") {
+ CardSection(title: tr("Исключения"),
+ footer: tr("Эти папки восстанавливаются одной командой (npm install, pip install, сборка). Файлы с ключами и токенами — .env, .envrc, *.pem, *.key, *.p8, id_ed25519 — и папки .ssh, .gnupg, .aws в бэкап проектов не попадают никогда: для них раздел «Ключи и токены» ниже, и лежат они только в сейфе.")) {
exclusionsSection
}
- CardSection(title: "Куда", number: 2) {
+ CardSection(title: tr("Куда"), number: 2) {
destinationSection
}
- CardSection(title: "Ключи и токены — только в сейф", number: 3) {
+ CardSection(title: tr("Ключи и токены — только в сейф"), number: 3) {
keysSection
}
}
- .navigationTitle("Бэкап")
+ .navigationTitle(tr("Бэкап"))
}
@ViewBuilder
private var sourcesSection: some View {
let model = app.backup
if model.sources.isEmpty {
- Text("Добавьте папки с проектами и документами.")
+ Text(tr("Добавьте папки с проектами и документами."))
.foregroundStyle(Theme.muted)
.rowPadding()
RowDivider()
@@ -42,13 +42,13 @@ struct BackupView: View {
Image(systemName: "minus.circle")
}
.buttonStyle(.borderless)
- .help("Убрать из бэкапа")
+ .help(tr("Убрать из бэкапа"))
.disabled(model.isRunning)
}
.rowPadding()
RowDivider()
}
- Button { model.addSources() } label: { Label("Добавить папки…", systemImage: "plus") }
+ Button { model.addSources() } label: { Label(tr("Добавить папки…"), systemImage: "plus") }
.buttonStyle(.borderless)
.disabled(model.isRunning)
.rowPadding()
@@ -60,9 +60,9 @@ struct BackupView: View {
@Bindable var bindable = model
VStack(alignment: .leading, spacing: 10) {
HStack {
- Text("Не копировать папки и файлы с именами")
+ Text(tr("Не копировать папки и файлы с именами"))
Spacer()
- Button(editingExclusions ? "Готово" : "Изменить") { editingExclusions.toggle() }
+ Button(editingExclusions ? tr("Готово") : tr("Изменить")) { editingExclusions.toggle() }
.controlSize(.small)
}
if editingExclusions {
@@ -71,7 +71,7 @@ struct BackupView: View {
.textFieldStyle(.roundedBorder)
.lineLimit(2...6)
} else if model.excludedNames.isEmpty {
- Text("Ничего не пропускается.").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("Ничего не пропускается.")).font(.callout).foregroundStyle(Theme.muted)
} else {
FlowLayout(spacing: 6) {
ForEach(model.excludedNames.sorted(), id: \.self) { Chip(text: $0) }
@@ -87,15 +87,15 @@ struct BackupView: View {
VStack(alignment: .leading, spacing: 12) {
TargetSummary()
if let volume = app.target {
- InfoRow(title: "Папка") {
+ InfoRow(title: tr("Папка")) {
HStack {
Text(model.destination(on: volume).path).lineLimit(1).truncationMode(.head).textSelection(.enabled)
- Button("Выбрать…") { model.chooseDestination(on: volume) }.disabled(model.isRunning)
+ Button(tr("Выбрать…")) { model.chooseDestination(on: volume) }.disabled(model.isRunning)
}
}
.font(.callout)
if !volume.isEncryptedImage {
- Notice(.warning, "Бэкап ляжет на диск открыто: кто получит диск, прочтёт ваши проекты. Чтобы зашифровать, выберите внизу боковой панели «В сейф».")
+ Notice(.warning, tr("Бэкап ляжет на диск открыто: кто получит диск, прочтёт ваши проекты. Чтобы зашифровать, выберите внизу боковой панели «В сейф»."))
}
if model.isRunning {
HStack {
@@ -103,12 +103,12 @@ struct BackupView: View {
Text(model.currentItem).lineLimit(1).truncationMode(.middle).foregroundStyle(Theme.muted)
Spacer()
Text(Format.bytes(model.copiedBytes)).monospacedDigit()
- Button("Остановить") { model.cancel() }
+ Button(tr("Остановить")) { model.cancel() }
}
} else {
Button { model.run(on: volume, app: app) } label: {
HStack(spacing: 6) {
- Label("Обновить бэкап", systemImage: "arrow.triangle.2.circlepath")
+ Label(tr("Обновить бэкап"), systemImage: "arrow.triangle.2.circlepath")
if !app.pro.allows(.projectBackup) { ProTag() }
}
}
@@ -117,9 +117,9 @@ struct BackupView: View {
}
if let report = model.report {
Notice(report.problems.isEmpty ? .success : .warning,
- "Скопировано \(report.copied) (\(Format.bytes(report.bytesCopied))), без изменений \(report.unchanged). Секретов пропущено: \(report.secretsSkipped.count). Проблем: \(report.problems.count).")
+ tr("Скопировано \(report.copied) (\(Format.bytes(report.bytesCopied))), без изменений \(report.unchanged). Секретов пропущено: \(report.secretsSkipped.count). Проблем: \(report.problems.count)."))
if !report.problems.isEmpty {
- DisclosureGroup("Проблемы") {
+ DisclosureGroup(tr("Проблемы")) {
ForEach(report.problems.prefix(100), id: \.self) { Text($0).font(.caption).textSelection(.enabled) }
}
}
@@ -136,24 +136,24 @@ struct BackupView: View {
private var keysSection: some View {
let model = app.backup
VStack(alignment: .leading, spacing: 12) {
- Text("~/.ssh, учётка GitHub CLI, дотфайлы с токенами (.zshrc, .npmrc, .netrc…), базы KeePass и .env/.key/.pem из папок бэкапа. Складываются с сохранением путей; инструкция по восстановлению лежит в сейфе рядом.")
+ Text(tr("~/.ssh, учётка GitHub CLI, дотфайлы с токенами (.zshrc, .npmrc, .netrc…), базы KeePass и .env/.key/.pem из папок бэкапа. Складываются с сохранением путей; инструкция по восстановлению лежит в сейфе рядом."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
if app.safeVolume != nil {
HStack {
- Button { model.putKeys(app: app) } label: { Label("Сложить в сейф", systemImage: "key.horizontal") }
+ Button { model.putKeys(app: app) } label: { Label(tr("Сложить в сейф"), systemImage: "key.horizontal") }
.disabled(model.keysBusy)
if model.keysBusy { ProgressView().controlSize(.small) }
}
} else if app.safe.state?.isEncrypted == true {
SafeUnlockRow().frame(maxWidth: 440)
} else if app.destination != nil {
- Button("Создать сейф…") { app.section = .safe }
+ Button(tr("Создать сейф…")) { app.section = .safe }
} else {
- Text("Подключите внешний диск.").foregroundStyle(Theme.muted)
+ Text(tr("Подключите внешний диск.")).foregroundStyle(Theme.muted)
}
if let message = model.keysMessage { Notice(message) }
if let report = model.keysReport, !report.unprotectedKeys.isEmpty {
- Notice(.warning, "SSH-ключи без парольной фразы: \(report.unprotectedKeys.joined(separator: ", ")). Любой, кто получит сам файл ключа, сразу сможет им пользоваться. Добавьте фразу: ssh-keygen -p -f ~/.ssh/<ключ>")
+ Notice(.warning, tr("SSH-ключи без парольной фразы: \(report.unprotectedKeys.joined(separator: ", ")). Любой, кто получит сам файл ключа, сразу сможет им пользоваться. Добавьте фразу: ssh-keygen -p -f ~/.ssh/<ключ>"))
}
}
.padding(Theme.cardPadding)
diff --git a/Sources/Offload/Views/CleanupView.swift b/Sources/Offload/Views/CleanupView.swift
index 4f62bcc..e4136aa 100644
--- a/Sources/Offload/Views/CleanupView.swift
+++ b/Sources/Offload/Views/CleanupView.swift
@@ -5,10 +5,10 @@ import SwiftUI
extension CleanupAction {
var title: String {
switch self {
- case .trash: return "В Корзину"
- case .safe: return "В сейф"
- case .backup: return "В бэкап"
- case .keep: return "Оставить"
+ case .trash: return tr("В Корзину")
+ case .safe: return tr("В сейф")
+ case .backup: return tr("В бэкап")
+ case .keep: return tr("Оставить")
}
}
@@ -34,11 +34,11 @@ extension CleanupAction {
extension CleanupModule {
var title: String {
switch self {
- case .junk: return "Мусор"
- case .safe: return "Крупное и старое"
- case .duplicates: return "Лишние копии"
- case .installers: return "Установщики"
- case .projects: return "Проекты без бэкапа"
+ case .junk: return tr("Мусор")
+ case .safe: return tr("Крупное и старое")
+ case .duplicates: return tr("Лишние копии")
+ case .installers: return tr("Установщики")
+ case .projects: return tr("Проекты без бэкапа")
}
}
@@ -81,12 +81,12 @@ extension CleanupQuestion {
var title: String {
switch kind {
- case .module(.junk): return "Удалить мусор?"
- case .module(.duplicates): return "Удалить лишние копии?"
- case .module(.installers): return "Удалить старые установщики?"
- case .module(.safe): return "Убрать в сейф крупное и старое?"
- case .module(.projects): return "Добавить проекты в бэкап?"
- case .docker: return "Очистить Docker?"
+ case .module(.junk): return tr("Удалить мусор?")
+ case .module(.duplicates): return tr("Удалить лишние копии?")
+ case .module(.installers): return tr("Удалить старые установщики?")
+ case .module(.safe): return tr("Убрать в сейф крупное и старое?")
+ case .module(.projects): return tr("Добавить проекты в бэкап?")
+ case .docker: return tr("Очистить Docker?")
}
}
@@ -94,52 +94,52 @@ extension CleanupQuestion {
var text: String {
switch kind {
case .module(.junk):
- return "\(Self.list(labels, quoted: false)) — программы создадут это заново."
+ return tr("\(Self.list(labels, quoted: false)) — программы создадут это заново.")
case .module(.duplicates):
let groups = Set(items.compactMap(\.duplicateGroup)).count
- return "\(items.count) \(pluralRu(items.count, "лишняя копия", "лишние копии", "лишних копий")) одинаковых файлов (\(groups) \(pluralRu(groups, "группа", "группы", "групп"))): \(Self.list(labels, quoted: true)). У каждого файла останется одна копия — та, что лежит на своём месте, — и каждая лишняя перед удалением сверяется с ней байт в байт."
+ return tr("\(items.count) \(pluralRu(items.count, tr("лишняя копия"), tr("лишние копии"), tr("лишних копий"))) одинаковых файлов (\(groups) \(pluralRu(groups, tr("группа"), tr("группы"), tr("групп")))): \(Self.list(labels, quoted: true)). У каждого файла останется одна копия — та, что лежит на своём месте, — и каждая лишняя перед удалением сверяется с ней байт в байт.")
case .module(.installers):
- return "\(Self.list(labels, quoted: true)) — .dmg, .pkg и .xip старше недели. Если программа понадобится снова, установщик можно скачать. Посмотрите список ниже: «Разрешить всё» установщики не удаляет, только ответ здесь."
+ return tr("\(Self.list(labels, quoted: true)) — .dmg, .pkg и .xip старше недели. Если программа понадобится снова, установщик можно скачать. Посмотрите список ниже: «Разрешить всё» установщики не удаляет, только ответ здесь.")
case .module(.safe):
- return "\(Self.list(labels, quoted: true)) — не менялось больше трёх месяцев. Перенесу в сейф со сверкой каждого файла и уберу с Mac; вернуть можно в «Перенесённом»."
+ return tr("\(Self.list(labels, quoted: true)) — не менялось больше трёх месяцев. Перенесу в сейф со сверкой каждого файла и уберу с Mac; вернуть можно в «Перенесённом».")
case .module(.projects):
- return "\(Self.list(labels, quoted: true)) — папки с git, которых нет в бэкапе. Ничего не удаляется: они только добавятся в список папок бэкапа."
+ return tr("\(Self.list(labels, quoted: true)) — папки с git, которых нет в бэкапе. Ничего не удаляется: они только добавятся в список папок бэкапа.")
case .docker:
var parts: [String] = []
- if let cache = docker[.buildCache] { parts.append("кеш сборки (\(Format.bytes(cache)))") }
- if docker[.danglingImages] != nil { parts.append("образы без имени — остатки пересборок") }
- let what = parts.joined(separator: " и ")
- return "\(what.prefix(1).uppercased() + what.dropFirst()). Кеш наберётся при следующей сборке. Образы с именем, тома с данными и контейнеры не трогаю; все неиспользуемые образы можно убрать в разделе «Docker»."
+ if let cache = docker[.buildCache] { parts.append(tr("кеш сборки (\(Format.bytes(cache)))")) }
+ if docker[.danglingImages] != nil { parts.append(tr("образы без имени — остатки пересборок")) }
+ let what = parts.joined(separator: tr(" и "))
+ return tr("\(what.prefix(1).uppercased() + what.dropFirst()). Кеш наберётся при следующей сборке. Образы с именем, тома с данными и контейнеры не трогаю; все неиспользуемые образы можно убрать в разделе «Docker».")
}
}
var yesTitle: String {
switch kind {
- case .module(.duplicates): return "Удалить копии"
- case .module(.safe): return "Убрать в сейф"
- case .module(.projects): return "Добавить"
- case .docker: return "Очистить"
- case .module: return "Удалить"
+ case .module(.duplicates): return tr("Удалить копии")
+ case .module(.safe): return tr("Убрать в сейф")
+ case .module(.projects): return tr("Добавить")
+ case .docker: return tr("Очистить")
+ case .module: return tr("Удалить")
}
}
- var noTitle: String { "Не сейчас" }
+ var noTitle: String { tr("Не сейчас") }
/// Размер справа: сколько освободится, у проектов — сколько папок.
var amount: String {
- if kind == .module(.projects) { return "\(items.count) \(pluralRu(items.count, "папка", "папки", "папок"))" }
+ if kind == .module(.projects) { return "\(items.count) \(pluralRu(items.count, tr("папка"), tr("папки"), tr("папок")))" }
return Format.bytes(bytes)
}
/// «Кеш npm, кеш Chrome и ещё 3» или «Датасеты», «Съёмки 2023» и ещё 2».
static func list(_ labels: [String], quoted: Bool) -> String {
let shown = labels.prefix(3).enumerated().map { index, label -> String in
- if quoted { return "«\(label)»" }
+ if quoted { return tr("«\(label)»") }
return index == 0 ? label : label.prefix(1).lowercased() + label.dropFirst()
}
let rest = labels.count - shown.count
let head = shown.joined(separator: ", ")
- return rest > 0 ? "\(head) и ещё \(rest)" : head
+ return rest > 0 ? tr("\(head) и ещё \(rest)") : head
}
}
@@ -158,7 +158,7 @@ struct CleanupView: View {
let model = app.cleanup
PageScroll {
if let problem = model.storeProblem {
- Notice(.warning, "Решения не запоминаются: \(problem)")
+ Notice(.warning, tr("Решения не запоминаются: \(problem)"))
}
switch model.stage {
case .idle:
@@ -169,7 +169,7 @@ struct CleanupView: View {
review
}
}
- .navigationTitle("Разобрать")
+ .navigationTitle(tr("Разобрать"))
.task {
model.loadHabits(home: app.rules.home)
// В демонстрации сразу показываем вопросы — снимку экрана нечего ждать.
@@ -180,15 +180,15 @@ struct CleanupView: View {
model.answer(.module(.safe), yes: true, app: app)
}
}
- .confirmationDialog("Закончить разбор?", isPresented: Binding(get: { leaving != nil }, set: { if !$0 { leaving = nil } })) {
- Button("Закончить") {
+ .confirmationDialog(tr("Закончить разбор?"), isPresented: Binding(get: { leaving != nil }, set: { if !$0 { leaving = nil } })) {
+ Button(tr("Закончить")) {
let scan = leaving == true
leaving = nil
finishLeaving(thenScan: scan)
}
- Button("Остаться", role: .cancel) { leaving = nil }
+ Button(tr("Остаться"), role: .cancel) { leaving = nil }
} message: {
- Text("В Корзине лежит \(model.trashedItems.count) объектов, отправленных этим разбором. Кнопки «Вернуть» у вопросов после этого не будет: вернуть их можно будет только вручную из Корзины.")
+ Text(tr("В Корзине лежит \(model.trashedItems.count) объектов, отправленных этим разбором. Кнопки «Вернуть» у вопросов после этого не будет: вернуть их можно будет только вручную из Корзины."))
}
}
@@ -231,19 +231,19 @@ struct CleanupView: View {
/// Где разбор ищет — чтобы было видно, что личное в ~/Library он не трогает.
private static let places: [(symbol: String, title: String)] = [
- ("arrow.down.circle", "Загрузки"), ("menubar.dock.rectangle", "Рабочий стол"), ("doc", "Документы"),
- ("film", "Фильмы"), ("music.note", "Музыка"), ("photo", "Изображения"),
- ("folder", "Свои папки в домашней"), ("hammer", "Кеши программ"), ("shippingbox", "Docker"),
+ ("arrow.down.circle", tr("Загрузки")), ("menubar.dock.rectangle", tr("Рабочий стол")), ("doc", tr("Документы")),
+ ("film", tr("Фильмы")), ("music.note", tr("Музыка")), ("photo", tr("Изображения")),
+ ("folder", tr("Свои папки в домашней")), ("hammer", tr("Кеши программ")), ("shippingbox", "Docker"),
]
/// О чём спрошу и что будет по «да».
private static let kinds: [(symbol: String, tone: Tone, title: String, detail: String, outcome: String)] = [
- ("trash.fill", .brand, "Мусор", "Кеши и скачанные пакеты — программы создадут их заново.", "в Корзину"),
- ("shippingbox.fill", .info, "Docker", "Кеш сборки и образы без имени. Образы с именем и тома с данными не трогаю.", "удалит Docker"),
- ("doc.on.doc.fill", .caution, "Лишние копии", "Одинаковые файлы — одна копия каждого остаётся всегда.", "в Корзину"),
- ("arrow.down.app.fill", .info, "Установщики", ".dmg, .pkg и .xip старше недели — только отдельным «да».", "в Корзину"),
- ("lock.shield.fill", .good, "Крупное и старое", "Не менялось больше трёх месяцев — со сверкой каждого файла.", "в сейф"),
- ("externaldrive.badge.checkmark", .brand, "Проекты без бэкапа", "Папки с git — ничего не удаляется.", "в бэкап"),
+ ("trash.fill", .brand, tr("Мусор"), tr("Кеши и скачанные пакеты — программы создадут их заново."), tr("в Корзину")),
+ ("shippingbox.fill", .info, "Docker", tr("Кеш сборки и образы без имени. Образы с именем и тома с данными не трогаю."), tr("удалит Docker")),
+ ("doc.on.doc.fill", .caution, tr("Лишние копии"), tr("Одинаковые файлы — одна копия каждого остаётся всегда."), tr("в Корзину")),
+ ("arrow.down.app.fill", .info, tr("Установщики"), tr(".dmg, .pkg и .xip старше недели — только отдельным «да»."), tr("в Корзину")),
+ ("lock.shield.fill", .good, tr("Крупное и старое"), tr("Не менялось больше трёх месяцев — со сверкой каждого файла."), tr("в сейф")),
+ ("externaldrive.badge.checkmark", .brand, tr("Проекты без бэкапа"), tr("Папки с git — ничего не удаляется."), tr("в бэкап")),
]
private var start: some View {
@@ -252,8 +252,8 @@ struct CleanupView: View {
return VStack(alignment: .leading, spacing: Theme.sectionSpacing) {
HStack(alignment: .center, spacing: 24) {
VStack(alignment: .leading, spacing: 12) {
- Text("Разобрать Mac").font(Theme.display(30))
- Text("Найду, что занимает место зря, и спрошу про каждое: удалить или нет. Выбирать файлы и ходить по папкам не нужно — только отвечать «да» или «не сейчас». Без вашего «да» ничего не трогаю.")
+ Text(tr("Разобрать Mac")).font(Theme.display(30))
+ Text(tr("Найду, что занимает место зря, и спрошу про каждое: удалить или нет. Выбирать файлы и ходить по папкам не нужно — только отвечать «да» или «не сейчас». Без вашего «да» ничего не трогаю."))
.foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
FlowLayout(spacing: 6) {
ForEach(Self.places, id: \.title) { place in
@@ -266,7 +266,7 @@ struct CleanupView: View {
}
}
Spacer(minLength: 0)
- ScanButton(title: "Начать", action: beginScan)
+ ScanButton(title: tr("Начать"), action: beginScan)
}
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -275,11 +275,11 @@ struct CleanupView: View {
.overlay { shape.strokeBorder(Theme.brand.opacity(0.25)) }
if let run = model.lastRun {
- CardSection(title: "Прошлый разбор — \(run.date.formatted(date: .abbreviated, time: .shortened))") {
+ CardSection(title: tr("Прошлый разбор — \(run.date.formatted(date: .abbreviated, time: .shortened))")) {
HStack(alignment: .top, spacing: 16) {
- result(Format.bytes(run.trashedBytes), "удалено")
- result(Format.bytes(run.movedBytes), "убрано в сейф")
- result("\(run.addedToBackup)", "добавлено в бэкап")
+ result(Format.bytes(run.trashedBytes), tr("удалено"))
+ result(Format.bytes(run.movedBytes), tr("убрано в сейф"))
+ result("\(run.addedToBackup)", tr("добавлено в бэкап"))
}
.padding(Theme.cardPadding)
}
@@ -295,8 +295,8 @@ struct CleanupView: View {
}
private var legend: some View {
- CardSection(title: "О чём спрошу",
- footer: "Удаляемое уходит в Корзину — вернуть можно одной кнопкой у своего вопроса, а в конце можно удалить это из Корзины насовсем. Образы Docker удаляет сам Docker, он скачает их снова. В сейф — со сверкой каждого файла; вернуть можно в «Перенесённом».") {
+ CardSection(title: tr("О чём спрошу"),
+ footer: tr("Удаляемое уходит в Корзину — вернуть можно одной кнопкой у своего вопроса, а в конце можно удалить это из Корзины насовсем. Образы Docker удаляет сам Docker, он скачает их снова. В сейф — со сверкой каждого файла; вернуть можно в «Перенесённом».")) {
ForEach(Self.kinds, id: \.title) { kind in
HStack(spacing: 12) {
IconTile(systemImage: kind.symbol, tone: kind.tone)
@@ -317,15 +317,15 @@ struct CleanupView: View {
/// Чему OffLoadAI научился на решениях человека — и кнопка, чтобы всё это забыть.
private var learned: some View {
let model = app.cleanup
- return CardSection(title: "Чему научился",
- footer: "Учусь только на этом Mac и только на ваших ответах. Что вы вернули из Корзины, больше не предлагаю; похожее на то, что вы обычно убираете в сейф, добавляю в вопрос о сейфе. Сам ничего не делаю — только спрашиваю.") {
+ return CardSection(title: tr("Чему научился"),
+ footer: tr("Учусь только на этом Mac и только на ваших ответах. Что вы вернули из Корзины, больше не предлагаю; похожее на то, что вы обычно убираете в сейф, добавляю в вопрос о сейфе. Сам ничего не делаю — только спрашиваю.")) {
if !app.pro.allows(.habits) {
HStack(spacing: 12) {
IconTile(systemImage: "sparkles", tone: .neutral)
- Text("Привычки действуют в OffLoadAI Pro. Ваши ответы запоминаются и сейчас — с Pro похожее сразу начнёт попадать в нужный вопрос.")
+ Text(tr("Привычки действуют в OffLoadAI Pro. Ваши ответы запоминаются и сейчас — с Pro похожее сразу начнёт попадать в нужный вопрос."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
- Button("Подробнее…") { app.pro.offer(.habits) }
+ Button(tr("Подробнее…")) { app.pro.offer(.habits) }
}
.rowPadding()
RowDivider(inset: 54)
@@ -333,7 +333,7 @@ struct CleanupView: View {
if model.habits.isEmpty {
HStack(alignment: .top, spacing: 12) {
IconTile(systemImage: "sparkles", tone: .neutral)
- Text("Пока привычек нет. Привычка появляется, когда вы хотя бы трижды одинаково решаете похожее — например, убираете в сейф старые съёмки из «Фильмов». Тогда похожие папки сами попадут в вопрос о сейфе.")
+ Text(tr("Пока привычек нет. Привычка появляется, когда вы хотя бы трижды одинаково решаете похожее — например, убираете в сейф старые съёмки из «Фильмов». Тогда похожие папки сами попадут в вопрос о сейфе."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
.rowPadding()
@@ -343,11 +343,11 @@ struct CleanupView: View {
IconTile(systemImage: habit.action.symbol, tone: habit.action.tone)
VStack(alignment: .leading, spacing: 2) {
Text(habit.scope.prefix(1).uppercased() + habit.scope.dropFirst()).fontWeight(.medium)
- Text("обычно \(HabitModel.Prediction.verb(habit.action))").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("обычно \(HabitModel.Prediction.verb(habit.action))")).font(.callout).foregroundStyle(Theme.muted)
}
Spacer(minLength: 12)
- Text("\(habit.agreeing) из \(habit.total)").font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
- .help("Столько похожих решений за это действие из всех похожих")
+ Text(tr("\(habit.agreeing) из \(habit.total)")).font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
+ .help(tr("Столько похожих решений за это действие из всех похожих"))
}
.rowPadding()
if habit != model.habits.last || model.remembered > 0 { RowDivider(inset: 54) }
@@ -355,42 +355,42 @@ struct CleanupView: View {
if model.remembered > 0 {
if model.habits.isEmpty { RowDivider(inset: 54) }
HStack(spacing: 12) {
- Text("Помню ваши ответы для \(model.remembered) \(pluralRu(model.remembered, "объекта", "объектов", "объектов"))")
+ Text(tr("Помню ваши ответы для \(model.remembered) \(pluralRu(model.remembered, tr("объекта"), tr("объектов"), tr("объектов")))"))
.font(.callout).foregroundStyle(Theme.muted)
Spacer(minLength: 12)
- Button("Забыть мои решения…") { forgetting = true }
+ Button(tr("Забыть мои решения…")) { forgetting = true }
}
.rowPadding()
}
if let problem = model.forgetProblem {
- Notice(.warning, "Забыть не получилось: \(problem)").padding(Theme.cardPadding)
+ Notice(.warning, tr("Забыть не получилось: \(problem)")).padding(Theme.cardPadding)
}
}
- .confirmationDialog("Забыть ваши решения?", isPresented: $forgetting) {
- Button("Забыть", role: .destructive) { model.forgetDecisions(home: app.rules.home) }
- Button("Отмена", role: .cancel) {}
+ .confirmationDialog(tr("Забыть ваши решения?"), isPresented: $forgetting) {
+ Button(tr("Забыть"), role: .destructive) { model.forgetDecisions(home: app.rules.home) }
+ Button(tr("Отмена"), role: .cancel) {}
} message: {
- Text("OffLoadAI забудет ваши ответы по каждой папке и файлу и привычки, выученные на них. Итоги прошлых разборов и то, что вы просили не предлагать, останутся.")
+ Text(tr("OffLoadAI забудет ваши ответы по каждой папке и файлу и привычки, выученные на них. Итоги прошлых разборов и то, что вы просили не предлагать, останутся."))
}
}
/// То, что человек просил больше не предлагать, — с возможностью вернуть.
private var ignoredSection: some View {
let model = app.cleanup
- return CardSection(title: "Не предлагаю — \(model.ignored.count)",
- footer: "Сюда попадает то, что вы попросили больше не предлагать: «Что именно» у вопроса → правый щелчок по строке → «Не предлагать больше». Папка — вместе со всем, что внутри.") {
+ return CardSection(title: tr("Не предлагаю — \(model.ignored.count)"),
+ footer: tr("Сюда попадает то, что вы попросили больше не предлагать: «Что именно» у вопроса → правый щелчок по строке → «Не предлагать больше». Папка — вместе со всем, что внутри.")) {
ForEach(model.ignored, id: \.self) { path in
HStack(spacing: 12) {
IconTile(systemImage: "eye.slash", tone: .neutral)
Text(relativeToHome(path, home: app.rules.home)).lineLimit(1).truncationMode(.middle)
Spacer(minLength: 12)
- Button("Снова предлагать") { model.unignore(path) }
+ Button(tr("Снова предлагать")) { model.unignore(path) }
}
.rowPadding()
if path != model.ignored.last { RowDivider(inset: 54) }
}
if let problem = model.ignoreProblem {
- Notice(.warning, "Не получилось: \(problem)").padding(Theme.cardPadding)
+ Notice(.warning, tr("Не получилось: \(problem)")).padding(Theme.cardPadding)
}
}
}
@@ -408,8 +408,8 @@ struct CleanupView: View {
.frame(width: 56, height: 56)
.background(Theme.brand.opacity(0.14), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
VStack(alignment: .leading, spacing: 4) {
- Text(progress.duplicates ? "Ищу одинаковые файлы"
- : progress.total == 0 ? "Собираю, что посмотреть…" : "Смотрю, что занимает место")
+ Text(progress.duplicates ? tr("Ищу одинаковые файлы")
+ : progress.total == 0 ? tr("Собираю, что посмотреть…") : tr("Смотрю, что занимает место"))
.font(Theme.display(19))
Text(progress.current.isEmpty ? " " : progress.current)
.font(.callout).foregroundStyle(Theme.muted)
@@ -417,13 +417,13 @@ struct CleanupView: View {
}
Spacer(minLength: 12)
if progress.duplicates {
- Text("\(progress.files) \(pluralRu(progress.files, "файл", "файла", "файлов"))")
+ Text("\(progress.files) \(pluralRu(progress.files, tr("файл"), tr("файла"), tr("файлов")))")
.font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
} else if progress.total > 0 {
- Text("\(progress.done) из \(progress.total)")
+ Text(tr("\(progress.done) из \(progress.total)"))
.font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
}
- Button("Отменить") { app.cleanup.cancel() }
+ Button(tr("Отменить")) { app.cleanup.cancel() }
}
if progress.duplicates {
// Сколько файлов впереди, заранее неизвестно — полоса без конца.
@@ -453,7 +453,7 @@ struct CleanupView: View {
let found = progress.found[module] ?? 0
let value: String
if module == .duplicates {
- value = progress.duplicates ? "ищу…" : "после замера"
+ value = progress.duplicates ? tr("ищу…") : tr("после замера")
} else if module == .projects {
value = "\(found)"
} else {
@@ -480,10 +480,10 @@ struct CleanupView: View {
let model = app.cleanup
if model.questions.isEmpty {
Card(spacing: 12) {
- Label("Спрашивать не о чем: мусора и лишнего нет, а всё крупное либо используется, либо уже на своём месте.",
+ Label(tr("Спрашивать не о чем: мусора и лишнего нет, а всё крупное либо используется, либо уже на своём месте."),
systemImage: "checkmark.seal.fill")
.foregroundStyle(Theme.ok)
- Button("Готово") { leave() }
+ Button(tr("Готово")) { leave() }
}
dockerIdleCard
} else {
@@ -499,13 +499,13 @@ struct CleanupView: View {
ignore: { model.ignore($0) })
}
if let problem = model.ignoreProblem {
- Notice(.warning, "Не получилось запомнить «не предлагать»: \(problem)")
+ Notice(.warning, tr("Не получилось запомнить «не предлагать»: \(problem)"))
}
trashCard
if model.isSettled {
HStack {
- Button("Готово") { leave() }
- Button("Разобрать ещё раз") { leave(thenScan: true) }
+ Button(tr("Готово")) { leave() }
+ Button(tr("Разобрать ещё раз")) { leave(thenScan: true) }
}
.disabled(model.isBusy)
}
@@ -524,15 +524,15 @@ struct CleanupView: View {
HStack(alignment: .center, spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
if !open.isEmpty {
- Text("Можно освободить").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("Можно освободить")).font(.callout).foregroundStyle(Theme.muted)
Text(Format.bytes(model.pendingBytes))
.font(Theme.display(44)).monospacedDigit()
.contentTransition(.numericText())
- Text("\(open.count) \(pluralRu(open.count, "вопрос", "вопроса", "вопросов")) — на каждый ответьте «да» или «не сейчас». Удаляемое сначала уходит в Корзину.")
+ Text(tr("\(open.count) \(pluralRu(open.count, tr("вопрос"), tr("вопроса"), tr("вопросов"))) — на каждый ответьте «да» или «не сейчас». Удаляемое сначала уходит в Корзину."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} else {
- Text(model.isSettled ? "Готово" : "Делаю…").font(.callout).foregroundStyle(Theme.muted)
- Text(freed >= 100_000_000 ? "Освободилось \(Format.bytes(freed))" : "Ответили на всё")
+ Text(model.isSettled ? tr("Готово") : tr("Делаю…")).font(.callout).foregroundStyle(Theme.muted)
+ Text(freed >= 100_000_000 ? tr("Освободилось \(Format.bytes(freed))") : tr("Ответили на всё"))
.font(Theme.display(34)).monospacedDigit()
Text(settledLine)
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
@@ -543,7 +543,7 @@ struct CleanupView: View {
VStack(alignment: .trailing, spacing: 8) {
if !together.isEmpty {
Button(action: allowAll) {
- Label(togetherBytes > 0 ? "Разрешить всё · \(Format.bytes(togetherBytes))" : "Разрешить всё",
+ Label(togetherBytes > 0 ? tr("Разрешить всё · \(Format.bytes(togetherBytes))") : tr("Разрешить всё"),
systemImage: "checkmark.circle.fill")
.frame(minWidth: 150)
}
@@ -552,11 +552,11 @@ struct CleanupView: View {
// Без Return: одно нажатие отвечало «да» на все вопросы сразу, включая безвозвратную
// очистку Docker, — хотя человек мог нажать Return, ещё не прочитав их.
.help(open.count > together.count
- ? "Ответить «да» на все вопросы, кроме установщиков (их удаляю только по отдельному ответу) и вопросов из OffLoadAI Pro"
- : "Ответить «да» на все вопросы")
+ ? tr("Ответить «да» на все вопросы, кроме установщиков (их удаляю только по отдельному ответу) и вопросов из OffLoadAI Pro")
+ : tr("Ответить «да» на все вопросы"))
}
if !model.isSettled {
- Button("Отмена") { leave() }
+ Button(tr("Отмена")) { leave() }
.buttonStyle(InkLinkStyle())
.disabled(model.isBusy)
}
@@ -568,10 +568,10 @@ struct CleanupView: View {
private var settledLine: String {
let model = app.cleanup
if !model.trashedItems.isEmpty {
- return "Удалённое лежит в Корзине и занимает место, пока её не очистят, — ниже можно удалить это насовсем."
+ return tr("Удалённое лежит в Корзине и занимает место, пока её не очистят, — ниже можно удалить это насовсем.")
}
- if let now = model.freeNow { return "Свободно на Mac \(Format.bytes(now))." }
- return "Ответы запомнены: что вы вернули из Корзины, больше не предложу."
+ if let now = model.freeNow { return tr("Свободно на Mac \(Format.bytes(now)).") }
+ return tr("Ответы запомнены: что вы вернули из Корзины, больше не предложу.")
}
/// Docker стоит, но не запущен: что в нём можно убрать, узнать нельзя — сказать, как это исправить.
@@ -582,13 +582,13 @@ struct CleanupView: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "shippingbox.fill", tone: .neutral, size: 40)
VStack(alignment: .leading, spacing: 4) {
- Text("Docker не запущен").font(.headline)
- Text("Его диск занимает \(Format.bytes(idle)). Запустите Docker Desktop и разберите ещё раз — спрошу, что из него можно удалить.")
+ Text(tr("Docker не запущен")).font(.headline)
+ Text(tr("Его диск занимает \(Format.bytes(idle)). Запустите Docker Desktop и разберите ещё раз — спрошу, что из него можно удалить."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
if let docker = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.docker.docker") {
- Button("Запустить Docker") { NSWorkspace.shared.open(docker) }
+ Button(tr("Запустить Docker")) { NSWorkspace.shared.open(docker) }
}
}
}
@@ -606,8 +606,8 @@ struct CleanupView: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "xmark.bin.fill", tone: .danger, size: 40)
VStack(alignment: .leading, spacing: 4) {
- Text("Удалить из Корзины насовсем?").font(.headline)
- Text("В Корзине \(Format.bytes(inTrash)) из этого разбора: место на Mac освободится, только когда их удалят оттуда. Остальное в Корзине не трогаю. Вернуть удалённое насовсем будет нельзя.")
+ Text(tr("Удалить из Корзины насовсем?")).font(.headline)
+ Text(tr("В Корзине \(Format.bytes(inTrash)) из этого разбора: место на Mac освободится, только когда их удалят оттуда. Остальное в Корзине не трогаю. Вернуть удалённое насовсем будет нельзя."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
@@ -619,22 +619,22 @@ struct CleanupView: View {
Text(finishing).font(.callout).foregroundStyle(Theme.muted)
}
Spacer()
- Button(role: .destructive) { erasing = true } label: { Label("Удалить насовсем…", systemImage: "xmark.bin") }
+ Button(role: .destructive) { erasing = true } label: { Label(tr("Удалить насовсем…"), systemImage: "xmark.bin") }
.disabled(model.isBusy)
}
}
- .confirmationDialog("Удалить насовсем \(Format.bytes(inTrash))?", isPresented: $erasing) {
- Button("Удалить насовсем", role: .destructive) { model.eraseTrashed(app: app) }
- Button("Отмена", role: .cancel) {}
+ .confirmationDialog(tr("Удалить насовсем \(Format.bytes(inTrash))?"), isPresented: $erasing) {
+ Button(tr("Удалить насовсем"), role: .destructive) { model.eraseTrashed(app: app) }
+ Button(tr("Отмена"), role: .cancel) {}
} message: {
- Text("Из Корзины удалится только то, что туда отправил этот разбор. Вернуть это будет нельзя.")
+ Text(tr("Из Корзины удалится только то, что туда отправил этот разбор. Вернуть это будет нельзя."))
}
}
if model.erased > 0 {
- Notice(.success, "Удалено из Корзины насовсем: \(model.erased), \(Format.bytes(model.erasedBytes)).")
+ Notice(.success, tr("Удалено из Корзины насовсем: \(model.erased), \(Format.bytes(model.erasedBytes))."))
}
if !model.trashProblems.isEmpty {
- Notice(.warning, "Не всё получилось:", details: model.trashProblems)
+ Notice(.warning, tr("Не всё получилось:"), details: model.trashProblems)
}
}
@@ -714,25 +714,25 @@ struct QuestionCard: View {
}
.prominentButton()
.controlSize(.large)
- .help(app.cleanup.isLocked(question.kind, app: app) ? "Нужен OffLoadAI Pro — откроется окно с ценой и ключом" : "")
+ .help(app.cleanup.isLocked(question.kind, app: app) ? tr("Нужен OffLoadAI Pro — откроется окно с ценой и ключом") : "")
}
case .queued:
HStack(spacing: 10) {
ProgressView().controlSize(.small)
- Text("В очереди — начну, как только закончу предыдущее").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("В очереди — начну, как только закончу предыдущее")).font(.callout).foregroundStyle(Theme.muted)
Spacer(minLength: 12)
- Button("Отменить", action: reconsider)
+ Button(tr("Отменить"), action: reconsider)
}
case .running(let progress):
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
if progress.count > 1 {
- Text("\(max(progress.index, 1)) из \(progress.count)").monospacedDigit()
+ Text(tr("\(max(progress.index, 1)) из \(progress.count)")).monospacedDigit()
}
- Text(progress.item.isEmpty ? " " : "«\(progress.item)»").lineLimit(1).truncationMode(.middle)
+ Text(progress.item.isEmpty ? " " : tr("«\(progress.item)»")).lineLimit(1).truncationMode(.middle)
Spacer(minLength: 12)
Text(progress.phase).foregroundStyle(Theme.muted)
- Button("Остановить", action: stop)
+ Button(tr("Остановить"), action: stop)
}
.font(.callout)
ProgressView(value: min(Double(progress.count), Double(max(progress.index, 1) - 1) + progress.fraction),
@@ -746,24 +746,24 @@ struct QuestionCard: View {
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 12)
if !outcome.trashedItems.isEmpty {
- Button { restore() } label: { Label("Вернуть", systemImage: "arrow.uturn.backward") }
+ Button { restore() } label: { Label(tr("Вернуть"), systemImage: "arrow.uturn.backward") }
.disabled(app.cleanup.finishing != nil)
- .help("Вернуть из Корзины на прежние места — и больше не предлагать")
+ .help(tr("Вернуть из Корзины на прежние места — и больше не предлагать"))
}
if question.kind == .module(.projects), outcome.done > 0 {
- Button("Открыть «Бэкап»") { app.section = .backup }
+ Button(tr("Открыть «Бэкап»")) { app.section = .backup }
}
}
if !outcome.problems.isEmpty {
- Notice(.warning, "Не всё получилось:", details: outcome.problems)
+ Notice(.warning, tr("Не всё получилось:"), details: outcome.problems)
}
}
case .declined:
HStack(spacing: 10) {
- Text("Не трогаю. Спрошу в следующий раз.")
+ Text(tr("Не трогаю. Спрошу в следующий раз."))
.font(.callout).foregroundStyle(Theme.muted)
Spacer(minLength: 12)
- Button("Передумал", action: reconsider).buttonStyle(InkLinkStyle())
+ Button(tr("Передумал"), action: reconsider).buttonStyle(InkLinkStyle())
}
}
}
@@ -771,21 +771,21 @@ struct QuestionCard: View {
private func doneLine(_ outcome: CleanupModel.Outcome) -> String {
var parts: [String] = []
if outcome.done == 0 {
- parts.append(outcome.cancelled ? "Остановлено, ничего не сделано" : "Не получилось")
+ parts.append(outcome.cancelled ? tr("Остановлено, ничего не сделано") : tr("Не получилось"))
} else {
switch question.kind {
case .module(.projects):
- parts.append("Добавлено в бэкап: \(outcome.done). Обновите бэкап, чтобы они в него попали")
+ parts.append(tr("Добавлено в бэкап: \(outcome.done). Обновите бэкап, чтобы они в него попали"))
case .module(.safe):
- parts.append("Убрано в сейф: \(Format.bytes(outcome.bytes)), \(outcome.done) из \(question.items.count)")
+ parts.append(tr("Убрано в сейф: \(Format.bytes(outcome.bytes)), \(outcome.done) из \(question.items.count)"))
case .docker:
- parts.append(outcome.bytes > 0 ? "Docker удалил \(Format.bytes(outcome.bytes))" : "Docker очищен")
+ parts.append(outcome.bytes > 0 ? tr("Docker удалил \(Format.bytes(outcome.bytes))") : tr("Docker очищен"))
case .module:
- parts.append("В Корзине: \(Format.bytes(outcome.bytes)), \(outcome.done) из \(question.items.count)")
+ parts.append(tr("В Корзине: \(Format.bytes(outcome.bytes)), \(outcome.done) из \(question.items.count)"))
}
- if outcome.cancelled { parts.append("остальное остановлено") }
+ if outcome.cancelled { parts.append(tr("остальное остановлено")) }
}
- if outcome.restored > 0 { parts.append("возвращено на место: \(outcome.restored)") }
+ if outcome.restored > 0 { parts.append(tr("возвращено на место: \(outcome.restored)")) }
return parts.joined(separator: " · ")
}
@@ -793,7 +793,7 @@ struct QuestionCard: View {
private var details: some View {
let items = question.items
let shown = Array(items.prefix(Self.visibleItems))
- return DisclosureGroup("Что именно — \(items.count)") {
+ return DisclosureGroup(tr("Что именно — \(items.count)")) {
VStack(spacing: 0) {
ForEach(shown) { item in
FoundRow(item: item, home: app.rules.home,
@@ -802,7 +802,7 @@ struct QuestionCard: View {
if item.id != shown.last?.id { RowDivider(inset: 44) }
}
if items.count > shown.count {
- Text("и ещё \(items.count - shown.count)").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("и ещё \(items.count - shown.count)")).font(.caption).foregroundStyle(Theme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 6)
}
@@ -830,15 +830,15 @@ struct FoundRow: View {
HStack(spacing: 6) {
Text(item.url.lastPathComponent).fontWeight(.medium).lineLimit(1).truncationMode(.middle)
if item.learned {
- StatusPill(title: "как в прошлый раз", systemImage: "clock.arrow.circlepath", tone: .brand)
+ StatusPill(title: tr("как в прошлый раз"), systemImage: "clock.arrow.circlepath", tone: .brand)
} else if item.habit {
- StatusPill(title: "как вы обычно", systemImage: "sparkles", tone: .brand)
+ StatusPill(title: tr("как вы обычно"), systemImage: "sparkles", tone: .brand)
}
}
Text(relativeToHome(item.url.path, home: home)).font(.caption).foregroundStyle(Theme.muted)
.lineLimit(1).truncationMode(.middle)
if let keeper {
- Text("Такая же остаётся: \(relativeToHome(keeper.url.path, home: home))")
+ Text(tr("Такая же остаётся: \(relativeToHome(keeper.url.path, home: home))"))
.font(.caption).foregroundStyle(Theme.muted).lineLimit(1).truncationMode(.middle)
} else {
Text(item.reason).font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
@@ -858,8 +858,8 @@ struct FoundRow: View {
}
.padding(.vertical, 6)
.contextMenu {
- Button("Показать в Finder") { revealInFinder(item.url) }
- if let ignore { Button("Не предлагать больше", action: ignore) }
+ Button(tr("Показать в Finder")) { revealInFinder(item.url) }
+ if let ignore { Button(tr("Не предлагать больше"), action: ignore) }
}
}
}
@@ -874,26 +874,26 @@ struct SafeStatusLine: View {
let safe = app.safe
Group {
if Demo.isOn {
- Label("Сейф открыт", systemImage: "lock.open.fill").foregroundStyle(Theme.muted)
+ Label(tr("Сейф открыт"), systemImage: "lock.open.fill").foregroundStyle(Theme.muted)
} else if app.destination == nil {
- Label("Подключите внешний диск с сейфом", systemImage: "externaldrive.badge.xmark").foregroundStyle(Theme.warn)
+ Label(tr("Подключите внешний диск с сейфом"), systemImage: "externaldrive.badge.xmark").foregroundStyle(Theme.warn)
} else if !safe.exists {
HStack(spacing: 6) {
- Label("Сейфа на диске нет", systemImage: "lock.slash").foregroundStyle(Theme.warn)
- Button("Создать…") { app.section = .safe }.buttonStyle(InkLinkStyle())
+ Label(tr("Сейфа на диске нет"), systemImage: "lock.slash").foregroundStyle(Theme.warn)
+ Button(tr("Создать…")) { app.section = .safe }.buttonStyle(InkLinkStyle())
}
} else if safe.state?.isEncrypted != true {
- Label("Шифрование образа не подтверждается", systemImage: "exclamationmark.octagon").foregroundStyle(Theme.bad)
+ Label(tr("Шифрование образа не подтверждается"), systemImage: "exclamationmark.octagon").foregroundStyle(Theme.bad)
} else if let room = safe.roomLeft(host: app.destination, volume: app.safeVolume), needed > room {
HStack(spacing: 6) {
- Label("Поместится около \(Format.bytes(room))", systemImage: "exclamationmark.triangle.fill").foregroundStyle(Theme.warn)
- Button("Увеличить…") { growing = true }.buttonStyle(InkLinkStyle())
+ Label(tr("Поместится около \(Format.bytes(room))"), systemImage: "exclamationmark.triangle.fill").foregroundStyle(Theme.warn)
+ Button(tr("Увеличить…")) { growing = true }.buttonStyle(InkLinkStyle())
}
} else if let volume = app.safeVolume {
- Label("Сейф открыт · свободно \(Format.bytes(volume.availableBytes))", systemImage: "lock.open.fill")
+ Label(tr("Сейф открыт · свободно \(Format.bytes(volume.availableBytes))"), systemImage: "lock.open.fill")
.foregroundStyle(Theme.muted)
} else {
- Label("Сейф закрыт — пароль спрошу, когда ответите «да»", systemImage: "lock.fill").foregroundStyle(Theme.muted)
+ Label(tr("Сейф закрыт — пароль спрошу, когда ответите «да»"), systemImage: "lock.fill").foregroundStyle(Theme.muted)
}
}
.font(.caption)
@@ -938,26 +938,26 @@ struct SafeBeforeRunSheet: View {
var body: some View {
let safe = app.safe
- SheetLayout(systemImage: "lock.fill", tone: .good, title: "Откройте сейф",
- subtitle: "Убрать в сейф \(Format.bytes(bytes))") {
+ SheetLayout(systemImage: "lock.fill", tone: .good, title: tr("Откройте сейф"),
+ subtitle: tr("Убрать в сейф \(Format.bytes(bytes))")) {
if app.destination == nil {
- Text("Подключите внешний диск, на котором лежит сейф, и ответьте ещё раз. Остальные вопросы от этого не зависят.")
+ Text(tr("Подключите внешний диск, на котором лежит сейф, и ответьте ещё раз. Остальные вопросы от этого не зависят."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} else if !safe.exists {
- Text("На диске «\(app.destination?.name ?? "")» сейфа нет. Создайте его в разделе «Сейф» и ответьте ещё раз.")
+ Text(tr("На диске «\(app.destination?.name ?? "")» сейфа нет. Создайте его в разделе «Сейф» и ответьте ещё раз."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} else if safe.state?.isEncrypted != true {
- Text("Шифрование образа на диске не подтверждается — класть в него нельзя. Разберитесь в разделе «Сейф».")
+ Text(tr("Шифрование образа на диске не подтверждается — класть в него нельзя. Разберитесь в разделе «Сейф»."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} else {
- Text("Пароль нужен, чтобы убрать это в сейф. Он уходит в macOS и нигде не сохраняется. Как только сейф откроется, начну.")
+ Text(tr("Пароль нужен, чтобы убрать это в сейф. Он уходит в macOS и нигде не сохраняется. Как только сейф откроется, начну."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
SafeUnlockRow()
}
} actions: {
- Button("Отмена") { dismiss() }
+ Button(tr("Отмена")) { dismiss() }
if app.destination != nil, !safe.exists {
- Button("Открыть «Сейф»") {
+ Button(tr("Открыть «Сейф»")) {
dismiss()
app.section = .safe
}
diff --git a/Sources/Offload/Views/CloudRestoreView.swift b/Sources/Offload/Views/CloudRestoreView.swift
index 950db5e..f6ff3ca 100644
--- a/Sources/Offload/Views/CloudRestoreView.swift
+++ b/Sources/Offload/Views/CloudRestoreView.swift
@@ -12,23 +12,23 @@ struct CloudRestoreView: View {
var body: some View {
let model = app.cloud
PageScroll {
- CardSection(title: "Хранилище", number: 1,
- footer: "Бэкапы restic из iCloud Drive — например, копия внешнего диска. OffLoadAI только читает хранилище и ничего в нём не меняет, а пароль держит в памяти, пока хранилище открыто: сон и блокировка экрана его стирают.") {
+ CardSection(title: tr("Хранилище"), number: 1,
+ footer: tr("Бэкапы restic из iCloud Drive — например, копия внешнего диска. OffLoadAI только читает хранилище и ничего в нём не меняет, а пароль держит в памяти, пока хранилище открыто: сон и блокировка экрана его стирают.")) {
repositorySection
}
if model.isUnlocked {
- CardSection(title: "Снимок", number: 2) {
+ CardSection(title: tr("Снимок"), number: 2) {
snapshotSection
}
- CardSection(title: "Куда восстановить", number: 3) {
+ CardSection(title: tr("Куда восстановить"), number: 3) {
destinationSection
}
- CardSection(title: "Файлы", number: 4) {
+ CardSection(title: tr("Файлы"), number: 4) {
filesSection
}
}
}
- .navigationTitle("Из iCloud")
+ .navigationTitle(tr("Из iCloud"))
.task { model.discover() }
}
@@ -40,7 +40,7 @@ struct CloudRestoreView: View {
if !model.resticInstalled {
VStack(alignment: .leading, spacing: 10) {
Notice(.warning, CloudRestore.RestoreError.resticMissing.localizedDescription)
- Button("Проверить снова") { model.discover() }
+ Button(tr("Проверить снова")) { model.discover() }
}
.padding(Theme.cardPadding)
RowDivider()
@@ -48,12 +48,12 @@ struct CloudRestoreView: View {
if model.searching && model.repositories.isEmpty {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
- Text("Ищу бэкапы в iCloud Drive…").foregroundStyle(Theme.muted)
+ Text(tr("Ищу бэкапы в iCloud Drive…")).foregroundStyle(Theme.muted)
}
.rowPadding()
RowDivider()
} else if model.repositories.isEmpty {
- Text("В iCloud Drive бэкапов restic не нашлось. Если хранилище лежит в другом месте, выберите его папку.")
+ Text(tr("В iCloud Drive бэкапов restic не нашлось. Если хранилище лежит в другом месте, выберите его папку."))
.foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
.rowPadding()
@@ -63,7 +63,7 @@ struct CloudRestoreView: View {
repositoryRow(repository)
RowDivider()
}
- Button { model.chooseRepository() } label: { Label("Выбрать папку хранилища…", systemImage: "folder") }
+ Button { model.chooseRepository() } label: { Label(tr("Выбрать папку хранилища…"), systemImage: "folder") }
.buttonStyle(.borderless)
.disabled(model.restoring != nil)
.rowPadding()
@@ -106,32 +106,32 @@ struct CloudRestoreView: View {
VStack(alignment: .leading, spacing: 8) {
if model.isUnlocked {
HStack {
- Label("Открыто · \(model.snapshots.count) \(pluralRu(model.snapshots.count, "снимок", "снимка", "снимков"))",
+ Label(tr("Открыто · \(model.snapshots.count) \(pluralRu(model.snapshots.count, tr("снимок"), tr("снимка"), tr("снимков")))"),
systemImage: "lock.open.fill")
Spacer()
- Button("Закрыть") { model.lock() }.disabled(model.restoring != nil)
+ Button(tr("Закрыть")) { model.lock() }.disabled(model.restoring != nil)
}
} else {
HStack(spacing: 8) {
Image(systemName: "lock.fill").foregroundStyle(Theme.muted)
- SecureField("Пароль хранилища", text: $password)
+ SecureField(tr("Пароль хранилища"), text: $password)
.textFieldStyle(.roundedBorder)
.onSubmit(unlock)
if model.loading != nil {
ProgressView().controlSize(.small)
} else {
- Button("Открыть", action: unlock)
+ Button(tr("Открыть"), action: unlock)
.prominentButton()
.disabled(password.isEmpty)
}
}
HStack(spacing: 12) {
if let file = model.passwordFile {
- Button("Пароль из «\(file.lastPathComponent)»") { model.unlockWithFile(file) }
+ Button(tr("Пароль из «\(file.lastPathComponent)»")) { model.unlockWithFile(file) }
.buttonStyle(InkLinkStyle())
.help(relativeToHome(file.path, home: app.rules.home))
}
- Button("Пароль из файла…") { model.unlockWithFile() }
+ Button(tr("Пароль из файла…")) { model.unlockWithFile() }
.buttonStyle(InkLinkStyle())
}
.font(.callout)
@@ -161,7 +161,7 @@ struct CloudRestoreView: View {
let model = app.cloud
@Bindable var bindable = model
VStack(alignment: .leading, spacing: 10) {
- Picker("Снимок", selection: $bindable.snapshotID) {
+ Picker(tr("Снимок"), selection: $bindable.snapshotID) {
ForEach(model.snapshots) { snapshot in
Text(title(of: snapshot)).tag(Optional(snapshot.id))
}
@@ -169,7 +169,7 @@ struct CloudRestoreView: View {
.pickerStyle(.menu)
.disabled(model.restoring != nil || model.loading != nil)
if let snapshot = model.snapshot {
- InfoRow("Что в снимке", value: snapshot.paths.joined(separator: ", "))
+ InfoRow(tr("Что в снимке"), value: snapshot.paths.joined(separator: ", "))
.font(.callout)
}
}
@@ -189,43 +189,43 @@ struct CloudRestoreView: View {
private var destinationSection: some View {
let model = app.cloud
VStack(alignment: .leading, spacing: 12) {
- InfoRow(title: "Папка") {
+ InfoRow(title: tr("Папка")) {
HStack {
Text(relativeToHome(model.destination.path, home: app.rules.home))
.lineLimit(1).truncationMode(.head).textSelection(.enabled)
- Button("Выбрать…") { model.chooseDestination() }.disabled(model.restoring != nil)
+ Button(tr("Выбрать…")) { model.chooseDestination() }.disabled(model.restoring != nil)
}
}
.font(.callout)
- Text("Внутри появится новая папка «Из бэкапа …»: ничего существующего не перезаписывается. Восстановленное сверяется с бэкапом.")
+ Text(tr("Внутри появится новая папка «Из бэкапа …»: ничего существующего не перезаписывается. Восстановленное сверяется с бэкапом."))
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
if let entry = model.restoring {
VStack(alignment: .leading, spacing: 6) {
HStack {
- Text("Восстанавливаю «\(entry.name)»").lineLimit(1).truncationMode(.middle)
+ Text(tr("Восстанавливаю «\(entry.name)»")).lineLimit(1).truncationMode(.middle)
Spacer()
if let progress = model.progress, progress.bytesTotal > 0 {
- Text("\(Format.bytes(progress.bytesDone)) из \(Format.bytes(progress.bytesTotal))")
+ Text(tr("\(Format.bytes(progress.bytesDone)) из \(Format.bytes(progress.bytesTotal))"))
.monospacedDigit().foregroundStyle(Theme.muted)
}
- Button("Остановить") { model.cancelRestore() }
+ Button(tr("Остановить")) { model.cancelRestore() }
}
if let progress = model.progress {
ProgressView(value: progress.fraction)
} else {
ProgressView().progressViewStyle(.linear)
- Text("Считаю размер…").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Считаю размер…")).font(.caption).foregroundStyle(Theme.muted)
}
if model.waitingForCloud {
- Notice(.warning, "Жду iCloud: часть бэкапа хранится только в облаке, и iCloud её ещё не скачал. OffLoadAI попросил скачать и ждёт. Если долго ничего не меняется, проверьте в Finder → iCloud Drive, что синхронизация идёт, — или остановите и попробуйте позже.")
+ Notice(.warning, tr("Жду iCloud: часть бэкапа хранится только в облаке, и iCloud её ещё не скачал. OffLoadAI попросил скачать и ждёт. Если долго ничего не меняется, проверьте в Finder → iCloud Drive, что синхронизация идёт, — или остановите и попробуйте позже."))
}
}
}
if let message = model.restoreMessage {
Notice(message)
if let item = model.restoredItem {
- Button { revealInFinder(item) } label: { Label("Показать в Finder", systemImage: "folder") }
+ Button { revealInFinder(item) } label: { Label(tr("Показать в Finder"), systemImage: "folder") }
}
}
}
@@ -240,13 +240,13 @@ struct CloudRestoreView: View {
@Bindable var bindable = model
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(Theme.muted)
- TextField("Найти по имени во всём снимке", text: $bindable.query)
+ TextField(tr("Найти по имени во всём снимке"), text: $bindable.query)
.textFieldStyle(.roundedBorder)
.onSubmit { model.search() }
- Button("Найти") { model.search() }
+ Button(tr("Найти")) { model.search() }
.disabled(model.query.trimmingCharacters(in: .whitespaces).isEmpty || model.loading != nil)
if model.searchResults != nil {
- Button("Сбросить") { model.clearSearch() }
+ Button(tr("Сбросить")) { model.clearSearch() }
}
}
.rowPadding()
@@ -260,7 +260,7 @@ struct CloudRestoreView: View {
RowDivider()
}
if let results = model.searchResults {
- Text(results.isEmpty ? "Ничего не нашлось." : "Найдено: \(results.count)" + (results.count >= 500 ? " — показаны первые 500, уточните запрос" : ""))
+ Text(results.isEmpty ? tr("Ничего не нашлось.") : tr("Найдено: \(results.count)") + (results.count >= 500 ? tr(" — показаны первые 500, уточните запрос") : ""))
.font(.callout).foregroundStyle(Theme.muted)
.rowPadding()
ForEach(results) { entry in
@@ -271,7 +271,7 @@ struct CloudRestoreView: View {
breadcrumbs
if model.entries.isEmpty && model.loading == nil {
RowDivider()
- Text("Папка пуста.").foregroundStyle(Theme.muted).rowPadding()
+ Text(tr("Папка пуста.")).foregroundStyle(Theme.muted).rowPadding()
}
ForEach(model.entries.prefix(visibleLimit)) { entry in
RowDivider()
@@ -279,7 +279,7 @@ struct CloudRestoreView: View {
}
if model.entries.count > visibleLimit {
RowDivider()
- Text("Ещё \(model.entries.count - visibleLimit) не показаны — найдите нужное по имени.")
+ Text(tr("Ещё \(model.entries.count - visibleLimit) не показаны — найдите нужное по имени."))
.font(.callout).foregroundStyle(Theme.muted).rowPadding()
}
}
@@ -292,7 +292,7 @@ struct CloudRestoreView: View {
HStack(spacing: 4) {
ForEach(Array(path.enumerated()), id: \.element) { index, item in
if index > 0 { Image(systemName: "chevron.right").font(.caption2).foregroundStyle(Theme.faint) }
- let name = item == "/" ? "Корень" : (item as NSString).lastPathComponent
+ let name = item == "/" ? tr("Корень") : (item as NSString).lastPathComponent
if item == model.directory {
Text(name).fontWeight(.semibold)
} else {
@@ -327,7 +327,7 @@ struct CloudRestoreView: View {
}
.buttonStyle(.plain)
.disabled(!entry.isDirectory || model.loading != nil)
- Button("Восстановить") { model.restore(entry, app: app) }
+ Button(tr("Восстановить")) { model.restore(entry, app: app) }
.controlSize(.small)
.disabled(model.restoring != nil)
}
diff --git a/Sources/Offload/Views/Components.swift b/Sources/Offload/Views/Components.swift
index e493ddb..dc6a7fd 100644
--- a/Sources/Offload/Views/Components.swift
+++ b/Sources/Offload/Views/Components.swift
@@ -4,9 +4,9 @@ import SwiftUI
extension Verdict {
var title: String {
switch self {
- case .safe: return "Можно перенести"
- case .caution: return "С оговорками"
- case .blocked: return "Не трогать"
+ case .safe: return tr("Можно перенести")
+ case .caution: return tr("С оговорками")
+ case .blocked: return tr("Не трогать")
}
}
@@ -129,13 +129,13 @@ struct FullDiskAccessBanner: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "lock.shield", tone: .caution, size: 36)
VStack(alignment: .leading, spacing: 8) {
- Text("Нет полного доступа к диску").font(.headline)
- Text("Без него OffLoadAI не видит «Документы», «Рабочий стол», Почту и данные многих приложений, и часть занятого места останется неизвестной. Выдайте доступ в настройках и перезапустите OffLoadAI.")
+ Text(tr("Нет полного доступа к диску")).font(.headline)
+ Text(tr("Без него OffLoadAI не видит «Документы», «Рабочий стол», Почту и данные многих приложений, и часть занятого места останется неизвестной. Выдайте доступ в настройках и перезапустите OffLoadAI."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
HStack {
- Button("Открыть настройки") { FullDiskAccess.openSettings() }
+ Button(tr("Открыть настройки")) { FullDiskAccess.openSettings() }
.prominentButton()
- Button("Проверить снова") { app.refreshVolumes() }
+ Button(tr("Проверить снова")) { app.refreshVolumes() }
}
.padding(.top, 2)
}
diff --git a/Sources/Offload/Views/ContentView.swift b/Sources/Offload/Views/ContentView.swift
index 1f3a7d4..eb3ada6 100644
--- a/Sources/Offload/Views/ContentView.swift
+++ b/Sources/Offload/Views/ContentView.swift
@@ -64,8 +64,8 @@ struct ContentView: View {
.task(id: app.destinationID) { app.safe.refresh(app: app) }
.sheet(isPresented: $pro.isPresented) { ProSheet() }
.sheet(isPresented: $updates.isPresented) { UpdateSheet() }
- .alert("Обновления", isPresented: updateResultShown) {
- Button("Готово", role: .cancel) {}
+ .alert(tr("Обновления"), isPresented: updateResultShown) {
+ Button(tr("Готово"), role: .cancel) {}
} message: {
Text(updates.checkResult ?? "")
}
@@ -77,11 +77,11 @@ struct ContentView: View {
app.updates.checkIfDue()
}
// Закрыть сейф не дали открытые в нём файлы — откуда бы ни закрывали: из панели, меню или раздела.
- .alert("Сейф не закрывается", isPresented: $safe.closeBlocked) {
- Button("Закрыть принудительно", role: .destructive) { app.safe.close(app: app, force: true) }
- Button("Оставить открытым", role: .cancel) {}
+ .alert(tr("Сейф не закрывается"), isPresented: $safe.closeBlocked) {
+ Button(tr("Закрыть принудительно"), role: .destructive) { app.safe.close(app: app, force: true) }
+ Button(tr("Оставить открытым"), role: .cancel) {}
} message: {
- Text("В нём открыты файлы в других программах. Закройте их и повторите — или закройте сейф принудительно: несохранённое в этих программах может пропасть.")
+ Text(tr("В нём открыты файлы в других программах. Закройте их и повторите — или закройте сейф принудительно: несохранённое в этих программах может пропасть."))
}
// Журнал нужен не только разделу «Перенесённое»: «Обзор» и «Сейф» по нему видят,
// что лежит на диске открыто. Поэтому читается сразу и при каждой смене дисков и сейфа.
diff --git a/Sources/Offload/Views/DockerView.swift b/Sources/Offload/Views/DockerView.swift
index 823a983..6c0bd80 100644
--- a/Sources/Offload/Views/DockerView.swift
+++ b/Sources/Offload/Views/DockerView.swift
@@ -16,26 +16,26 @@ struct DockerView: View {
Divider()
switch model.status {
case .unknown, .checking:
- ProgressView("Спрашиваю Docker…").frame(maxWidth: .infinity, maxHeight: .infinity)
+ ProgressView(tr("Спрашиваю Docker…")).frame(maxWidth: .infinity, maxHeight: .infinity)
case .notInstalled:
- ContentUnavailableView("Docker не установлен", systemImage: "shippingbox",
- description: Text("Этот раздел нужен, только если вы пользуетесь Docker."))
+ ContentUnavailableView(tr("Docker не установлен"), systemImage: "shippingbox",
+ description: Text(tr("Этот раздел нужен, только если вы пользуетесь Docker.")))
case .notRunning:
ContentUnavailableView {
- Label("Docker не запущен", systemImage: "shippingbox")
+ Label(tr("Docker не запущен"), systemImage: "shippingbox")
} description: {
- Text("Откройте Docker Desktop и обновите список.")
+ Text(tr("Откройте Docker Desktop и обновите список."))
} actions: {
- Button("Обновить") { model.reload(app: app) }
+ Button(tr("Обновить")) { model.reload(app: app) }
}
case .ready:
usageBand
Divider()
Table(model.volumes, selection: $bindable.selection) {
- TableColumn("Том") { volume in
+ TableColumn(tr("Том")) { volume in
Text(volume.name).lineLimit(1).truncationMode(.middle)
}
- TableColumn("Размер") { volume in
+ TableColumn(tr("Размер")) { volume in
if let bytes = volume.sizeBytes {
Text(Format.bytes(bytes)).monospacedDigit()
} else if model.sizing {
@@ -45,21 +45,21 @@ struct DockerView: View {
}
}
.width(90)
- TableColumn("Создан") { volume in
+ TableColumn(tr("Создан")) { volume in
Text(volume.createdAt.map { Format.relative($0) } ?? "—")
}
.width(140)
- TableColumn("Последнее изменение") { volume in
+ TableColumn(tr("Последнее изменение")) { volume in
if let date = model.activity[volume.name] {
Text(Format.relative(date))
} else if model.checking.contains(volume.name) {
ProgressView().controlSize(.mini)
} else {
- Button("Проверить") { model.checkActivity([volume.name]) }.controlSize(.small)
+ Button(tr("Проверить")) { model.checkActivity([volume.name]) }.controlSize(.small)
}
}
.width(160)
- TableColumn("Используется") { volume in
+ TableColumn(tr("Используется")) { volume in
if volume.usedBy.isEmpty {
Text("—").foregroundStyle(Theme.muted)
} else {
@@ -77,25 +77,25 @@ struct DockerView: View {
.sheet(isPresented: $showPrune) { DockerPruneSheet() }
.task { if model.status == .unknown { model.reload(app: app) } }
.onChange(of: app.target?.id) { model.reload(app: app) }
- .confirmationDialog("Архивировать выбранные тома?", isPresented: $confirmArchive) {
+ .confirmationDialog(tr("Архивировать выбранные тома?"), isPresented: $confirmArchive) {
Button(app.target?.isEncryptedImage == true
- ? "Упаковать в сейф и убрать из Docker"
- : "Упаковать на «\(app.target?.name ?? "")» открыто и убрать из Docker") {
+ ? tr("Упаковать в сейф и убрать из Docker")
+ : tr("Упаковать на «\(app.target?.name ?? "")» открыто и убрать из Docker")) {
if let volume = app.target { model.archiveSelected(to: volume, app: app) }
}
- Button("Отмена", role: .cancel) {}
+ Button(tr("Отмена"), role: .cancel) {}
} message: {
- Text("Выбрано \(model.selection.count), около \(Format.bytes(model.selectedBytes)). Каждый том упаковывается в архив, список всех его файлов сверяется с архивом, и только потом том удаляется из Docker. Тома, подключённые к контейнерам, не трогаются.")
+ Text(tr("Выбрано \(model.selection.count), около \(Format.bytes(model.selectedBytes)). Каждый том упаковывается в архив, список всех его файлов сверяется с архивом, и только потом том удаляется из Docker. Тома, подключённые к контейнерам, не трогаются."))
}
- .alert("Вернуть том в Docker",
+ .alert(tr("Вернуть том в Docker"),
isPresented: Binding(get: { restoreArchive != nil }, set: { if !$0 { restoreArchive = nil } })) {
- TextField("Имя тома", text: $restoreName)
- Button("Вернуть") {
+ TextField(tr("Имя тома"), text: $restoreName)
+ Button(tr("Вернуть")) {
if let archive = restoreArchive { model.restore(archive, name: restoreName, app: app) }
}
- Button("Отмена", role: .cancel) {}
+ Button(tr("Отмена"), role: .cancel) {}
} message: {
- Text("Будет создан новый том и заполнен из архива со сверкой. Существующий том с таким именем не перезаписывается.")
+ Text(tr("Будет создан новый том и заполнен из архива со сверкой. Существующий том с таким именем не перезаписывается."))
}
}
@@ -113,30 +113,30 @@ struct DockerView: View {
VStack(alignment: .leading, spacing: 2) {
Text("Docker").font(Theme.display(19))
if model.sizing {
- Text("Docker считает размеры томов — это может занять минуту").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Docker считает размеры томов — это может занять минуту")).font(.caption).foregroundStyle(Theme.muted)
} else if let raw = model.rawBytes {
- Text("Диск Docker (Docker.raw) занимает \(Format.bytes(raw))").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Диск Docker (Docker.raw) занимает \(Format.bytes(raw))")).font(.caption).foregroundStyle(Theme.muted)
}
}
Spacer()
if let busy = model.busy {
ProgressView().controlSize(.small)
Text(busy).font(.callout).lineLimit(1).truncationMode(.middle)
- if !model.pruning { Button("Отменить") { model.cancel() } }
+ if !model.pruning { Button(tr("Отменить")) { model.cancel() } }
}
- Button { model.reload(app: app) } label: { Label("Обновить", systemImage: "arrow.clockwise") }
+ Button { model.reload(app: app) } label: { Label(tr("Обновить"), systemImage: "arrow.clockwise") }
.disabled(model.busy != nil)
Button {
if app.pro.allows(.dockerVolumes) { confirmArchive = true } else { app.pro.offer(.dockerVolumes) }
} label: {
HStack(spacing: 6) {
- Label("Архивировать на диск…", systemImage: "archivebox")
+ Label(tr("Архивировать на диск…"), systemImage: "archivebox")
if !app.pro.allows(.dockerVolumes) { ProTag() }
}
}
.prominentButton()
.disabled(model.selection.isEmpty || model.busy != nil || app.target == nil)
- .help(app.targetProblem ?? "Упаковать выбранные тома и убрать их из Docker")
+ .help(app.targetProblem ?? tr("Упаковать выбранные тома и убрать их из Docker"))
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
@@ -147,22 +147,22 @@ struct DockerView: View {
let model = app.docker
return HStack(spacing: 24) {
if let usage = model.usage {
- figure("Образы", usage.images, note: "можно убрать")
- figure("Кеш сборки", usage.buildCache, note: "можно убрать")
- figure("Контейнеры", usage.containers, note: "можно убрать")
+ figure(tr("Образы"), usage.images, note: tr("можно убрать"))
+ figure(tr("Кеш сборки"), usage.buildCache, note: tr("можно убрать"))
+ figure(tr("Контейнеры"), usage.containers, note: tr("можно убрать"))
// Тома очистка не трогает: неподключённые можно только упаковать на диск.
- figure("Тома", usage.volumes, note: "не подключены", tone: .neutral)
+ figure(tr("Тома"), usage.volumes, note: tr("не подключены"), tone: .neutral)
} else if model.measuringUsage {
ProgressView().controlSize(.small)
- Text("Docker считает, что занимает место внутри него…").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("Docker считает, что занимает место внутри него…")).font(.callout).foregroundStyle(Theme.muted)
} else {
- Text("Docker не сказал, сколько места занято внутри.").font(.callout).foregroundStyle(Theme.muted)
+ Text(tr("Docker не сказал, сколько места занято внутри.")).font(.callout).foregroundStyle(Theme.muted)
}
Spacer(minLength: 12)
if model.usage != nil, model.measuringUsage { ProgressView().controlSize(.small) }
- Button { showPrune = true } label: { Label("Освободить место…", systemImage: "sparkles") }
+ Button { showPrune = true } label: { Label(tr("Освободить место…"), systemImage: "sparkles") }
.disabled(model.usage == nil || model.busy != nil)
- .help("Удалить кеш сборки, неиспользуемые образы и остановленные контейнеры. Тома не трогаются.")
+ .help(tr("Удалить кеш сборки, неиспользуемые образы и остановленные контейнеры. Тома не трогаются."))
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
@@ -193,7 +193,7 @@ struct DockerView: View {
if !model.archives.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 8) {
- Text("Архивы томов").font(.headline)
+ Text(tr("Архивы томов")).font(.headline)
ScrollView {
VStack(spacing: 0) {
ForEach(model.archives, id: \.self) { archive in
@@ -203,10 +203,10 @@ struct DockerView: View {
tone: place.inSafe ? .good : .neutral, size: 26)
VStack(alignment: .leading, spacing: 1) {
Text(place.path).lineLimit(1).truncationMode(.middle).help(archive.path)
- Text(place.inSafe ? "в сейфе" : "открыто на диске").font(.caption).foregroundStyle(Theme.muted)
+ Text(place.inSafe ? tr("в сейфе") : tr("открыто на диске")).font(.caption).foregroundStyle(Theme.muted)
}
Spacer()
- Button("Вернуть в Docker…") {
+ Button(tr("Вернуть в Docker…")) {
restoreName = DockerService.volumeName(fromArchive: archive) ?? ""
restoreArchive = archive
}
@@ -235,23 +235,23 @@ struct DockerView: View {
extension DockerPruneTarget {
var title: String {
switch self {
- case .buildCache: return "Кеш сборки"
- case .danglingImages: return "Образы без имени"
- case .images: return "Все неиспользуемые образы"
- case .containers: return "Остановленные контейнеры"
+ case .buildCache: return tr("Кеш сборки")
+ case .danglingImages: return tr("Образы без имени")
+ case .images: return tr("Все неиспользуемые образы")
+ case .containers: return tr("Остановленные контейнеры")
}
}
var detail: String {
switch self {
case .buildCache:
- return "Промежуточные слои от docker build. Следующая сборка пойдёт дольше, пока кеш не наберётся заново."
+ return tr("Промежуточные слои от docker build. Следующая сборка пойдёт дольше, пока кеш не наберётся заново.")
case .danglingImages:
- return "Остатки пересборок с именем : запустить их не по чему, ни один контейнер их не использует."
+ return tr("Остатки пересборок с именем : запустить их не по чему, ни один контейнер их не использует.")
case .images:
- return "Образы, которые не нужны ни одному контейнеру. Docker скачает их заново, когда понадобятся; собранные вами и никуда не отправленные придётся собрать снова. Отмечайте сами."
+ return tr("Образы, которые не нужны ни одному контейнеру. Docker скачает их заново, когда понадобятся; собранные вами и никуда не отправленные придётся собрать снова. Отмечайте сами.")
case .containers:
- return "Всё, что записано внутри контейнера, а не в томе, пропадёт вместе с ним. Образы удалённых контейнеров тоже освободятся."
+ return tr("Всё, что записано внутри контейнера, а не в томе, пропадёт вместе с ним. Образы удалённых контейнеров тоже освободятся.")
}
}
}
@@ -270,8 +270,8 @@ struct DockerPruneSheet: View {
var body: some View {
let model = app.docker
let usage = model.usage ?? DockerUsage()
- SheetLayout(systemImage: "shippingbox.fill", title: "Освободить место в Docker",
- subtitle: model.rawBytes.map { "Диск Docker (Docker.raw) занимает на Mac \(Format.bytes($0))" },
+ SheetLayout(systemImage: "shippingbox.fill", title: tr("Освободить место в Docker"),
+ subtitle: model.rawBytes.map { tr("Диск Docker (Docker.raw) занимает на Mac \(Format.bytes($0))") },
width: 580) {
Card(padding: 0, spacing: 0) {
ForEach(Self.order, id: \.self) { target in
@@ -280,12 +280,12 @@ struct DockerPruneSheet: View {
}
}
if targets.contains(.containers) {
- Notice(.warning, "Отмечайте остановленные контейнеры, только если они точно не нужны: удалённый контейнер не вернуть.")
+ Notice(.warning, tr("Отмечайте остановленные контейнеры, только если они точно не нужны: удалённый контейнер не вернуть."))
}
- Notice(.info, "Тома не трогаются: в них данные баз и проектов. Ненужные тома можно упаковать на диск кнопкой «Архивировать на диск…».")
+ Notice(.info, tr("Тома не трогаются: в них данные баз и проектов. Ненужные тома можно упаковать на диск кнопкой «Архивировать на диск…»."))
} actions: {
- Button("Отмена") { dismiss() }.keyboardShortcut(.cancelAction)
- Button(targets.isEmpty ? "Освободить" : "Освободить около \(Format.bytes(usage.reclaimable(targets)))") {
+ Button(tr("Отмена")) { dismiss() }.keyboardShortcut(.cancelAction)
+ Button(targets.isEmpty ? tr("Освободить") : tr("Освободить около \(Format.bytes(usage.reclaimable(targets)))")) {
model.prune(targets, app: app)
dismiss()
}
diff --git a/Sources/Offload/Views/HistoryView.swift b/Sources/Offload/Views/HistoryView.swift
index 707398f..2fc9164 100644
--- a/Sources/Offload/Views/HistoryView.swift
+++ b/Sources/Offload/Views/HistoryView.swift
@@ -42,9 +42,9 @@ struct HistoryGroup: Identifiable {
extension MoveRecord {
var symbol: String { restored ? "arrow.uturn.backward" : (isEncrypted ? "lock.fill" : "externaldrive.fill") }
var tone: Tone { restored ? .neutral : (isEncrypted ? .good : .caution) }
- var location: String { isEncrypted ? "в сейфе «\(volumeName)»" : "открыто на «\(volumeName)»" }
+ var location: String { isEncrypted ? tr("в сейфе «\(volumeName)»") : tr("открыто на «\(volumeName)»") }
/// Почему вернуть сейчас нельзя.
- var unavailableReason: String { isEncrypted ? "Сейф закрыт" : "Диск не подключён" }
+ var unavailableReason: String { isEncrypted ? tr("Сейф закрыт") : tr("Диск не подключён") }
}
struct HistoryView: View {
@@ -59,10 +59,10 @@ struct HistoryView: View {
VStack(alignment: .leading, spacing: 8) {
HStack {
ProgressView().controlSize(.small)
- Text("Возвращаю «\(URL(fileURLWithPath: record.originalPath).lastPathComponent)»").font(.headline)
+ Text(tr("Возвращаю «\(URL(fileURLWithPath: record.originalPath).lastPathComponent)»")).font(.headline)
Spacer()
- Text(model.progress?.phase.rawValue ?? "Подготовка").foregroundStyle(Theme.muted)
- Button("Отменить") { model.cancel() }
+ Text(model.progress.map { trDynamic($0.phase.rawValue) } ?? tr("Подготовка")).foregroundStyle(Theme.muted)
+ Button(tr("Отменить")) { model.cancel() }
}
ProgressView(value: model.progress?.fraction ?? 0)
}
@@ -75,15 +75,15 @@ struct HistoryView: View {
Notice(message).padding(16)
}
ContentUnavailableView {
- Label("Пока ничего не перенесено", systemImage: "tray")
+ Label(tr("Пока ничего не перенесено"), systemImage: "tray")
} description: {
Text(app.destination == nil
- ? "Перенесённые на внешний диск папки и файлы появятся здесь. Подключите диск и выберите его внизу боковой панели — тогда можно будет добавить в журнал и то, что вы перенесли раньше без OffLoadAI."
- : "Перенесённые на внешний диск папки и файлы появятся здесь. Вернуть их можно, пока диск подключён. То, что вы перенесли раньше без OffLoadAI, можно добавить вручную.")
+ ? tr("Перенесённые на внешний диск папки и файлы появятся здесь. Подключите диск и выберите его внизу боковой панели — тогда можно будет добавить в журнал и то, что вы перенесли раньше без OffLoadAI.")
+ : tr("Перенесённые на внешний диск папки и файлы появятся здесь. Вернуть их можно, пока диск подключён. То, что вы перенесли раньше без OffLoadAI, можно добавить вручную."))
} actions: {
- Button("Добавить вручную…") { showImport = true }
+ Button(tr("Добавить вручную…")) { showImport = true }
.disabled(app.destination == nil)
- .help(app.destination == nil ? "Нужен подключённый внешний диск" : "")
+ .help(app.destination == nil ? tr("Нужен подключённый внешний диск") : "")
}
} else {
PageScroll {
@@ -112,24 +112,24 @@ struct HistoryView: View {
}
}
}
- .navigationTitle("Перенесённое")
+ .navigationTitle(tr("Перенесённое"))
.toolbar {
- Button { showImport = true } label: { Label("Добавить вручную…", systemImage: "plus") }
+ Button { showImport = true } label: { Label(tr("Добавить вручную…"), systemImage: "plus") }
.disabled(app.destination == nil)
.help(app.destination == nil
- ? "Нужен подключённый внешний диск: выберите его внизу боковой панели"
- : "Зарегистрировать папку или файл, уже перенесённые на внешний диск без OffLoadAI")
+ ? tr("Нужен подключённый внешний диск: выберите его внизу боковой панели")
+ : tr("Зарегистрировать папку или файл, уже перенесённые на внешний диск без OffLoadAI"))
}
.sheet(isPresented: $showImport) { ImportSheet() }
.task(id: app.historyVolumes.map(\.id)) { model.reload(volumes: app.historyVolumes) }
- .confirmationDialog("Вернуть на Mac?",
+ .confirmationDialog(tr("Вернуть на Mac?"),
isPresented: Binding(get: { pendingRestore != nil }, set: { if !$0 { pendingRestore = nil } }),
presenting: pendingRestore) { record in
- Button("Вернуть и оставить копию на диске") { model.restore(record, deleteArchive: false, app: app) }
- Button("Вернуть и удалить с диска", role: .destructive) { model.restore(record, deleteArchive: true, app: app) }
- Button("Отмена", role: .cancel) {}
+ Button(tr("Вернуть и оставить копию на диске")) { model.restore(record, deleteArchive: false, app: app) }
+ Button(tr("Вернуть и удалить с диска"), role: .destructive) { model.restore(record, deleteArchive: true, app: app) }
+ Button(tr("Отмена"), role: .cancel) {}
} message: { record in
- Text("«\(relativeToHome(record.originalPath, home: app.rules.home))» будет скопирован обратно: каждый файл перечитывается с диска и сверяется по SHA-256 со списком, записанным при переносе. На Mac понадобится \(Format.bytes(record.bytes)).")
+ Text(tr("«\(relativeToHome(record.originalPath, home: app.rules.home))» будет скопирован обратно: каждый файл перечитывается с диска и сверяется по SHA-256 со списком, записанным при переносе. На Mac понадобится \(Format.bytes(record.bytes))."))
}
}
@@ -137,13 +137,13 @@ struct HistoryView: View {
let onDisks = records.filter { !$0.restored }
let open = onDisks.filter { !$0.isEncrypted }.count
return HStack(alignment: .top, spacing: 16) {
- StatTile(value: Format.bytes(onDisks.reduce(0) { $0 + $1.bytes }), title: "лежит на внешних дисках",
+ StatTile(value: Format.bytes(onDisks.reduce(0) { $0 + $1.bytes }), title: tr("лежит на внешних дисках"),
systemImage: "externaldrive.fill")
StatTile(value: "\(onDisks.count)",
- title: pluralRu(onDisks.count, "перенесённый объект", "перенесённых объекта", "перенесённых объектов"),
- detail: "в сейфе \(onDisks.count - open) · открыто \(open)",
+ title: pluralRu(onDisks.count, tr("перенесённый объект"), tr("перенесённых объекта"), tr("перенесённых объектов")),
+ detail: tr("в сейфе \(onDisks.count - open) · открыто \(open)"),
systemImage: open > 0 ? "lock.open.fill" : "lock.fill", tone: open > 0 ? .caution : .good)
- StatTile(value: "\(records.count - onDisks.count)", title: "возвращено на Mac",
+ StatTile(value: "\(records.count - onDisks.count)", title: tr("возвращено на Mac"),
systemImage: "arrow.uturn.backward", tone: .neutral)
}
.fixedSize(horizontal: false, vertical: true)
@@ -172,11 +172,11 @@ struct HistoryGroupRow: View {
IconTile(systemImage: group.first.restored ? "arrow.uturn.backward" : "square.stack.3d.up.fill",
tone: group.first.restored ? .neutral : .brand, size: 32)
VStack(alignment: .leading, spacing: 3) {
- Text("\((group.originalParent as NSString).lastPathComponent) · \(group.records.count) \(pluralRu(group.records.count, "объект", "объекта", "объектов"))")
+ Text("\((group.originalParent as NSString).lastPathComponent) · \(group.records.count) \(pluralRu(group.records.count, tr("объект"), tr("объекта"), tr("объектов")))")
.fontWeight(.medium)
Text(relativeToHome(group.originalParent, home: home)).font(.caption).foregroundStyle(Theme.muted)
.lineLimit(1).truncationMode(.middle)
- Text("\(group.latest.formatted(date: .abbreviated, time: .shortened)) · \(Format.bytes(group.bytes)) · файлов \(group.files) · \(group.first.location)")
+ Text(tr("\(group.latest.formatted(date: .abbreviated, time: .shortened)) · \(Format.bytes(group.bytes)) · файлов \(group.files) · \(group.first.location)"))
.font(.caption).foregroundStyle(Theme.muted)
if let note = group.note, !note.isEmpty {
Text(note).font(.caption).foregroundStyle(Theme.warn).fixedSize(horizontal: false, vertical: true)
@@ -184,7 +184,7 @@ struct HistoryGroupRow: View {
}
Spacer()
if group.first.restored {
- StatusPill(title: "Возвращено", systemImage: "checkmark", tone: .good)
+ StatusPill(title: tr("Возвращено"), systemImage: "checkmark", tone: .good)
} else if !group.records.contains(where: isAvailable) {
StatusPill(title: group.first.unavailableReason, tone: .neutral)
}
@@ -225,7 +225,7 @@ struct HistoryRow: View {
Text(URL(fileURLWithPath: record.originalPath).lastPathComponent).fontWeight(.medium)
Text(relativeToHome(record.originalPath, home: home)).font(.caption).foregroundStyle(Theme.muted)
.lineLimit(1).truncationMode(.middle)
- Text("\(record.date.formatted(date: .abbreviated, time: .shortened)) · \(Format.bytes(record.bytes)) · файлов \(record.files) · \(record.location)")
+ Text(tr("\(record.date.formatted(date: .abbreviated, time: .shortened)) · \(Format.bytes(record.bytes)) · файлов \(record.files) · \(record.location)"))
.font(.caption).foregroundStyle(Theme.muted)
if let note = record.note, !note.isEmpty {
Text(note).font(.caption).foregroundStyle(Theme.warn).fixedSize(horizontal: false, vertical: true)
@@ -236,13 +236,13 @@ struct HistoryRow: View {
// Лупа видна под мышью: в каждой строке сразу она только шумела бы.
Button { revealInFinder(URL(fileURLWithPath: record.archivedPath)) } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.borderless)
- .help("Показать на диске")
+ .help(tr("Показать на диске"))
.opacity(hovering ? 1 : 0)
}
if record.restored {
- StatusPill(title: "Возвращено", systemImage: "checkmark", tone: .good)
+ StatusPill(title: tr("Возвращено"), systemImage: "checkmark", tone: .good)
} else if available {
- Button("Вернуть…", action: onRestore)
+ Button(tr("Вернуть…"), action: onRestore)
.buttonStyle(PillButtonStyle())
.disabled(busy)
} else {
@@ -254,10 +254,10 @@ struct HistoryRow: View {
.onHover { hovering = $0 }
.contextMenu {
if archiveExists {
- Button("Показать на диске") { revealInFinder(URL(fileURLWithPath: record.archivedPath)) }
+ Button(tr("Показать на диске")) { revealInFinder(URL(fileURLWithPath: record.archivedPath)) }
}
if available, !record.restored {
- Button("Вернуть…", action: onRestore).disabled(busy)
+ Button(tr("Вернуть…"), action: onRestore).disabled(busy)
}
}
}
@@ -276,29 +276,29 @@ struct ImportSheet: View {
@State private var busy = false
var body: some View {
- SheetLayout(systemImage: "plus.rectangle.on.folder", title: "Добавить перенесённое вручную",
- subtitle: "Для папок и файлов, которые вы уже перенесли на внешний диск без OffLoadAI. Запись появится в списке, и вернуть их на Mac можно будет как обычно — со сверкой.",
+ SheetLayout(systemImage: "plus.rectangle.on.folder", title: tr("Добавить перенесённое вручную"),
+ subtitle: tr("Для папок и файлов, которые вы уже перенесли на внешний диск без OffLoadAI. Запись появится в списке, и вернуть их на Mac можно будет как обычно — со сверкой."),
width: 580) {
VStack(spacing: 0) {
- pickRow("На диске", value: archive.map { relativeToVolume($0) }, action: pickArchive)
+ pickRow(tr("На диске"), value: archive.map { relativeToVolume($0) }, action: pickArchive)
RowDivider()
- pickRow("Было на Mac в папке", value: originalParent.map { relativeToHome($0.path, home: app.rules.home) }, action: pickParent)
+ pickRow(tr("Было на Mac в папке"), value: originalParent.map { relativeToHome($0.path, home: app.rules.home) }, action: pickParent)
RowDivider()
- FormRow(title: "Под именем") {
- TextField("имя папки или файла", text: $originalName)
+ FormRow(title: tr("Под именем")) {
+ TextField(tr("имя папки или файла"), text: $originalName)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 280)
}
}
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
- Toggle("Оригинал уже удалён с Mac", isOn: $originalRemoved)
- TextField("Заметка, например «упаковано в tar.gz» (необязательно)", text: $note, axis: .vertical)
+ Toggle(tr("Оригинал уже удалён с Mac"), isOn: $originalRemoved)
+ TextField(tr("Заметка, например «упаковано в tar.gz» (необязательно)"), text: $note, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...3)
if let error { Notice(.error, error) }
} actions: {
- Button("Отмена") { dismiss() }
- Button("Добавить") { add() }
+ Button(tr("Отмена")) { dismiss() }
+ Button(tr("Добавить")) { add() }
.keyboardShortcut(.defaultAction)
.disabled(archive == nil || originalParent == nil || originalName.isEmpty || busy)
}
@@ -308,17 +308,17 @@ struct ImportSheet: View {
HStack(spacing: 12) {
Text(title)
Spacer(minLength: 12)
- Text(value ?? "не выбрано")
+ Text(value ?? tr("не выбрано"))
.lineLimit(1).truncationMode(.middle)
.foregroundStyle(value == nil ? .secondary : .primary)
- Button("Выбрать…", action: action)
+ Button(tr("Выбрать…"), action: action)
}
.rowPadding()
}
private func relativeToVolume(_ url: URL) -> String {
guard let root = app.destination?.mountPoint.path, url.path.hasPrefix(root + "/") else { return url.path }
- return "«\(app.destination?.name ?? "")» / " + url.path.dropFirst(root.count + 1)
+ return tr("«\(app.destination?.name ?? "")» / ") + url.path.dropFirst(root.count + 1)
}
private func pickArchive() {
@@ -327,8 +327,8 @@ struct ImportSheet: View {
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = app.destination?.mountPoint
- panel.prompt = "Выбрать"
- panel.message = "Что лежит на внешнем диске"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Что лежит на внешнем диске")
guard panel.runModal() == .OK, let url = panel.url else { return }
archive = url
if originalName.isEmpty { originalName = url.lastPathComponent }
@@ -341,8 +341,8 @@ struct ImportSheet: View {
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.directoryURL = app.rules.home
- panel.prompt = "Выбрать"
- panel.message = "Папка на Mac, где это лежало"
+ panel.prompt = tr("Выбрать")
+ panel.message = tr("Папка на Mac, где это лежало")
guard panel.runModal() == .OK, let url = panel.url else { return }
originalParent = url
}
diff --git a/Sources/Offload/Views/OverviewView.swift b/Sources/Offload/Views/OverviewView.swift
index 2d1112b..8e88ee5 100644
--- a/Sources/Offload/Views/OverviewView.swift
+++ b/Sources/Offload/Views/OverviewView.swift
@@ -23,7 +23,7 @@ struct OverviewView: View {
ScenarioCard(macDisk: model.disk)
if !model.advice.isEmpty {
Card {
- CardTitle("Что стоит сделать", systemImage: "lightbulb")
+ CardTitle(tr("Что стоит сделать"), systemImage: "lightbulb")
ForEach(model.advice, id: \.self) { tip in
Label {
Text(tip).fixedSize(horizontal: false, vertical: true)
@@ -35,12 +35,12 @@ struct OverviewView: View {
}
if let memory = model.memory, !memory.apps.isEmpty {
Card {
- CardTitle("Память по приложениям", systemImage: "square.stack.3d.up")
+ CardTitle(tr("Память по приложениям"), systemImage: "square.stack.3d.up")
AppMemoryList(apps: memory.apps, physical: memory.physicalBytes)
}
}
}
- .navigationTitle("Обзор")
+ .navigationTitle(tr("Обзор"))
.task { await model.poll() }
}
}
@@ -50,7 +50,7 @@ struct DiskCard: View {
var body: some View {
Card(spacing: 14, fillsHeight: true) {
- CardTitle("Диск Mac", systemImage: "internaldrive")
+ CardTitle(tr("Диск Mac"), systemImage: "internaldrive")
if let volume, volume.totalBytes > 0 {
let used = Double(volume.totalBytes - volume.availableBytes) / Double(volume.totalBytes)
// Те же пороги, что у шага «Освободите место» и у совета: меньше 15% свободно — мало.
@@ -67,12 +67,12 @@ struct DiskCard: View {
Text(Format.bytes(volume.availableBytes))
.font(Theme.display(26))
.monospacedDigit()
- Text("свободно из \(Format.bytes(volume.totalBytes))")
+ Text(tr("свободно из \(Format.bytes(volume.totalBytes))"))
.font(.caption).foregroundStyle(Theme.muted)
}
}
Spacer(minLength: 0)
- Label(low ? "Мало места" : "Места достаточно",
+ Label(low ? tr("Мало места") : tr("Места достаточно"),
systemImage: low ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.font(.callout.weight(.medium))
.foregroundStyle(low ? tint : Color.green)
@@ -89,7 +89,7 @@ struct ExternalDiskCard: View {
var body: some View {
Card(spacing: 14, fillsHeight: true) {
- CardTitle("Внешний диск", systemImage: "externaldrive")
+ CardTitle(tr("Внешний диск"), systemImage: "externaldrive")
if let disk = app.destination {
VStack(alignment: .leading, spacing: 6) {
Text(disk.name)
@@ -98,7 +98,7 @@ struct ExternalDiskCard: View {
if disk.totalBytes > 0 {
CapacityBar(fraction: Double(disk.totalBytes - disk.availableBytes) / Double(disk.totalBytes))
}
- Text("\(disk.fsDisplayName) · свободно \(Format.bytes(disk.availableBytes)) из \(Format.bytes(disk.totalBytes))")
+ Text(tr("\(disk.fsDisplayName) · свободно \(Format.bytes(disk.availableBytes)) из \(Format.bytes(disk.totalBytes))"))
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
}
@@ -109,10 +109,10 @@ struct ExternalDiskCard: View {
.foregroundStyle(summary.tone.color)
} else {
VStack(alignment: .leading, spacing: 6) {
- Text("Не подключён")
+ Text(tr("Не подключён"))
.font(Theme.display(19))
.foregroundStyle(Theme.muted)
- Text("На него OffLoadAI переносит то, что не нужно держать на Mac, и там же живёт сейф.")
+ Text(tr("На него OffLoadAI переносит то, что не нужно держать на Mac, и там же живёт сейф."))
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
}
@@ -127,7 +127,7 @@ struct MemoryCard: View {
var body: some View {
Card(spacing: 14, fillsHeight: true) {
- CardTitle(title: "Память", systemImage: "memorychip") {
+ CardTitle(title: tr("Память"), systemImage: "memorychip") {
if let snapshot {
Text(Format.memory(snapshot.physicalBytes)).font(.callout).foregroundStyle(Theme.muted)
}
@@ -138,13 +138,13 @@ struct MemoryCard: View {
Circle().fill(color(snapshot.pressure)).frame(width: 10, height: 10)
Text(snapshot.pressure.title).font(Theme.display(19))
}
- Text("давление памяти").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("давление памяти")).font(.caption).foregroundStyle(Theme.muted)
}
VStack(spacing: 5) {
- InfoRow("Swap", value: "\(Format.memory(snapshot.swapUsedBytes)) из \(Format.memory(snapshot.swapTotalBytes))")
- InfoRow("Сжато", value: Format.memory(snapshot.compressedBytes))
- InfoRow("Свободно", value: Format.memory(snapshot.freeBytes))
- InfoRow("Без перезагрузки", value: uptime(snapshot.uptime))
+ InfoRow("Swap", value: tr("\(Format.memory(snapshot.swapUsedBytes)) из \(Format.memory(snapshot.swapTotalBytes))"))
+ InfoRow(tr("Сжато"), value: Format.memory(snapshot.compressedBytes))
+ InfoRow(tr("Свободно"), value: Format.memory(snapshot.freeBytes))
+ InfoRow(tr("Без перезагрузки"), value: uptime(snapshot.uptime))
}
.font(.caption)
.monospacedDigit()
@@ -166,7 +166,7 @@ struct MemoryCard: View {
private func uptime(_ interval: TimeInterval) -> String {
let days = Int(interval / 86_400)
let hours = Int(interval.truncatingRemainder(dividingBy: 86_400) / 3_600)
- return days > 0 ? "\(days) дн. \(hours) ч." : "\(hours) ч."
+ return days > 0 ? tr("\(days) дн. \(hours) ч.") : tr("\(hours) ч.")
}
}
@@ -209,8 +209,8 @@ struct ScenarioCard: View {
let next = list.first { $0.state != .done }?.id
Card(padding: 0, spacing: 0) {
VStack(alignment: .leading, spacing: 10) {
- CardTitle(title: "Порядок работы", systemImage: "list.number") {
- Text("готово \(done) из \(list.count)")
+ CardTitle(title: tr("Порядок работы"), systemImage: "list.number") {
+ Text(tr("готово \(done) из \(list.count)"))
.font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
}
CapacityBar(fraction: Double(done) / Double(max(1, list.count)), tint: Theme.ok, height: 4)
@@ -228,55 +228,55 @@ struct ScenarioCard: View {
var steps: [Step] = []
let host = app.destination
steps.append(host.map {
- Step(id: 1, state: .done, title: "Внешний диск «\($0.name)»",
- detail: "\($0.fsDisplayName) · свободно \(Format.bytes($0.availableBytes))" + (app.safe.state?.hostEncrypted == true ? " · зашифрован целиком" : ""))
- } ?? Step(id: 1, state: .todo, title: "Подключите внешний диск",
- detail: "На него OffLoadAI переносит то, что не нужно держать на Mac, и там же живёт сейф."))
+ Step(id: 1, state: .done, title: tr("Внешний диск «\($0.name)»"),
+ detail: tr("\($0.fsDisplayName) · свободно \(Format.bytes($0.availableBytes))") + (app.safe.state?.hostEncrypted == true ? tr(" · зашифрован целиком") : ""))
+ } ?? Step(id: 1, state: .todo, title: tr("Подключите внешний диск"),
+ detail: tr("На него OffLoadAI переносит то, что не нужно держать на Mac, и там же живёт сейф.")))
let safe = app.safe
if host != nil {
if let state = safe.state, state.exists, state.isEncrypted, let limit = state.sizeLimit, limit < 20 << 30 {
- steps.append(Step(id: 2, state: .warning, title: "Сейф мал для переноса",
- detail: "«\(state.displayName)» ограничен \(Format.bytes(limit)): для ключей хватит, для больших папок — нет. Предел можно увеличить без потери содержимого.",
- action: ("Сейф", .safe)))
+ steps.append(Step(id: 2, state: .warning, title: tr("Сейф мал для переноса"),
+ detail: tr("«\(state.displayName)» ограничен \(Format.bytes(limit)): для ключей хватит, для больших папок — нет. Предел можно увеличить без потери содержимого."),
+ action: (tr("Сейф"), .safe)))
} else if let state = safe.state, state.exists, state.isEncrypted {
steps.append(Step(id: 2, state: .done,
- title: safe.isOpen ? "Сейф открыт" : "Сейф закрыт",
+ title: safe.isOpen ? tr("Сейф открыт") : tr("Сейф закрыт"),
detail: safe.isOpen
- ? "Перенос, бэкап и ключи идут в него. Закройте, когда закончите."
- : "На диске только шифротекст. Откройте, чтобы класть в сейф или брать из него.",
- action: ("Сейф", .safe)))
+ ? tr("Перенос, бэкап и ключи идут в него. Закройте, когда закончите.")
+ : tr("На диске только шифротекст. Откройте, чтобы класть в сейф или брать из него."),
+ action: (tr("Сейф"), .safe)))
} else {
- steps.append(Step(id: 2, state: .todo, title: "Заведите сейф",
- detail: "Зашифрованный образ на внешнем диске: без пароля его содержимое не прочтёт никто.",
- action: ("Создать", .safe)))
+ steps.append(Step(id: 2, state: .todo, title: tr("Заведите сейф"),
+ detail: tr("Зашифрованный образ на внешнем диске: без пароля его содержимое не прочтёт никто."),
+ action: (tr("Создать"), .safe)))
}
}
if let macDisk, macDisk.totalBytes > 0 {
let free = Double(macDisk.availableBytes) / Double(macDisk.totalBytes)
steps.append(Step(id: 3, state: free < 0.15 ? .warning : .done,
- title: free < 0.15 ? "Освободите место на Mac" : "Места на Mac достаточно",
- detail: "Свободно \(Format.bytes(macDisk.availableBytes)) из \(Format.bytes(macDisk.totalBytes))."
- + (free < 0.15 ? " Перенесите большое и редко нужное в сейф — вернуть можно в любой момент." : ""),
- action: ("Освободить место", .space)))
+ title: free < 0.15 ? tr("Освободите место на Mac") : tr("Места на Mac достаточно"),
+ detail: tr("Свободно \(Format.bytes(macDisk.availableBytes)) из \(Format.bytes(macDisk.totalBytes)).")
+ + (free < 0.15 ? tr(" Перенесите большое и редко нужное в сейф — вернуть можно в любой момент.") : ""),
+ action: (tr("Освободить место"), .space)))
}
if host != nil {
let plain = app.plainRecords
if !plain.isEmpty {
let bytes = plain.reduce(Int64(0)) { $0 + $1.bytes }
- steps.append(Step(id: 4, state: .warning, title: "Зашифруйте то, что уже лежит на диске открыто",
- detail: "\(plain.count) \(pluralRu(plain.count, "объект", "объекта", "объектов")), \(Format.bytes(bytes)) — прочтёт любой, у кого окажется диск.",
- action: ("Зашифровать", .safe)))
+ steps.append(Step(id: 4, state: .warning, title: tr("Зашифруйте то, что уже лежит на диске открыто"),
+ detail: tr("\(plain.count) \(pluralRu(plain.count, tr("объект"), tr("объекта"), tr("объектов"))), \(Format.bytes(bytes)) — прочтёт любой, у кого окажется диск."),
+ action: (tr("Зашифровать"), .safe)))
}
}
let sources = app.backup.sources.count
steps.append(Step(id: 5, state: sources == 0 ? .todo : .done,
- title: sources == 0 ? "Настройте бэкап проектов и ключей" : "Бэкап: папок \(sources)",
- detail: "Обновляемая копия проектов и ключи с токенами — в сейф.",
- action: ("Бэкап", .backup)))
+ title: sources == 0 ? tr("Настройте бэкап проектов и ключей") : tr("Бэкап: папок \(sources)"),
+ detail: tr("Обновляемая копия проектов и ключи с токенами — в сейф."),
+ action: (tr("Бэкап"), .backup)))
return steps
}
}
@@ -344,17 +344,17 @@ struct ConnectedPrompt: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "externaldrive.fill.badge.plus", size: 40)
VStack(alignment: .leading, spacing: 4) {
- Text("Подключён «\(disk)»").font(.headline)
- Text("Разобрать Mac: найду, что занимает место зря, и спрошу про каждое — удалить, убрать в сейф или добавить в бэкап. Без вашего «да» ничего не трогаю.")
+ Text(tr("Подключён «\(disk)»")).font(.headline)
+ Text(tr("Разобрать Mac: найду, что занимает место зря, и спрошу про каждое — удалить, убрать в сейф или добавить в бэкап. Без вашего «да» ничего не трогаю."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
HStack {
Button {
app.connectedPrompt = nil
app.section = .cleanup
app.cleanup.scan(app: app)
- } label: { Label("Разобрать", systemImage: "wand.and.stars") }
+ } label: { Label(tr("Разобрать"), systemImage: "wand.and.stars") }
.prominentButton()
- Button("Не сейчас") { app.connectedPrompt = nil }
+ Button(tr("Не сейчас")) { app.connectedPrompt = nil }
}
.padding(.top, 2)
}
diff --git a/Sources/Offload/Views/ProView.swift b/Sources/Offload/Views/ProView.swift
index e749e5c..f9ea9f6 100644
--- a/Sources/Offload/Views/ProView.swift
+++ b/Sources/Offload/Views/ProView.swift
@@ -8,14 +8,14 @@ struct ProSheet: View {
@State private var key = ""
/// Цена — одной строкой здесь и в README («OffLoadAI Pro»).
- static let price = "1 490 ₽ или $19 — один раз"
- static let terms = "Ключ работает всегда. Новые версии — год, дальше продление за полцены; не продлили — остаётся последняя версия того года."
+ static let price = tr("1 490 ₽ или $19 — один раз")
+ static let terms = tr("Ключ работает всегда. Новые версии — год, дальше продление за полцены; не продлили — остаётся последняя версия того года.")
var body: some View {
let pro = app.pro
SheetLayout(systemImage: "sparkles", title: "OffLoadAI Pro", subtitle: subtitle, width: 540) {
if let reason = pro.reason, !pro.status.isPro {
- Notice(.info, "«\(reason.title)» — в OffLoadAI Pro. \(reasonTail)")
+ Notice(.info, tr("«\(reason.title)» — в OffLoadAI Pro. \(reasonTail)"))
}
VStack(spacing: 0) {
ForEach(ProFeature.allCases, id: \.self) { feature in
@@ -28,28 +28,28 @@ struct ProSheet: View {
}
Spacer(minLength: 8)
if pro.status.isPro {
- Image(systemName: "checkmark").foregroundStyle(Theme.ok).accessibilityLabel("Открыто")
+ Image(systemName: "checkmark").foregroundStyle(Theme.ok).accessibilityLabel(tr("Открыто"))
}
}
.padding(.vertical, 8)
if feature != ProFeature.allCases.last { RowDivider(inset: 40) }
}
}
- Text("Бесплатно всегда: сейф, перенос со сверкой, возврат перенесённого, очистка мусора и Docker, старые установщики, ключи и токены в сейф, восстановление из iCloud. Вернуть своё OffLoadAI не мешает никогда — ни без ключа, ни после пробы.")
+ Text(tr("Бесплатно всегда: сейф, перенос со сверкой, возврат перенесённого, очистка мусора и Docker, старые установщики, ключи и токены в сейф, восстановление из iCloud. Вернуть своё OffLoadAI не мешает никогда — ни без ключа, ни после пробы."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
licenseBlock
} actions: {
if pro.status.isPro {
if case .licensed = pro.status {} else {
- Button("Купить…") { NSWorkspace.shared.open(ProModel.purchaseURL) }
+ Button(tr("Купить…")) { NSWorkspace.shared.open(ProModel.purchaseURL) }
}
- Button("Готово") { dismiss() }
+ Button(tr("Готово")) { dismiss() }
.prominentButton()
.keyboardShortcut(.defaultAction)
} else {
- Button("Закрыть") { dismiss() }
+ Button(tr("Закрыть")) { dismiss() }
.keyboardShortcut(.cancelAction)
- Button("Купить…") { NSWorkspace.shared.open(ProModel.purchaseURL) }
+ Button(tr("Купить…")) { NSWorkspace.shared.open(ProModel.purchaseURL) }
.prominentButton()
}
}
@@ -58,18 +58,18 @@ struct ProSheet: View {
private var subtitle: String {
switch app.pro.status {
- case .licensed(let license): return "Ключ на имя «\(license.name)»"
- case .early: return "Вы пользовались OffLoadAI до Pro — всё открыто навсегда"
- case .trial(let days): return "Пробный период: осталось \(days) \(pluralRu(days, "день", "дня", "дней"))"
- case .expired: return "Обновления по ключу закончились"
+ case .licensed(let license): return tr("Ключ на имя «\(license.name)»")
+ case .early: return tr("Вы пользовались OffLoadAI до Pro — всё открыто навсегда")
+ case .trial(let days): return tr("Пробный период: осталось \(days) \(pluralRu(days, tr("день"), tr("дня"), tr("дней")))")
+ case .expired: return tr("Обновления по ключу закончились")
case .free: return Self.price
}
}
private var reasonTail: String {
switch app.pro.status {
- case .expired: return "Ключ не открывает эту версию — продлите его."
- default: return "Пробные две недели закончились; всё найденное по-прежнему видно, а «не сейчас» работает как всегда."
+ case .expired: return tr("Ключ не открывает эту версию — продлите его.")
+ default: return tr("Пробные две недели закончились; всё найденное по-прежнему видно, а «не сейчас» работает как всегда.")
}
}
@@ -79,12 +79,12 @@ struct ProSheet: View {
switch pro.status {
case .licensed(let license):
HStack(alignment: .firstTextBaseline) {
- Text("Ключ №\(license.id). Обновления до \(ProModel.day(license.updatesUntil)).")
+ Text(tr("Ключ №\(license.id). Обновления до \(ProModel.day(license.updatesUntil))."))
.font(.callout).foregroundStyle(Theme.muted)
Spacer(minLength: 8)
- Button("Убрать ключ с этого Mac") { pro.removeLicense() }
+ Button(tr("Убрать ключ с этого Mac")) { pro.removeLicense() }
.buttonStyle(InkLinkStyle())
- .help("Например, перед продажей Mac. Ключ остаётся вашим — введите его на новом Mac.")
+ .help(tr("Например, перед продажей Mac. Ключ остаётся вашим — введите его на новом Mac."))
}
default:
VStack(alignment: .leading, spacing: 8) {
@@ -95,10 +95,10 @@ struct ProSheet: View {
.fixedSize(horizontal: false, vertical: true)
}
HStack(spacing: 8) {
- TextField("Ключ: OFFLOAD-…", text: $key)
+ TextField(tr("Ключ: OFFLOAD-…"), text: $key)
.textFieldStyle(.roundedBorder)
.onSubmit(activate)
- Button("Ввести ключ", action: activate)
+ Button(tr("Ввести ключ"), action: activate)
.disabled(key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
if let problem = pro.keyProblem {
@@ -140,7 +140,7 @@ struct ProSidebarRow: View {
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
- .help("OffLoadAI Pro: что в нём и ключ")
+ .help(tr("OffLoadAI Pro: что в нём и ключ"))
}
}
@@ -150,6 +150,6 @@ struct ProTag: View {
Text("Pro").font(.system(size: 10, weight: .bold))
.padding(.horizontal, 5).padding(.vertical, 1)
.overlay(Capsule().strokeBorder(lineWidth: 1).opacity(0.6))
- .accessibilityLabel("Нужен OffLoadAI Pro")
+ .accessibilityLabel(tr("Нужен OffLoadAI Pro"))
}
}
diff --git a/Sources/Offload/Views/SafeComponents.swift b/Sources/Offload/Views/SafeComponents.swift
index bbdd717..3ff3f35 100644
--- a/Sources/Offload/Views/SafeComponents.swift
+++ b/Sources/Offload/Views/SafeComponents.swift
@@ -4,10 +4,10 @@ import SwiftUI
extension SafeModel {
/// Состояние сейфа одной строкой — для боковой панели и «Обзора».
var summary: (title: String, systemImage: String, tone: Tone) {
- guard let state else { return ("Смотрю, есть ли сейф…", "lock", .neutral) }
- if !state.exists { return ("Сейфа нет", "lock.slash", .neutral) }
- if !state.isEncrypted { return ("Образ не зашифрован", "exclamationmark.octagon.fill", .danger) }
- return isOpen ? ("Сейф открыт", "lock.open.fill", .caution) : ("Сейф закрыт", "lock.fill", .good)
+ guard let state else { return (tr("Смотрю, есть ли сейф…"), "lock", .neutral) }
+ if !state.exists { return (tr("Сейфа нет"), "lock.slash", .neutral) }
+ if !state.isEncrypted { return (tr("Образ не зашифрован"), "exclamationmark.octagon.fill", .danger) }
+ return isOpen ? (tr("Сейф открыт"), "lock.open.fill", .caution) : (tr("Сейф закрыт"), "lock.fill", .good)
}
}
@@ -28,7 +28,7 @@ struct PasswordStrengthView: View {
.frame(width: 34, height: 5)
}
}
- Text(password.isEmpty ? "Введите пароль" : "\(strength.title) · ≈\(Int(strength.bits)) бит")
+ Text(password.isEmpty ? tr("Введите пароль") : tr("\(strength.title) · ≈\(Int(strength.bits)) бит"))
.font(.caption.weight(.medium))
.foregroundStyle(password.isEmpty ? .secondary : color(strength.level))
.monospacedDigit()
@@ -60,13 +60,13 @@ struct NewPasswordFields: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
- SecureField("Новый пароль", text: $password)
+ SecureField(tr("Новый пароль"), text: $password)
.textFieldStyle(.roundedBorder)
- SecureField("Ещё раз", text: $confirmation)
+ SecureField(tr("Ещё раз"), text: $confirmation)
.textFieldStyle(.roundedBorder)
PasswordStrengthView(password: password)
if !confirmation.isEmpty, confirmation != password {
- Label("Пароли не совпадают.", systemImage: "xmark.circle.fill")
+ Label(tr("Пароли не совпадают."), systemImage: "xmark.circle.fill")
.font(.caption).foregroundStyle(Theme.bad)
}
}
@@ -79,7 +79,7 @@ struct NewPasswordFields: View {
struct SafeUnlockRow: View {
@Environment(AppModel.self) private var app
@State private var password = ""
- var prompt = "Пароль сейфа"
+ var prompt = tr("Пароль сейфа")
var body: some View {
VStack(alignment: .leading, spacing: 6) {
@@ -91,7 +91,7 @@ struct SafeUnlockRow: View {
if app.safe.activity != nil {
ProgressView().controlSize(.small)
} else {
- Button("Открыть", action: open)
+ Button(tr("Открыть"), action: open)
.prominentButton()
.disabled(password.isEmpty)
}
@@ -123,12 +123,12 @@ struct TargetSummary: View {
if let target = app.target {
if target.isEncryptedImage {
banner(systemImage: "lock.fill", tone: .good,
- title: "В сейф «\(target.name)»",
- detail: "зашифровано · свободно \(Format.bytes(target.availableBytes))")
+ title: tr("В сейф «\(target.name)»"),
+ detail: tr("зашифровано · свободно \(Format.bytes(target.availableBytes))"))
} else {
banner(systemImage: "lock.open.trianglebadge.exclamationmark", tone: .caution,
- title: "На диск «\(target.name)» открыто",
- detail: "не зашифровано · свободно \(Format.bytes(target.availableBytes))")
+ title: tr("На диск «\(target.name)» открыто"),
+ detail: tr("не зашифровано · свободно \(Format.bytes(target.availableBytes))"))
}
} else if let problem = app.targetProblem {
banner(systemImage: "lock.slash", tone: problemTone, title: problem, detail: nil)
@@ -162,13 +162,13 @@ struct SafeStatusPanel: View {
let shape = RoundedRectangle(cornerRadius: 10, style: .continuous)
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 6) {
- Text("Внешний диск").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Внешний диск")).font(.caption).foregroundStyle(Theme.muted)
if app.volumes.isEmpty {
- Label("Не подключён", systemImage: "externaldrive.badge.xmark").font(.callout).foregroundStyle(Theme.muted)
+ Label(tr("Не подключён"), systemImage: "externaldrive.badge.xmark").font(.callout).foregroundStyle(Theme.muted)
} else {
// Выбор нужен, только когда дисков несколько; один диск — просто его имя.
if app.volumes.count > 1 || app.destination == nil {
- Picker("Внешний диск", selection: $app.destinationID) {
+ Picker(tr("Внешний диск"), selection: $app.destinationID) {
ForEach(app.volumes) { volume in
Text(volume.name).tag(Optional(volume.id))
}
@@ -177,7 +177,7 @@ struct SafeStatusPanel: View {
// Сон, блокировка и простой закрывают сейф выбранного диска. Переключись на другой
// диск при открытом сейфе — прежний остался бы открытым без присмотра.
.disabled(app.safe.isOpen || app.safe.activity != nil)
- .help(app.safe.isOpen ? "Закройте сейф, чтобы выбрать другой диск" : "")
+ .help(app.safe.isOpen ? tr("Закройте сейф, чтобы выбрать другой диск") : "")
} else if let volume = app.destination {
Label(volume.name, systemImage: "externaldrive.fill")
.font(.callout.weight(.medium))
@@ -187,7 +187,7 @@ struct SafeStatusPanel: View {
if volume.totalBytes > 0 {
CapacityBar(fraction: Double(volume.totalBytes - volume.availableBytes) / Double(volume.totalBytes), height: 4)
}
- Text("\(volume.fsDisplayName) · свободно \(Format.bytes(volume.availableBytes))")
+ Text(tr("\(volume.fsDisplayName) · свободно \(Format.bytes(volume.availableBytes))"))
.font(.caption).foregroundStyle(Theme.muted)
}
}
@@ -196,12 +196,12 @@ struct SafeStatusPanel: View {
if app.destination != nil {
Divider()
safeRow
- Picker("Куда класть", selection: $app.storeMode) {
+ Picker(tr("Куда класть"), selection: $app.storeMode) {
ForEach(StoreMode.allCases) { Text($0.title).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
- .help("Куда пойдут перенос, бэкап и тома Docker")
+ .help(tr("Куда пойдут перенос, бэкап и тома Docker"))
}
}
.padding(10)
@@ -224,19 +224,19 @@ struct SafeStatusPanel: View {
if safe.activity != nil {
ProgressView().controlSize(.small)
} else if safe.isOpen {
- Button("Закрыть") { safe.close(app: app) }
+ Button(tr("Закрыть")) { safe.close(app: app) }
.controlSize(.small)
- .help("Закрыть сейф (⌘⇧L)")
+ .help(tr("Закрыть сейф (⌘⇧L)"))
} else if safe.state?.isEncrypted == true {
- Button("Открыть") { unlocking = true }
+ Button(tr("Открыть")) { unlocking = true }
.controlSize(.small)
.popover(isPresented: $unlocking, arrowEdge: .trailing) {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
IconTile(systemImage: "lock.fill", tone: .good, size: 32)
VStack(alignment: .leading, spacing: 1) {
- Text("Открыть сейф").font(.headline)
- Text("«\(safe.state?.displayName ?? "")»").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Открыть сейф")).font(.headline)
+ Text(tr("«\(safe.state?.displayName ?? "")»")).font(.caption).foregroundStyle(Theme.muted)
}
}
SafeUnlockRow().frame(width: 300)
@@ -245,7 +245,7 @@ struct SafeStatusPanel: View {
.onChange(of: safe.isOpen) { if safe.isOpen { unlocking = false } }
}
} else {
- Button("Создать") { app.section = .safe }.controlSize(.small)
+ Button(tr("Создать")) { app.section = .safe }.controlSize(.small)
}
}
}
@@ -253,9 +253,9 @@ struct SafeStatusPanel: View {
/// Вторая строка под состоянием: где сейф и сколько в нём места.
private var status: String {
let safe = app.safe
- guard let state = safe.state, state.exists else { return "на «\(app.destination?.name ?? "")»" }
- if !state.isEncrypted { return "класть в него нельзя" }
- if let volume = app.safeVolume { return "свободно \(Format.bytes(volume.availableBytes))" }
- return "«\(state.displayName)»"
+ guard let state = safe.state, state.exists else { return tr("на «\(app.destination?.name ?? "")»") }
+ if !state.isEncrypted { return tr("класть в него нельзя") }
+ if let volume = app.safeVolume { return tr("свободно \(Format.bytes(volume.availableBytes))") }
+ return tr("«\(state.displayName)»")
}
}
diff --git a/Sources/Offload/Views/SafeView.swift b/Sources/Offload/Views/SafeView.swift
index eeed3a9..0f09eb2 100644
--- a/Sources/Offload/Views/SafeView.swift
+++ b/Sources/Offload/Views/SafeView.swift
@@ -24,35 +24,35 @@ struct SafeView: View {
Card(spacing: 16) {
statusSection
}
- Text("Сейф — зашифрованный образ (AES-256) на внешнем диске. Пока он закрыт, на диске лежит только шифротекст: потерянный или украденный диск ничего не выдаст. Пароль OffLoadAI не хранит и не записывает — если его забыть, данные не восстановит никто.")
+ Text(tr("Сейф — зашифрованный образ (AES-256) на внешнем диске. Пока он закрыт, на диске лежит только шифротекст: потерянный или украденный диск ничего не выдаст. Пароль OffLoadAI не хранит и не записывает — если его забыть, данные не восстановит никто."))
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 4)
}
if safe.state?.isEncrypted == true {
- CardSection(title: "Автоматическое закрытие",
- footer: "Пока сейф открыт, ключ шифрования живёт в памяти Mac, а файлы доступны программам под вашей учётной записью. Поэтому лучше не держать его открытым без нужды. При выходе из OffLoadAI сейф закрывается всегда.") {
+ CardSection(title: tr("Автоматическое закрытие"),
+ footer: tr("Пока сейф открыт, ключ шифрования живёт в памяти Mac, а файлы доступны программам под вашей учётной записью. Поэтому лучше не держать его открытым без нужды. При выходе из OffLoadAI сейф закрывается всегда.")) {
autoCloseSection
}
}
if app.destination != nil {
- CardSection(title: "Открытые данные на диске") {
+ CardSection(title: tr("Открытые данные на диске")) {
exposureSection
}
}
if safe.state?.isEncrypted == true {
- CardSection(title: "Пароль, заголовок, место",
+ CardSection(title: tr("Пароль, заголовок, место"),
footer: safe.isOpen
- ? "Смена пароля, копия и восстановление заголовка, увеличение и сжатие — на закрытом сейфе."
- : "В заголовке лежит ключ данных, зашифрованный паролем: испортится он — пропадёт всё, даже при верном пароле. Храните копию заголовка отдельно от диска. Место, освобождённое внутри сейфа, идёт под новые данные, но сам образ на диске не уменьшается; сжатие возвращает его частично, а на больших сейфах macOS может не вернуть ничего — OffLoadAI покажет, сколько вернулось на самом деле.") {
+ ? tr("Смена пароля, копия и восстановление заголовка, увеличение и сжатие — на закрытом сейфе.")
+ : tr("В заголовке лежит ключ данных, зашифрованный паролем: испортится он — пропадёт всё, даже при верном пароле. Храните копию заголовка отдельно от диска. Место, освобождённое внутри сейфа, идёт под новые данные, но сам образ на диске не уменьшается; сжатие возвращает его частично, а на больших сейфах macOS может не вернуть ничего — OffLoadAI покажет, сколько вернулось на самом деле.")) {
keySection
}
}
- CardSection(title: "Что защищено, а что нет") {
+ CardSection(title: tr("Что защищено, а что нет")) {
honestySection
}
}
- .navigationTitle("Сейф")
+ .navigationTitle(tr("Сейф"))
.disabled(safe.activity != nil && safe.migration == nil)
.overlay(alignment: .top) {
if let activity = safe.activity {
@@ -86,24 +86,24 @@ struct SafeView: View {
if !state.exists || creatingAnother {
createForm(host: host, replacing: creatingAnother ? state : nil)
} else if !state.isEncrypted {
- Notice(.error, "Образ «\(state.imageURL.lastPathComponent)» не зашифрован или шифрование не подтверждается. OffLoadAI не будет класть в него данные.")
+ Notice(.error, tr("Образ «\(state.imageURL.lastPathComponent)» не зашифрован или шифрование не подтверждается. OffLoadAI не будет класть в него данные."))
candidatesPicker(state)
- Button("Создать настоящий сейф") { creatingAnother = true }
+ Button(tr("Создать настоящий сейф")) { creatingAnother = true }
} else {
openedOrClosed(state: state, host: host)
}
} else {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
- Text("Смотрю, что на диске «\(host.name)»…").foregroundStyle(Theme.muted)
+ Text(tr("Смотрю, что на диске «\(host.name)»…")).foregroundStyle(Theme.muted)
}
}
} else {
HStack(spacing: 14) {
IconTile(systemImage: "externaldrive.badge.xmark", tone: .neutral, size: 52)
VStack(alignment: .leading, spacing: 3) {
- Text("Нет внешнего диска").font(Theme.display(22))
- Text("Подключите внешний диск — сейф живёт на нём.").foregroundStyle(Theme.muted)
+ Text(tr("Нет внешнего диска")).font(Theme.display(22))
+ Text(tr("Подключите внешний диск — сейф живёт на нём.")).foregroundStyle(Theme.muted)
}
}
}
@@ -116,21 +116,21 @@ struct SafeView: View {
HStack(spacing: 14) {
IconTile(systemImage: isOpen ? "lock.open.fill" : "lock.shield.fill", tone: isOpen ? .caution : .good, size: 52)
VStack(alignment: .leading, spacing: 3) {
- Text(isOpen ? "Сейф открыт" : "Сейф закрыт").font(Theme.display(22))
+ Text(isOpen ? tr("Сейф открыт") : tr("Сейф закрыт")).font(Theme.display(22))
Text(isOpen
- ? "Перенос, бэкап и ключи сейчас идут сюда. Закройте после работы."
- : "На диске только шифротекст. Чтобы класть в сейф или брать из него, откройте его паролем.")
+ ? tr("Перенос, бэкап и ключи сейчас идут сюда. Закройте после работы.")
+ : tr("На диске только шифротекст. Чтобы класть в сейф или брать из него, откройте его паролем."))
.foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
if let mount = state.mount {
- Button("Показать в Finder") {
+ Button(tr("Показать в Finder")) {
safe.noteUse()
// Том сейфа скрыт из боковой панели Finder (-nobrowse), поэтому открываем
// саму папку тома, а не выделяем её в /Volumes, где её не видно.
NSWorkspace.shared.open(mount)
}
- Button { safe.close(app: app) } label: { Label("Закрыть сейф", systemImage: "lock.fill") }
+ Button { safe.close(app: app) } label: { Label(tr("Закрыть сейф"), systemImage: "lock.fill") }
.prominentButton()
.keyboardShortcut("l", modifiers: [.command, .shift])
}
@@ -138,34 +138,34 @@ struct SafeView: View {
if state.mount == nil {
SafeUnlockRow().frame(maxWidth: 440)
} else if let pending = safe.pendingClose {
- Label("Закроется после копирования (\(pending))", systemImage: "clock")
+ Label(tr("Закроется после копирования (\(pending))"), systemImage: "clock")
.font(.callout).foregroundStyle(Theme.muted)
}
Divider()
HStack(alignment: .top, spacing: 16) {
- fact(Format.bytes(state.allocated), "занимает на диске")
+ fact(Format.bytes(state.allocated), tr("занимает на диске"))
if let limit = state.sizeLimit {
- fact(Format.bytes(limit), "предел роста")
+ fact(Format.bytes(limit), tr("предел роста"))
}
if let volume = app.safeVolume {
- fact(Format.bytes(volume.availableBytes), "свободно внутри")
- .help("Меньшее из свободного внутри образа и на самом диске")
+ fact(Format.bytes(volume.availableBytes), tr("свободно внутри"))
+ .help(tr("Меньшее из свободного внутри образа и на самом диске"))
}
}
VStack(spacing: 6) {
- InfoRow(title: "Образ") {
- Text("«\(state.imageURL.lastPathComponent)» на «\(host.name)»").textSelection(.enabled)
+ InfoRow(title: tr("Образ")) {
+ Text(tr("«\(state.imageURL.lastPathComponent)» на «\(host.name)»")).textSelection(.enabled)
}
- InfoRow("Шифрование", value: "AES-256" + (state.info?.version.map { ", формат \($0)" } ?? "") + (state.info.map { " · паролей: \($0.passphraseCount)" } ?? ""))
+ InfoRow(tr("Шифрование"), value: "AES-256" + (state.info?.version.map { tr(", формат \($0)") } ?? "") + (state.info.map { tr(" · паролей: \($0.passphraseCount)") } ?? ""))
}
.font(.callout)
if let limit = state.sizeLimit, limit < 20 << 30 {
- Notice(.warning, "Этот сейф ограничен \(Format.bytes(limit)): для ключей хватит, а для переноса больших папок — нет. Предел можно увеличить — содержимое останется на месте.")
+ Notice(.warning, tr("Этот сейф ограничен \(Format.bytes(limit)): для ключей хватит, а для переноса больших папок — нет. Предел можно увеличить — содержимое останется на месте."))
HStack {
- Button { sheet = .grow } label: { Label("Увеличить предел…", systemImage: "arrow.up.left.and.arrow.down.right") }
+ Button { sheet = .grow } label: { Label(tr("Увеличить предел…"), systemImage: "arrow.up.left.and.arrow.down.right") }
.prominentButton()
- Button("Создать другой сейф…") { creatingAnother = true }
+ Button(tr("Создать другой сейф…")) { creatingAnother = true }
}
}
candidatesPicker(state)
@@ -184,7 +184,7 @@ struct SafeView: View {
@ViewBuilder
private func candidatesPicker(_ state: SafeModel.State) -> some View {
if state.candidates.count > 1 {
- Picker("Какой образ — сейф", selection: Binding(get: { state.imageURL.standardizedFileURL },
+ Picker(tr("Какой образ — сейф"), selection: Binding(get: { state.imageURL.standardizedFileURL },
set: { app.safe.choose(image: $0, app: app) })) {
ForEach(state.candidates, id: \.self) { url in
Text(url.lastPathComponent).tag(url.standardizedFileURL)
@@ -200,32 +200,32 @@ struct SafeView: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "lock.shield", tone: .brand, size: 52)
VStack(alignment: .leading, spacing: 4) {
- Text(replacing == nil ? "На диске «\(host.name)» сейфа пока нет" : "Новый сейф на диске «\(host.name)»")
+ Text(replacing == nil ? tr("На диске «\(host.name)» сейфа пока нет") : tr("Новый сейф на диске «\(host.name)»"))
.font(Theme.display(22))
- Text("Образ разрежённый: места он занимает ровно столько, сколько в нём лежит, а предел лишь не даёт ему вырасти больше. Предел потом можно увеличить. Придумайте пароль, который не используете больше нигде. Надёжнее всего — фраза из 4–6 случайных слов.")
+ Text(tr("Образ разрежённый: места он занимает ровно столько, сколько в нём лежит, а предел лишь не даёт ему вырасти больше. Предел потом можно увеличить. Придумайте пароль, который не используете больше нигде. Надёжнее всего — фраза из 4–6 случайных слов."))
.foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 4) {
- Picker("Предел сейфа", selection: $newLimit) {
+ Picker(tr("Предел сейфа"), selection: $newLimit) {
ForEach(SafeModel.limitChoices(host: host), id: \.self) { limit in
- Text(limit == host.totalBytes ? "весь диск (\(Format.bytes(limit)))" : Format.bytes(limit))
+ Text(limit == host.totalBytes ? tr("весь диск (\(Format.bytes(limit)))") : Format.bytes(limit))
.tag(limit == host.totalBytes ? Int64?.none : Int64?.some(limit))
}
}
.fixedSize()
- Text("Остальное место на «\(host.name)» остаётся для обычных файлов, пока сейф до него не дорос.")
+ Text(tr("Остальное место на «\(host.name)» остаётся для обычных файлов, пока сейф до него не дорос."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
fields.frame(maxWidth: 440)
HStack {
if replacing != nil {
- Button("Отмена") {
+ Button(tr("Отмена")) {
creatingAnother = false
password = ""; confirmation = ""
}
}
- Button("Создать сейф") {
+ Button(tr("Создать сейф")) {
app.safe.create(password: password, limit: newLimit ?? host.totalBytes, app: app)
password = ""; confirmation = ""
creatingAnother = false
@@ -241,24 +241,24 @@ struct SafeView: View {
@ViewBuilder
private var autoCloseSection: some View {
@Bindable var safe = app.safe
- ToggleRow(title: "Когда Mac уходит в сон", isOn: $safe.closeOnSleep)
+ ToggleRow(title: tr("Когда Mac уходит в сон"), isOn: $safe.closeOnSleep)
RowDivider()
- ToggleRow(title: "Когда экран заблокирован, включилась заставка или сменился пользователь", isOn: $safe.closeOnLock)
+ ToggleRow(title: tr("Когда экран заблокирован, включилась заставка или сменился пользователь"), isOn: $safe.closeOnLock)
RowDivider()
- FormRow(title: "Если им не пользоваться") {
- Picker("Если им не пользоваться", selection: $safe.idleMinutes) {
+ FormRow(title: tr("Если им не пользоваться")) {
+ Picker(tr("Если им не пользоваться"), selection: $safe.idleMinutes) {
ForEach(SafeModel.idleChoices, id: \.self) { minutes in
- Text(minutes == 0 ? "не закрывать" : "\(minutes) мин").tag(minutes)
+ Text(minutes == 0 ? tr("не закрывать") : tr("\(minutes) мин")).tag(minutes)
}
}
.labelsHidden()
.fixedSize()
}
RowDivider()
- ToggleRow(title: "Прерывать копирование ради закрытия",
+ ToggleRow(title: tr("Прерывать копирование ради закрытия"),
detail: safe.interruptOperations
- ? "Идущий перенос отменится, оригиналы останутся на месте: они удаляются только после сверки копии."
- : "Если в сейф идёт копирование, он закроется сразу после его окончания.",
+ ? tr("Идущий перенос отменится, оригиналы останутся на месте: они удаляются только после сверки копии.")
+ : tr("Если в сейф идёт копирование, он закроется сразу после его окончания."),
isOn: $safe.interruptOperations)
}
@@ -273,22 +273,22 @@ struct SafeView: View {
if let migration = safe.migration {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
- Text("\(migration.index) из \(migration.count): «\(migration.item)» — \(migration.phase.lowercased())")
+ Text(tr("\(migration.index) из \(migration.count): «\(migration.item)» — \(migration.phase.lowercased())"))
.lineLimit(1).truncationMode(.middle)
Spacer()
- Text("\(Format.bytes(migration.bytesDone)) из \(Format.bytes(migration.bytesTotal))")
+ Text(tr("\(Format.bytes(migration.bytesDone)) из \(Format.bytes(migration.bytesTotal))"))
.font(.caption).foregroundStyle(Theme.muted).monospacedDigit()
}
ProgressView(value: migration.bytesTotal > 0 ? Double(migration.bytesDone) / Double(migration.bytesTotal) : 0)
- Button("Остановить") { safe.cancelMigration() }
+ Button(tr("Остановить")) { safe.cancelMigration() }
}
.rowPadding()
} else if plain.isEmpty {
- Label("Перенесённого, лежащего на диске открыто, нет.", systemImage: "checkmark.shield.fill")
+ Label(tr("Перенесённого, лежащего на диске открыто, нет."), systemImage: "checkmark.shield.fill")
.foregroundStyle(Theme.ok)
.rowPadding()
} else {
- Notice(.warning, "На диске «\(app.destination?.name ?? "")» открыто лежат перенесённые данные: \(plain.count) \(pluralRu(plain.count, "объект", "объекта", "объектов")), \(Format.bytes(bytes)). Кто получит диск в руки, прочтёт их без пароля.")
+ Notice(.warning, tr("На диске «\(app.destination?.name ?? "")» открыто лежат перенесённые данные: \(plain.count) \(pluralRu(plain.count, tr("объект"), tr("объекта"), tr("объектов"))), \(Format.bytes(bytes)). Кто получит диск в руки, прочтёт их без пароля."))
.padding(12)
ForEach(plain) { record in
Toggle(isOn: Binding(get: { !excluded.contains(record.id) },
@@ -305,15 +305,15 @@ struct SafeView: View {
RowDivider()
}
VStack(alignment: .leading, spacing: 10) {
- Text("Если какой-то программой вы пользуетесь прямо с диска (например, моделями LM Studio), после переноса в сейф укажите ей новую папку и держите сейф открытым, пока она нужна. Такие пункты можно снять.")
+ Text(tr("Если какой-то программой вы пользуетесь прямо с диска (например, моделями LM Studio), после переноса в сейф укажите ей новую папку и держите сейф открытым, пока она нужна. Такие пункты можно снять."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
if app.safeVolume == nil {
- Text(app.targetProblem ?? "Откройте сейф.").font(.callout).foregroundStyle(Theme.muted)
+ Text(app.targetProblem ?? tr("Откройте сейф.")).font(.callout).foregroundStyle(Theme.muted)
} else {
Button {
safe.encrypt(chosen, app: app)
} label: {
- Label("Перенести в сейф и удалить открытые копии (\(chosen.count))", systemImage: "lock.doc")
+ Label(tr("Перенести в сейф и удалить открытые копии (\(chosen.count))"), systemImage: "lock.doc")
}
.prominentButton()
.disabled(chosen.isEmpty || app.isBusy)
@@ -330,12 +330,12 @@ struct SafeView: View {
RowDivider()
Group {
if state.hostEncrypted {
- Label("Сам диск «\(host.name)» зашифрован целиком.", systemImage: "checkmark.shield.fill").foregroundStyle(Theme.ok)
+ Label(tr("Сам диск «\(host.name)» зашифрован целиком."), systemImage: "checkmark.shield.fill").foregroundStyle(Theme.ok)
} else if ["apfs", "hfs"].contains(host.fsType) {
- Text("Сам диск «\(host.name)» не зашифрован. Его можно зашифровать целиком, не стирая: правый щелчок по диску в Finder → «Зашифровать». Тогда защищено будет и то, что лежит вне сейфа.")
+ Text(tr("Сам диск «\(host.name)» не зашифрован. Его можно зашифровать целиком, не стирая: правый щелчок по диску в Finder → «Зашифровать». Тогда защищено будет и то, что лежит вне сейфа."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} else {
- Text("Сам диск «\(host.name)» (\(host.fsDisplayName)) зашифровать нельзя: у этой файловой системы шифрования нет. Удалённые с SSD и флешек файлы физически могут оставаться в памяти, пока контроллер их не перезапишет. Для защиты всего диска, как в VeraCrypt при шифровании раздела: перенесите данные, отформатируйте диск в «APFS (зашифрованный)» в Дисковой утилите и верните их.")
+ Text(tr("Сам диск «\(host.name)» (\(host.fsDisplayName)) зашифровать нельзя: у этой файловой системы шифрования нет. Удалённые с SSD и флешек файлы физически могут оставаться в памяти, пока контроллер их не перезапишет. Для защиты всего диска, как в VeraCrypt при шифровании раздела: перенесите данные, отформатируйте диск в «APFS (зашифрованный)» в Дисковой утилите и верните их."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
}
@@ -349,24 +349,24 @@ struct SafeView: View {
private var keySection: some View {
let safe = app.safe
let closed = !safe.isOpen
- FormRow(title: "Пароль", detail: "Данные не перешифровываются — меняется только заголовок.") {
- Button("Сменить…") { sheet = .changePassword }.disabled(!closed)
+ FormRow(title: tr("Пароль"), detail: tr("Данные не перешифровываются — меняется только заголовок.")) {
+ Button(tr("Сменить…")) { sheet = .changePassword }.disabled(!closed)
}
RowDivider()
- FormRow(title: "Копия заголовка", detail: "Храните отдельно от диска: без заголовка сейф не откроется.") {
+ FormRow(title: tr("Копия заголовка"), detail: tr("Храните отдельно от диска: без заголовка сейф не откроется.")) {
HStack {
- Button("Сохранить…") { safe.backupHeader(app: app) }.disabled(!closed)
- Button("Восстановить…") { sheet = .restoreHeader }.disabled(!closed)
+ Button(tr("Сохранить…")) { safe.backupHeader(app: app) }.disabled(!closed)
+ Button(tr("Восстановить…")) { sheet = .restoreHeader }.disabled(!closed)
}
}
RowDivider()
- FormRow(title: "Предел роста",
- detail: "Сейчас \(app.safe.state?.sizeLimit.map { Format.bytes($0) } ?? "неизвестно"). Увеличивается без потери содержимого.") {
- Button("Увеличить…") { sheet = .grow }.disabled(!closed)
+ FormRow(title: tr("Предел роста"),
+ detail: tr("Сейчас \(app.safe.state?.sizeLimit.map { Format.bytes($0) } ?? tr("неизвестно")). Увеличивается без потери содержимого.")) {
+ Button(tr("Увеличить…")) { sheet = .grow }.disabled(!closed)
}
RowDivider()
- FormRow(title: "Место на диске", detail: "Образ сам не уменьшается, когда из сейфа удаляют файлы.") {
- Button("Вернуть…") { sheet = .compact }.disabled(!closed)
+ FormRow(title: tr("Место на диске"), detail: tr("Образ сам не уменьшается, когда из сейфа удаляют файлы.")) {
+ Button(tr("Вернуть…")) { sheet = .compact }.disabled(!closed)
}
}
@@ -403,16 +403,16 @@ struct ChangePasswordSheet: View {
var body: some View {
let fields = NewPasswordFields(password: $new, confirmation: $confirmation)
- SheetLayout(systemImage: "key.fill", title: "Сменить пароль сейфа",
- subtitle: "Данные не перешифровываются — меняется только заголовок, поэтому это быстро.") {
- SecureField("Текущий пароль", text: $old)
+ SheetLayout(systemImage: "key.fill", title: tr("Сменить пароль сейфа"),
+ subtitle: tr("Данные не перешифровываются — меняется только заголовок, поэтому это быстро.")) {
+ SecureField(tr("Текущий пароль"), text: $old)
.textFieldStyle(.roundedBorder)
fields
- Text("Копии заголовка, снятые раньше, откроются старым паролем: после смены снимите новую, а старые удалите.")
+ Text(tr("Копии заголовка, снятые раньше, откроются старым паролем: после смены снимите новую, а старые удалите."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
} actions: {
- Button("Отмена") { clear(); dismiss() }
- Button("Сменить") {
+ Button(tr("Отмена")) { clear(); dismiss() }
+ Button(tr("Сменить")) {
app.safe.changePassword(old: old, new: new, app: app)
clear(); dismiss()
}
@@ -430,14 +430,14 @@ struct CompactSheet: View {
@State private var password = ""
var body: some View {
- SheetLayout(systemImage: "arrow.down.right.and.arrow.up.left", title: "Вернуть место на диск") {
- Text("Файлы, удалённые или возвращённые из сейфа, продолжают занимать место на диске: образ сам не уменьшается, хотя внутри это место идёт под новые данные. Сжатие отдаёт диску полностью пустые участки образа. На больших сейфах macOS может не найти таких участков — тогда вернётся мало или ничего, и OffLoadAI так и скажет.")
+ SheetLayout(systemImage: "arrow.down.right.and.arrow.up.left", title: tr("Вернуть место на диск")) {
+ Text(tr("Файлы, удалённые или возвращённые из сейфа, продолжают занимать место на диске: образ сам не уменьшается, хотя внутри это место идёт под новые данные. Сжатие отдаёт диску полностью пустые участки образа. На больших сейфах macOS может не найти таких участков — тогда вернётся мало или ничего, и OffLoadAI так и скажет."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
- SecureField("Пароль сейфа", text: $password)
+ SecureField(tr("Пароль сейфа"), text: $password)
.textFieldStyle(.roundedBorder)
} actions: {
- Button("Отмена") { password = ""; dismiss() }
- Button("Сжать") {
+ Button(tr("Отмена")) { password = ""; dismiss() }
+ Button(tr("Сжать")) {
app.safe.compact(password: password, app: app)
password = ""; dismiss()
}
@@ -462,41 +462,41 @@ struct GrowSafeSheet: View {
let choices = app.destination.map { SafeModel.limitChoices(host: $0, above: current) } ?? []
let target = (safe.state?.allocated ?? 0) + needed
let chosen = limit ?? choices.first { $0 >= target } ?? choices.last
- SheetLayout(systemImage: "arrow.up.left.and.arrow.down.right", title: "Увеличить предел сейфа",
- subtitle: "Сейчас — \(Format.bytes(current))") {
- Text("Содержимое остаётся на месте, а места на диске образ занимает столько же, сколько занимал: предел лишь разрешает ему расти.")
+ SheetLayout(systemImage: "arrow.up.left.and.arrow.down.right", title: tr("Увеличить предел сейфа"),
+ subtitle: tr("Сейчас — \(Format.bytes(current))")) {
+ Text(tr("Содержимое остаётся на месте, а места на диске образ занимает столько же, сколько занимал: предел лишь разрешает ему расти."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
if choices.isEmpty {
- Notice(.info, "Сейф уже может занять весь диск — увеличивать некуда.")
+ Notice(.info, tr("Сейф уже может занять весь диск — увеличивать некуда."))
} else {
- Picker("Новый предел", selection: Binding(get: { chosen }, set: { limit = $0 })) {
+ Picker(tr("Новый предел"), selection: Binding(get: { chosen }, set: { limit = $0 })) {
ForEach(choices, id: \.self) { value in
- Text(value == app.destination?.totalBytes ? "весь диск (\(Format.bytes(value)))" : Format.bytes(value))
+ Text(value == app.destination?.totalBytes ? tr("весь диск (\(Format.bytes(value)))") : Format.bytes(value))
.tag(Int64?.some(value))
}
}
.fixedSize()
if needed > 0, let chosen, chosen < target {
- Text("Выбранное для сейфа (\(Format.bytes(needed))) при таком пределе поместится не целиком.")
+ Text(tr("Выбранное для сейфа (\(Format.bytes(needed))) при таком пределе поместится не целиком."))
.font(.caption).foregroundStyle(Theme.warn)
}
if safe.isOpen {
HStack {
- Text("Сейф открыт — увеличить можно только закрытый.")
+ Text(tr("Сейф открыт — увеличить можно только закрытый."))
.font(.callout).foregroundStyle(Theme.muted)
Spacer()
- Button("Закрыть сейф") { safe.close(app: app) }
+ Button(tr("Закрыть сейф")) { safe.close(app: app) }
}
} else {
- SecureField("Пароль сейфа", text: $password)
+ SecureField(tr("Пароль сейфа"), text: $password)
.textFieldStyle(.roundedBorder)
}
- Text("На время увеличения сейф ненадолго подключится без открытия: файлы не видны ни Finder, ни программам.")
+ Text(tr("На время увеличения сейф ненадолго подключится без открытия: файлы не видны ни Finder, ни программам."))
.font(.caption).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
}
} actions: {
- Button("Отмена") { password = ""; dismiss() }
- Button("Увеличить") {
+ Button(tr("Отмена")) { password = ""; dismiss() }
+ Button(tr("Увеличить")) {
if let chosen { safe.grow(to: chosen, password: password, app: app) }
password = ""; dismiss()
}
@@ -513,23 +513,23 @@ struct RestoreHeaderSheet: View {
@State private var password = ""
var body: some View {
- SheetLayout(systemImage: "arrow.counterclockwise", tone: .caution, title: "Восстановить заголовок") {
- Text("Нужно, если сейф перестал открываться верным паролем (испортился заголовок). Сейф откроется паролем, который действовал, когда снималась копия. Если пароль к копии не подойдёт, прежний заголовок вернётся как был.")
+ SheetLayout(systemImage: "arrow.counterclockwise", tone: .caution, title: tr("Восстановить заголовок")) {
+ Text(tr("Нужно, если сейф перестал открываться верным паролем (испортился заголовок). Сейф откроется паролем, который действовал, когда снималась копия. Если пароль к копии не подойдёт, прежний заголовок вернётся как был."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
HStack {
Image(systemName: "doc").foregroundStyle(Theme.muted)
- Text(file?.lastPathComponent ?? "Копия не выбрана").lineLimit(1).truncationMode(.middle)
+ Text(file?.lastPathComponent ?? tr("Копия не выбрана")).lineLimit(1).truncationMode(.middle)
.foregroundStyle(file == nil ? .secondary : .primary)
Spacer()
- Button("Выбрать…") { choose() }
+ Button(tr("Выбрать…")) { choose() }
}
.padding(10)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
- SecureField("Пароль этой копии", text: $password)
+ SecureField(tr("Пароль этой копии"), text: $password)
.textFieldStyle(.roundedBorder)
} actions: {
- Button("Отмена") { password = ""; dismiss() }
- Button("Восстановить") {
+ Button(tr("Отмена")) { password = ""; dismiss() }
+ Button(tr("Восстановить")) {
if let file { app.safe.restoreHeader(from: file, password: password, app: app) }
password = ""; dismiss()
}
@@ -544,7 +544,7 @@ struct RestoreHeaderSheet: View {
panel.canChooseDirectories = false
panel.allowsMultipleSelection = false
panel.allowedContentTypes = []
- panel.message = "Файл копии заголовка (.offload-header)"
+ panel.message = tr("Файл копии заголовка (.offload-header)")
if panel.runModal() == .OK { file = panel.url }
}
}
diff --git a/Sources/Offload/Views/SpaceView.swift b/Sources/Offload/Views/SpaceView.swift
index f9e9b64..22cfbf3 100644
--- a/Sources/Offload/Views/SpaceView.swift
+++ b/Sources/Offload/Views/SpaceView.swift
@@ -25,7 +25,7 @@ struct SpaceView: View {
onFree: { free(item) })
}
if model.hiddenSmallCount > 0, !model.isScanning {
- Text("И ещё \(model.hiddenSmallCount) объектов меньше 1 МБ")
+ Text(tr("И ещё \(model.hiddenSmallCount) объектов меньше 1 МБ"))
.font(.caption).foregroundStyle(Theme.muted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 10)
@@ -36,10 +36,10 @@ struct SpaceView: View {
.padding(.vertical, 8)
}
.overlay {
- if model.items.isEmpty, model.isScanning { ProgressView("Считаю размеры…") }
+ if model.items.isEmpty, model.isScanning { ProgressView(tr("Считаю размеры…")) }
}
}
- .navigationTitle("Освободить место")
+ .navigationTitle(tr("Освободить место"))
.task {
if model.items.isEmpty, !model.isScanning { model.open(model.location, rules: app.rules) }
}
@@ -80,20 +80,20 @@ struct SpaceView: View {
HStack(spacing: 10) {
Button { model.goUp(rules: app.rules) } label: { Image(systemName: "chevron.left") }
.disabled(model.location == nil)
- .help("Наверх")
+ .help(tr("Наверх"))
VStack(alignment: .leading, spacing: 1) {
Text(model.title(home: app.rules.home))
.font(Theme.display(19))
.lineLimit(1).truncationMode(.middle)
- Text(!model.isScanning ? "\(Format.bytes(total)) · \(model.items.count) \(pluralRu(model.items.count, "объект", "объекта", "объектов"))"
- : model.items.isEmpty ? "Считаю…" : "Считаю: \(measured.count) из \(model.items.count)")
+ Text(!model.isScanning ? "\(Format.bytes(total)) · \(model.items.count) \(pluralRu(model.items.count, tr("объект"), tr("объекта"), tr("объектов")))"
+ : model.items.isEmpty ? tr("Считаю…") : tr("Считаю: \(measured.count) из \(model.items.count)"))
.font(.caption).foregroundStyle(Theme.muted).monospacedDigit()
}
Spacer()
if model.isScanning {
ProgressView().controlSize(.small)
}
- Button { model.rescan(rules: app.rules) } label: { Label("Пересчитать", systemImage: "arrow.clockwise") }
+ Button { model.rescan(rules: app.rules) } label: { Label(tr("Пересчитать"), systemImage: "arrow.clockwise") }
}
if total > 0 {
VStack(alignment: .leading, spacing: 8) {
@@ -103,9 +103,9 @@ struct SpaceView: View {
StackedBar.Part(fraction: Double(blocked) / Double(total), color: Theme.line),
])
HStack(spacing: 18) {
- legend("Можно перенести", bytes: movable, color: Theme.ink)
- legend("С оговорками", bytes: caution, color: Theme.faint)
- legend("Не трогать", bytes: blocked, color: Theme.line)
+ legend(tr("Можно перенести"), bytes: movable, color: Theme.ink)
+ legend(tr("С оговорками"), bytes: caution, color: Theme.faint)
+ legend(tr("Не трогать"), bytes: blocked, color: Theme.line)
Spacer(minLength: 0)
}
}
@@ -150,7 +150,7 @@ struct SpaceRow: View {
HStack(spacing: 6) {
Text(item.url.lastPathComponent).fontWeight(.medium).lineLimit(1).truncationMode(.middle)
if item.accessDenied {
- StatusPill(title: item.bytes > 0 ? "не всё доступно" : "нет доступа", tone: .caution)
+ StatusPill(title: item.bytes > 0 ? tr("не всё доступно") : tr("нет доступа"), tone: .caution)
}
}
CapacityBar(fraction: Double(item.bytes) / Double(largest), height: 5).frame(maxWidth: 320)
@@ -175,23 +175,23 @@ struct SpaceRow: View {
// Лупа видна под мышью: в каждой строке сразу она только шумела бы.
Button { revealInFinder(item.url) } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.borderless)
- .help("Показать в Finder")
+ .help(tr("Показать в Finder"))
.opacity(hovering ? 1 : 0)
if let appData {
- Button("Как освободить…", action: onFree)
+ Button(tr("Как освободить…"), action: onFree)
.buttonStyle(PillButtonStyle())
.controlSize(.small)
.help(appData == .docker
- ? "Открыть раздел «Docker»: очистка образов и кеша сборки, архивация томов"
- : "Сколько занимает каждая машина и как освободить место через UTM")
+ ? tr("Открыть раздел «Docker»: очистка образов и кеша сборки, архивация томов")
+ : tr("Сколько занимает каждая машина и как освободить место через UTM"))
} else if !item.verdict.isBlocked {
- Button("Перенести…", action: onMove)
+ Button(tr("Перенести…"), action: onMove)
.buttonStyle(PillButtonStyle())
.controlSize(.small)
}
Button(action: onOpen) { Image(systemName: "chevron.right") }
.buttonStyle(.borderless)
- .help("Открыть")
+ .help(tr("Открыть"))
.opacity(item.isDirectory ? 1 : 0)
.disabled(!item.isDirectory)
}
@@ -205,14 +205,14 @@ struct SpaceRow: View {
.onHover { hovering = $0 }
.onTapGesture(count: 2) { if item.isDirectory { onOpen() } }
.contextMenu {
- if item.isDirectory { Button("Открыть", action: onOpen) }
- Button("Показать в Finder") { revealInFinder(item.url) }
+ if item.isDirectory { Button(tr("Открыть"), action: onOpen) }
+ Button(tr("Показать в Finder")) { revealInFinder(item.url) }
if appData != nil {
Divider()
- Button("Как освободить…", action: onFree)
+ Button(tr("Как освободить…"), action: onFree)
} else if !item.verdict.isBlocked {
Divider()
- Button("Перенести…", action: onMove)
+ Button(tr("Перенести…"), action: onMove)
}
}
}
@@ -227,7 +227,7 @@ struct MoveSheet: View {
var body: some View {
SheetLayout(systemImage: "externaldrive.badge.plus",
- title: "Перенести «\(source.lastPathComponent)»",
+ title: tr("Перенести «\(source.lastPathComponent)»"),
subtitle: relativeToHome(source.path, home: app.rules.home),
width: 600) {
// Когда класть некуда, эта же строка и объясняет почему.
@@ -237,20 +237,20 @@ struct MoveSheet: View {
} else if app.destination != nil, app.storeMode == .safe, app.safe.state?.isEncrypted == true {
// Сейф закрыт — открываем прямо здесь, не уходя из окна переноса.
SafeUnlockRow()
- Button("Всё-таки положить открыто на диск «\(app.destination?.name ?? "")»") { app.storeMode = .open }
+ Button(tr("Всё-таки положить открыто на диск «\(app.destination?.name ?? "")»")) { app.storeMode = .open }
.buttonStyle(InkLinkStyle()).font(.caption)
}
} actions: {
switch model.stage {
case .inspecting, .running:
- Button("Отменить") { model.cancel() }
+ Button(tr("Отменить")) { model.cancel() }
case .ready(let plan):
- Button("Закрыть") { dismiss() }
- Button("Перенести") { model.run(plan, app: app) }
+ Button(tr("Закрыть")) { dismiss() }
+ Button(tr("Перенести")) { model.run(plan, app: app) }
.keyboardShortcut(.defaultAction)
.disabled(!model.canRun(plan))
default:
- Button("Готово") { dismiss() }.keyboardShortcut(.defaultAction)
+ Button(tr("Готово")) { dismiss() }.keyboardShortcut(.defaultAction)
}
}
// Esc запрещаем только во время самого копирования; на этапе проверки закрывать окно можно.
@@ -272,13 +272,13 @@ struct MoveSheet: View {
case .idle, .inspecting:
HStack(spacing: 10) {
ProgressView().controlSize(.small)
- Text("Проверяю содержимое, открытые файлы и диск «\(volume.name)»…").foregroundStyle(Theme.muted)
+ Text(tr("Проверяю содержимое, открытые файлы и диск «\(volume.name)»…")).foregroundStyle(Theme.muted)
}
case .ready(let plan):
VStack(alignment: .leading, spacing: 12) {
VStack(spacing: 8) {
- InfoRow("Размер", value: "\(Format.bytes(plan.content.logicalBytes)) · файлов \(plan.content.files) · папок \(max(0, plan.content.directories - 1))")
- InfoRow(title: "Куда") {
+ InfoRow(tr("Размер"), value: tr("\(Format.bytes(plan.content.logicalBytes)) · файлов \(plan.content.files) · папок \(max(0, plan.content.directories - 1))"))
+ InfoRow(title: tr("Куда")) {
Text(plan.target.path).lineLimit(1).truncationMode(.head).textSelection(.enabled)
}
}
@@ -286,24 +286,24 @@ struct MoveSheet: View {
.padding(12)
.background(Color.primary.opacity(0.04), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
if !plan.volume.isEncryptedImage {
- Notice(.warning, "Копия ляжет на диск открыто: кто получит диск, прочтёт её без пароля. Чтобы зашифровать, выберите внизу боковой панели «В сейф».")
+ Notice(.warning, tr("Копия ляжет на диск открыто: кто получит диск, прочтёт её без пароля. Чтобы зашифровать, выберите внизу боковой панели «В сейф»."))
}
switch plan.verdict {
case .blocked(let reason):
Notice(.error, reason)
case .caution(let notes):
ForEach(notes, id: \.self) { Notice(.warning, $0) }
- Toggle("Понимаю, переносить всё равно", isOn: bindable.acceptCautions)
+ Toggle(tr("Понимаю, переносить всё равно"), isOn: bindable.acceptCautions)
case .safe:
EmptyView()
}
ForEach(plan.check.blockers, id: \.self) { Notice(.error, $0) }
ForEach(plan.check.notes, id: \.self) { Notice(.info, $0) }
VStack(alignment: .leading, spacing: 4) {
- Toggle("Удалить оригинал после сверки", isOn: bindable.deleteOriginal)
+ Toggle(tr("Удалить оригинал после сверки"), isOn: bindable.deleteOriginal)
Text(model.deleteOriginal
- ? "Оригинал удаляется только после того, как каждый файл копии перечитан с диска и сверен по SHA-256."
- : "Останется копия на диске, место на Mac не освободится.")
+ ? tr("Оригинал удаляется только после того, как каждый файл копии перечитан с диска и сверен по SHA-256.")
+ : tr("Останется копия на диске, место на Mac не освободится."))
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 20)
@@ -312,9 +312,9 @@ struct MoveSheet: View {
case .running(let progress):
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
- Text(progress.phase.rawValue).fontWeight(.medium)
+ Text(trDynamic(progress.phase.rawValue)).fontWeight(.medium)
Spacer()
- Text("\(Format.bytes(progress.bytesDone)) из \(Format.bytes(progress.bytesTotal))")
+ Text(tr("\(Format.bytes(progress.bytesDone)) из \(Format.bytes(progress.bytesTotal))"))
.font(.callout).foregroundStyle(Theme.muted).monospacedDigit()
}
ProgressView(value: progress.fraction)
@@ -323,14 +323,14 @@ struct MoveSheet: View {
case .done(let record):
VStack(alignment: .leading, spacing: 10) {
Notice(.success, record.originalRemoved
- ? "Перенесено и сверено: \(record.files) файлов, \(Format.bytes(record.bytes)). Оригинал удалён, место на Mac освободилось."
- : "Скопировано и сверено: \(record.files) файлов, \(Format.bytes(record.bytes)). Оригинал на месте.")
+ ? tr("Перенесено и сверено: \(record.files) файлов, \(Format.bytes(record.bytes)). Оригинал удалён, место на Mac освободилось.")
+ : tr("Скопировано и сверено: \(record.files) файлов, \(Format.bytes(record.bytes)). Оригинал на месте."))
if record.isEncrypted {
- Label("Лежит в сейфе — зашифровано.", systemImage: "lock.fill").foregroundStyle(Theme.ok).font(.callout)
+ Label(tr("Лежит в сейфе — зашифровано."), systemImage: "lock.fill").foregroundStyle(Theme.ok).font(.callout)
}
HStack {
- Button("Показать на диске") { revealInFinder(URL(fileURLWithPath: record.archivedPath)) }
- Text("Вернуть обратно можно в разделе «Перенесённое».").font(.caption).foregroundStyle(Theme.muted)
+ Button(tr("Показать на диске")) { revealInFinder(URL(fileURLWithPath: record.archivedPath)) }
+ Text(tr("Вернуть обратно можно в разделе «Перенесённое».")).font(.caption).foregroundStyle(Theme.muted)
}
}
case .failed(let message):
diff --git a/Sources/Offload/Views/UTMSheet.swift b/Sources/Offload/Views/UTMSheet.swift
index 63c4ed1..c286e0f 100644
--- a/Sources/Offload/Views/UTMSheet.swift
+++ b/Sources/Offload/Views/UTMSheet.swift
@@ -15,22 +15,22 @@ struct UTMSheet: View {
@State private var utmApp: URL? = nil
var body: some View {
- SheetLayout(systemImage: "desktopcomputer", title: "Виртуальные машины UTM", subtitle: subtitle, width: 640) {
+ SheetLayout(systemImage: "desktopcomputer", title: tr("Виртуальные машины UTM"), subtitle: subtitle, width: 640) {
list
steps
} actions: {
- Button("Открыть UTM") { if let utmApp { NSWorkspace.shared.open(utmApp) } }
+ Button(tr("Открыть UTM")) { if let utmApp { NSWorkspace.shared.open(utmApp) } }
.disabled(utmApp == nil)
- .help(utmApp == nil ? "UTM не найден" : "Удалять и переносить машины нужно в самом UTM")
- Button("Готово") { dismiss() }.keyboardShortcut(.defaultAction)
+ .help(utmApp == nil ? tr("UTM не найден") : tr("Удалять и переносить машины нужно в самом UTM"))
+ Button(tr("Готово")) { dismiss() }.keyboardShortcut(.defaultAction)
}
.task { await load() }
}
private var subtitle: String {
- guard let machines else { return "Считаю, сколько занимает каждая машина…" }
+ guard let machines else { return tr("Считаю, сколько занимает каждая машина…") }
let total = machines.reduce(Int64(0)) { $0 + $1.bytes }
- return "\(machines.count) \(pluralRu(machines.count, "машина", "машины", "машин")) · \(Format.bytes(total))"
+ return "\(machines.count) \(pluralRu(machines.count, tr("машина"), tr("машины"), tr("машин"))) · \(Format.bytes(total))"
}
private func load() async {
@@ -67,7 +67,7 @@ struct UTMSheet: View {
private var list: some View {
if let machines {
if machines.isEmpty {
- Notice(.info, "В папке UTM машин нет. Машины, открытые из других папок, UTM показывает у себя в списке.")
+ Notice(.info, tr("В папке UTM машин нет. Машины, открытые из других папок, UTM показывает у себя в списке."))
} else if machines.count > 5 {
Card(padding: 0, spacing: 0) {
ScrollView { rows(machines) }.frame(height: 300)
@@ -78,7 +78,7 @@ struct UTMSheet: View {
} else {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
- Text("Считаю, сколько занимает каждая машина…").foregroundStyle(Theme.muted)
+ Text(tr("Считаю, сколько занимает каждая машина…")).foregroundStyle(Theme.muted)
}
}
}
@@ -94,8 +94,8 @@ struct UTMSheet: View {
HStack(spacing: 12) {
IconTile(systemImage: "tray.full.fill", tone: .neutral, size: 30)
VStack(alignment: .leading, spacing: 2) {
- Text("Прочее в папке UTM")
- Text("Кеш и данные самого UTM, не машины").font(.caption).foregroundStyle(Theme.muted)
+ Text(tr("Прочее в папке UTM"))
+ Text(tr("Кеш и данные самого UTM, не машины")).font(.caption).foregroundStyle(Theme.muted)
}
Spacer(minLength: 12)
Text(Format.bytes(rest)).fontWeight(.semibold).monospacedDigit()
@@ -120,17 +120,17 @@ struct UTMSheet: View {
Button { revealInFinder(machine.url) } label: { Image(systemName: "magnifyingglass") }
.buttonStyle(.borderless)
.frame(width: 16)
- .help("Показать в Finder")
+ .help(tr("Показать в Finder"))
}
.rowPadding()
}
private func caption(_ machine: UTMMachine) -> String? {
var parts: [String] = []
- if let modified = machine.modified { parts.append("менялась \(Format.relative(modified))") }
+ if let modified = machine.modified { parts.append(tr("менялась \(Format.relative(modified))")) }
// Диски машин разрежённые: где разрежённых файлов нет, машина займёт полный объём.
if machine.logicalBytes > machine.bytes + (1 << 30) {
- parts.append("полный объём дисков \(Format.bytes(machine.logicalBytes))")
+ parts.append(tr("полный объём дисков \(Format.bytes(machine.logicalBytes))"))
}
return parts.isEmpty ? nil : parts.joined(separator: " · ")
}
@@ -139,13 +139,13 @@ struct UTMSheet: View {
private var steps: some View {
VStack(alignment: .leading, spacing: 12) {
- step(1, "Удалить ненужную",
- "В UTM: правый клик по машине → «Удалить…» (Delete…). UTM удалит её вместе с дисками — место освободится сразу.")
- step(2, "Перенести на внешний диск",
- "В UTM: правый клик → «Переместить…» (Move…) и выберите папку в сейфе или на диске. UTM скопирует машину, удалит оригинал и запомнит новое место. Перед запуском такой машины подключите диск и откройте сейф — иначе UTM покажет её недоступной, — а перед сном Mac и закрытием сейфа выключите её: диск машины, у которой отключили сейф, может испортиться.")
+ step(1, tr("Удалить ненужную"),
+ tr("В UTM: правый клик по машине → «Удалить…» (Delete…). UTM удалит её вместе с дисками — место освободится сразу."))
+ step(2, tr("Перенести на внешний диск"),
+ tr("В UTM: правый клик → «Переместить…» (Move…) и выберите папку в сейфе или на диске. UTM скопирует машину, удалит оригинал и запомнит новое место. Перед запуском такой машины подключите диск и откройте сейф — иначе UTM покажет её недоступной, — а перед сном Mac и закрытием сейфа выключите её: диск машины, у которой отключили сейф, может испортиться."))
if let note = destinationNote { Notice(.warning, note) }
- step(3, "Ужать диск машины QEMU",
- "Если внутри гостевой системы удалили много файлов, сам образ диска не уменьшается. В настройках машины, в разделе её диска, есть кнопка «Reclaim Space» — она пересобирает образ без пустых блоков.")
+ step(3, tr("Ужать диск машины QEMU"),
+ tr("Если внутри гостевой системы удалили много файлов, сам образ диска не уменьшается. В настройках машины, в разделе её диска, есть кнопка «Reclaim Space» — она пересобирает образ без пустых блоков."))
}
}
@@ -167,9 +167,9 @@ struct UTMSheet: View {
private var destinationNote: String? {
guard let disk = app.destination else { return nil }
if let limit = disk.maxFileSize, (machines ?? []).contains(where: { $0.largestFile > limit }) {
- return "Диск «\(disk.name)» — \(disk.fsDisplayName): файлы больше 4 ГБ он не принимает, машину туда не перенести. Переносите в сейф."
+ return tr("Диск «\(disk.name)» — \(disk.fsDisplayName): файлы больше 4 ГБ он не принимает, машину туда не перенести. Переносите в сейф.")
}
guard !disk.keepsSparseFiles, (machines ?? []).contains(where: { $0.logicalBytes > $0.bytes + (1 << 30) }) else { return nil }
- return "Диск «\(disk.name)» — \(disk.fsDisplayName): разрежённых файлов там нет, и машина займёт на нём полный объём дисков, а не нынешний размер. Сейф внутри — APFS, он разрежённые файлы хранит."
+ return tr("Диск «\(disk.name)» — \(disk.fsDisplayName): разрежённых файлов там нет, и машина займёт на нём полный объём дисков, а не нынешний размер. Сейф внутри — APFS, он разрежённые файлы хранит.")
}
}
diff --git a/Sources/Offload/Views/UpdateViews.swift b/Sources/Offload/Views/UpdateViews.swift
index d54cfd4..4837d4c 100644
--- a/Sources/Offload/Views/UpdateViews.swift
+++ b/Sources/Offload/Views/UpdateViews.swift
@@ -11,13 +11,13 @@ struct UpdateQuestionCard: View {
HStack(alignment: .top, spacing: 14) {
IconTile(systemImage: "arrow.down.circle", tone: .brand, size: 36)
VStack(alignment: .leading, spacing: 8) {
- Text("Сообщать о новых версиях?").font(.headline)
- Text("Раз в сутки OffLoadAI спросит у GitHub номер последней версии и скажет, если вышла новая: в новых версиях бывают исправления безопасности. В запросе нет ничего о компьютере и файлах — GitHub видит только адрес сети, как при открытии любой страницы. Обновление ставите вы сами. Передумать можно в меню OffLoadAI.")
+ Text(tr("Сообщать о новых версиях?")).font(.headline)
+ Text(tr("Раз в сутки OffLoadAI спросит у GitHub номер последней версии и скажет, если вышла новая: в новых версиях бывают исправления безопасности. В запросе нет ничего о компьютере и файлах — GitHub видит только адрес сети, как при открытии любой страницы. Обновление ставите вы сами. Передумать можно в меню OffLoadAI."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
HStack {
- Button("Сообщать") { app.updates.setEnabled(true) }
+ Button(tr("Сообщать")) { app.updates.setEnabled(true) }
.prominentButton()
- Button("Не надо") { app.updates.setEnabled(false) }
+ Button(tr("Не надо")) { app.updates.setEnabled(false) }
}
.padding(.top, 2)
}
@@ -38,7 +38,7 @@ struct UpdateSidebarRow: View {
Image(systemName: "arrow.down.circle.fill")
.foregroundStyle(Theme.ink)
.frame(width: 20)
- Text("Вышла версия \(release.version)").font(.callout).fontWeight(.medium).foregroundStyle(Theme.ink).lineLimit(1)
+ Text(tr("Вышла версия \(release.version)")).font(.callout).fontWeight(.medium).foregroundStyle(Theme.ink).lineLimit(1)
Spacer(minLength: 4)
}
.padding(.horizontal, 10)
@@ -48,7 +48,7 @@ struct UpdateSidebarRow: View {
}
.buttonStyle(.plain)
.onHover { hovering = $0 }
- .help("Что нового и как обновиться")
+ .help(tr("Что нового и как обновиться"))
}
}
@@ -60,9 +60,9 @@ struct UpdateSheet: View {
var body: some View {
let updates = app.updates
- SheetLayout(systemImage: "arrow.down.circle", title: "Вышла версия \(updates.available?.version ?? "")",
- subtitle: UpdatesModel.currentVersion.map { "У вас — \($0)" }, width: 540) {
- Text("Чтобы обновиться, вставьте эту команду в Терминал. Она скачает новую версию, сверит её и поставит на место этой. Если идёт копирование, OffLoadAI сначала спросит, прервать ли его. Настройки, ключ Pro, журнал и сейф останутся как были.")
+ SheetLayout(systemImage: "arrow.down.circle", title: tr("Вышла версия \(updates.available?.version ?? "")"),
+ subtitle: UpdatesModel.currentVersion.map { tr("У вас — \($0)") }, width: 540) {
+ Text(tr("Чтобы обновиться, вставьте эту команду в Терминал. Она скачает новую версию, сверит её и поставит на место этой. Если идёт копирование, OffLoadAI сначала спросит, прервать ли его. Настройки, ключ Pro, журнал и сейф останутся как были."))
.font(.callout).foregroundStyle(Theme.muted).fixedSize(horizontal: false, vertical: true)
HStack(alignment: .top, spacing: 10) {
Text(UpdatesModel.installCommand)
@@ -70,7 +70,7 @@ struct UpdateSheet: View {
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
- Button(copied ? "Скопировано" : "Скопировать") {
+ Button(copied ? tr("Скопировано") : tr("Скопировать")) {
updates.copyCommand()
copied = true
}
@@ -79,13 +79,13 @@ struct UpdateSheet: View {
.background(Theme.soft, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
} actions: {
if let page = updates.available?.page {
- Button("Что нового") { NSWorkspace.shared.open(page) }
+ Button(tr("Что нового")) { NSWorkspace.shared.open(page) }
}
- Button("Не напоминать об этой") {
+ Button(tr("Не напоминать об этой")) {
updates.postpone()
dismiss()
}
- Button("Готово") { dismiss() }
+ Button(tr("Готово")) { dismiss() }
.prominentButton()
.keyboardShortcut(.defaultAction)
}
diff --git a/Sources/OffloadCore/Assistant.swift b/Sources/OffloadCore/Assistant.swift
index 13d1ffe..8ec2dca 100644
--- a/Sources/OffloadCore/Assistant.swift
+++ b/Sources/OffloadCore/Assistant.swift
@@ -241,7 +241,8 @@ public struct AssistantTrash: Sendable {
/// Что помощнику говорят и чего от него ждут. Одинаково для любой модели.
public enum AssistantPrompt {
- public static let instructions = """
+ /// Язык пояснений — тот же, что у интерфейса: английскому пользователю модель отвечает по-английски.
+ public static var instructions: String { """
Ты — помощник программы OffLoadAI, которая освобождает место на диске без риска потерять данные.
Твоя единственная задача — помочь человеку разобраться с его файлами и папками: что важно, что менее важно, а что мусор.
Ты видишь только сведения, которые передаёт программа: путь от домашней папки (~), размер, дату изменения,
@@ -254,7 +255,7 @@ public enum AssistantPrompt {
"junk" — мусор, который создаётся заново или больше не нужен (кеши, временные файлы, логи, скачанные установщики, дубликаты).
- action: "keep" — оставить на месте; "safe" — убрать в зашифрованный сейф на внешнем диске (вернуть можно в любой момент);
"trash" — в Корзину (вернуть можно, пока Корзина не очищена).
- - reason: одна короткая фраза по-русски, почему — так, чтобы понял человек без технических знаний.
+ - reason: одна короткая фраза на языке «\(AppLanguage.modelLanguage)», почему — так, чтобы понял человек без технических знаний.
Правила:
- Сомневаешься — выбирай более бережное: keep лучше safe, safe лучше trash. Важное никогда не отправляй в Корзину.
@@ -262,10 +263,10 @@ public enum AssistantPrompt {
заново, и старые установщики. Остальное, что не нужно на диске, — "safe".
- Объекты с пометкой "blocked" программа трогать запрещает: для них только "keep", объясни, что это.
- Не выдумывай: если по сведениям непонятно, что это, так и скажи в reason и выбери "keep".
- - summary: 1–3 предложения по-русски — что главное в этом списке и сколько места можно освободить.
+ - summary: 1–3 предложения на языке «\(AppLanguage.modelLanguage)» — что главное в этом списке и сколько места можно освободить.
- Если человек задал вопрос, ответь на него в summary, коротко и по делу, только о его файлах.
Отвечай строго по схеме JSON, только объектами из списка (по их id).
- """
+ """ }
public static let schema = """
{"type":"object","additionalProperties":false,"required":["summary","items"],"properties":{
@@ -323,7 +324,7 @@ public enum AssistantPrompt {
/// поправляются: запрещённое не трогается, Корзина — только для того, что правила разрешают удалить.
public static func parse(_ answer: Any?, facts: [FileFact], provider: String, cost: Double?) throws -> AssistantAnswer {
guard let root = answer as? [String: Any], let items = root["items"] as? [Any] else {
- throw AssistantError(.badAnswer, "Помощник ответил не по форме — попробуйте ещё раз.")
+ throw AssistantError(.badAnswer, tr("Помощник ответил не по форме — попробуйте ещё раз."))
}
let byID = Dictionary(facts.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
var advice: [Advice] = []
@@ -356,18 +357,18 @@ public enum AssistantPrompt {
var advice = advice
if fact.verdict.isBlocked, advice.action != .keep {
advice.action = .keep
- advice.overruled = "Правила OffLoadAI запрещают это трогать: " + fact.verdict.notes.joined(separator: " ")
+ advice.overruled = tr("Правила OffLoadAI запрещают это трогать: ") + fact.verdict.notes.joined(separator: " ")
} else if advice.action == .trash, case .caution = fact.verdict {
advice.action = .safe
- advice.overruled = "С оговорками — поэтому не в Корзину, а в сейф: оттуда вернуть проще."
+ advice.overruled = tr("С оговорками — поэтому не в Корзину, а в сейф: оттуда вернуть проще.")
} else if advice.action == .trash, advice.importance == .important {
advice.action = .safe
- advice.overruled = "Важное в Корзину не отправляю — только в сейф."
+ advice.overruled = tr("Важное в Корзину не отправляю — только в сейф.")
} else if advice.action == .trash, !fact.canTrash {
// Помощник мог ошибиться или поддаться имени файла: удалить OffLoadAI разрешает только то, что
// создаётся заново, и старые установщики — как в «Разобрать». Личное — в сейф, со сверкой.
advice.action = .safe
- advice.overruled = "Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это — в сейф: оригинал исчезнет, только когда копия сверена."
+ advice.overruled = tr("Удалять OffLoadAI разрешает только то, что создаётся заново, и старые установщики. Это — в сейф: оригинал исчезнет, только когда копия сверена.")
}
return advice
}
diff --git a/Sources/OffloadCore/AssistantApi.swift b/Sources/OffloadCore/AssistantApi.swift
index e0dc2cb..7a3af2d 100644
--- a/Sources/OffloadCore/AssistantApi.swift
+++ b/Sources/OffloadCore/AssistantApi.swift
@@ -9,7 +9,7 @@ public struct ApiKeyAssistant: AssistantProvider {
let key: @Sendable () -> String?
let hasKey: @Sendable () -> Bool
public let model: String
- public var title: String { "Claude по ключу API" }
+ public var title: String { tr("Claude по ключу API") }
/// `hasKey` — есть ли ключ, не читая его: чтение из связки ключей может спросить разрешение, а проверка — нет.
public init(model: String = "claude-opus-5", key: @escaping @Sendable () -> String?, hasKey: (@Sendable () -> Bool)? = nil) {
@@ -20,7 +20,7 @@ public struct ApiKeyAssistant: AssistantProvider {
public func problem() async -> String? {
hasKey() ? nil
- : "Вставьте ключ Anthropic API (console.anthropic.com → API Keys) — он хранится в связке ключей и только на этом Mac."
+ : tr("Вставьте ключ Anthropic API (console.anthropic.com → API Keys) — он хранится в связке ключей и только на этом Mac.")
}
/// Тело запроса: инструкции — системой, схема — структурированным выводом.
@@ -52,9 +52,9 @@ public struct ApiKeyAssistant: AssistantProvider {
} catch let error as URLError where error.code == .cancelled {
throw CancellationError()
} catch let error as URLError where error.code == .timedOut {
- throw AssistantError(.timedOut, "Claude не ответил за десять минут — попробуйте позже или папку поменьше.")
+ throw AssistantError(.timedOut, tr("Claude не ответил за десять минут — попробуйте позже или папку поменьше."))
} catch is URLError {
- throw AssistantError(.failed, "Нет связи с Anthropic — проверьте интернет.")
+ throw AssistantError(.failed, tr("Нет связи с Anthropic — проверьте интернет."))
}
return try read(data: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0, facts: facts)
}
@@ -65,13 +65,13 @@ public struct ApiKeyAssistant: AssistantProvider {
let said = (root?["error"] as? [String: Any])?["message"] as? String
switch status {
case 200: break
- case 401, 403: throw AssistantError(.notSignedIn, "Ключ API не подошёл — проверьте, что он скопирован целиком и не отозван.")
- case 429: throw AssistantError(.failed, "Слишком много запросов к Claude — подождите минуту и повторите.")
- case 500...: throw AssistantError(.failed, "Anthropic сейчас не отвечает (ошибка \(status)) — попробуйте через минуту.")
- default: throw AssistantError(.failed, "Claude: " + (said ?? "ошибка \(status)."))
+ case 401, 403: throw AssistantError(.notSignedIn, tr("Ключ API не подошёл — проверьте, что он скопирован целиком и не отозван."))
+ case 429: throw AssistantError(.failed, tr("Слишком много запросов к Claude — подождите минуту и повторите."))
+ case 500...: throw AssistantError(.failed, tr("Anthropic сейчас не отвечает (ошибка \(status)) — попробуйте через минуту."))
+ default: throw AssistantError(.failed, "Claude: " + (said ?? tr("ошибка \(status).")))
}
if root?["stop_reason"] as? String == "refusal" {
- throw AssistantError(.badAnswer, "Claude отказался разбирать этот список. Попробуйте другую папку.")
+ throw AssistantError(.badAnswer, tr("Claude отказался разбирать этот список. Попробуйте другую папку."))
}
let blocks = root?["content"] as? [[String: Any]] ?? []
let text = blocks.filter { $0["type"] as? String == "text" }.compactMap { $0["text"] as? String }.joined()
diff --git a/Sources/OffloadCore/AssistantBot.swift b/Sources/OffloadCore/AssistantBot.swift
index fb2254a..b26b38d 100644
--- a/Sources/OffloadCore/AssistantBot.swift
+++ b/Sources/OffloadCore/AssistantBot.swift
@@ -13,7 +13,7 @@ public struct BotAssistant: AssistantProvider {
let license: @Sendable () -> String?
let endpointText: @Sendable () -> String?
let client: String
- public var title: String { "Сервер OffLoadAI" }
+ public var title: String { tr("Сервер OffLoadAI") }
public init(client: String, license: @escaping @Sendable () -> String?, endpoint: @escaping @Sendable () -> String?) {
self.client = client
@@ -23,10 +23,10 @@ public struct BotAssistant: AssistantProvider {
public func problem() async -> String? {
if endpoint == nil {
- return "Помощник через сервер OffLoadAI ещё не запущен — он появится в одном из обновлений. Пока выберите другой вариант."
+ return tr("Помощник через сервер OffLoadAI ещё не запущен — он появится в одном из обновлений. Пока выберите другой вариант.")
}
if license()?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false {
- return "Помощник через сервер OffLoadAI входит в OffLoadAI Pro: введите ключ в окне «OffLoadAI Pro»."
+ return tr("Помощник через сервер OffLoadAI входит в OffLoadAI Pro: введите ключ в окне «OffLoadAI Pro».")
}
return nil
}
@@ -56,9 +56,9 @@ public struct BotAssistant: AssistantProvider {
} catch let error as URLError where error.code == .cancelled {
throw CancellationError()
} catch let error as URLError where error.code == .timedOut {
- throw AssistantError(.timedOut, "Сервер OffLoadAI не ответил за четыре минуты — попробуйте позже.")
+ throw AssistantError(.timedOut, tr("Сервер OffLoadAI не ответил за четыре минуты — попробуйте позже."))
} catch is URLError {
- throw AssistantError(.failed, "Нет связи с сервером OffLoadAI — проверьте интернет.")
+ throw AssistantError(.failed, tr("Нет связи с сервером OffLoadAI — проверьте интернет."))
}
return try read(data: data, status: (response as? HTTPURLResponse)?.statusCode ?? 0, facts: facts)
}
@@ -68,10 +68,10 @@ public struct BotAssistant: AssistantProvider {
let said = body?["error"] as? String
switch status {
case 200: break
- case 401: throw AssistantError(.notSignedIn, said ?? "Сервер не принял ключ OffLoadAI Pro.")
- case 402: throw AssistantError(.notSignedIn, said ?? "Нужен OffLoadAI Pro, или вопросы на этот месяц закончились.")
- case 429: throw AssistantError(.failed, said ?? "Слишком много вопросов подряд — подождите минуту.")
- default: throw AssistantError(.failed, said ?? "Сервер OffLoadAI ответил ошибкой \(status).")
+ case 401: throw AssistantError(.notSignedIn, said ?? tr("Сервер не принял ключ OffLoadAI Pro."))
+ case 402: throw AssistantError(.notSignedIn, said ?? tr("Нужен OffLoadAI Pro, или вопросы на этот месяц закончились."))
+ case 429: throw AssistantError(.failed, said ?? tr("Слишком много вопросов подряд — подождите минуту."))
+ default: throw AssistantError(.failed, said ?? tr("Сервер OffLoadAI ответил ошибкой \(status)."))
}
return try AssistantPrompt.parse(body?["answer"], facts: facts, provider: title, cost: nil)
}
diff --git a/Sources/OffloadCore/AssistantClaudeCode.swift b/Sources/OffloadCore/AssistantClaudeCode.swift
index d6464ea..1d18aa9 100644
--- a/Sources/OffloadCore/AssistantClaudeCode.swift
+++ b/Sources/OffloadCore/AssistantClaudeCode.swift
@@ -10,7 +10,7 @@ import Foundation
/// Всё, что он знает, — сведения, которые OffLoadAI передаёт во входном потоке.
public struct ClaudeCodeAssistant: AssistantProvider {
public let model: String
- public var title: String { "Claude Code на этом Mac" }
+ public var title: String { tr("Claude Code на этом Mac") }
public static let timeout: TimeInterval = 4 * 60
public init(model: String = "sonnet") {
@@ -19,7 +19,7 @@ public struct ClaudeCodeAssistant: AssistantProvider {
public func problem() async -> String? {
Runner.locate("claude") == nil
- ? "Claude Code не найден. Установите его (claude.com/claude-code) и войдите своей учётной записью Claude — затем «Проверить снова»."
+ ? tr("Claude Code не найден. Установите его (claude.com/claude-code) и войдите своей учётной записью Claude — затем «Проверить снова».")
: nil
}
@@ -61,7 +61,7 @@ public struct ClaudeCodeAssistant: AssistantProvider {
}
try Task.checkCancellation()
guard let result else {
- throw AssistantError(.timedOut, "Помощник не ответил за четыре минуты. Попробуйте папку поменьше.")
+ throw AssistantError(.timedOut, tr("Помощник не ответил за четыре минуты. Попробуйте папку поменьше."))
}
return try read(output: result.output, errors: result.stderr, facts: facts)
}
@@ -118,17 +118,17 @@ public struct ClaudeCodeAssistant: AssistantProvider {
/// Ответ `claude -p --output-format json`: структурированный вывод по схеме, цена, ошибка входа.
public func read(output: String, errors: String, facts: [FileFact]) throws -> AssistantAnswer {
guard let root = (try? JSONSerialization.jsonObject(with: Data(output.utf8))) as? [String: Any] else {
- throw AssistantError(.failed, "Claude Code не ответил: " + Self.firstLine(errors.isEmpty ? output : errors))
+ throw AssistantError(.failed, tr("Claude Code не ответил: ") + Self.firstLine(errors.isEmpty ? output : errors))
}
let text = root["result"] as? String ?? ""
if root["is_error"] as? Bool == true {
if text.range(of: "Unable to connect", options: .caseInsensitive) != nil
|| text.range(of: "ConnectionRefused", options: .caseInsensitive) != nil {
- throw AssistantError(.failed, "Claude Code не может связаться с Anthropic — проверьте интернет (или прокси, если он нужен).")
+ throw AssistantError(.failed, tr("Claude Code не может связаться с Anthropic — проверьте интернет (или прокси, если он нужен)."))
}
let signIn = ["login", "API key", "authenticat"].contains { text.range(of: $0, options: .caseInsensitive) != nil }
throw signIn
- ? AssistantError(.notSignedIn, "Claude Code не вошёл в учётную запись. Откройте Терминал, запустите claude и войдите (/login) — затем «Проверить снова».")
+ ? AssistantError(.notSignedIn, tr("Claude Code не вошёл в учётную запись. Откройте Терминал, запустите claude и войдите (/login) — затем «Проверить снова»."))
: AssistantError(.failed, "Claude Code: " + Self.firstLine(text))
}
let cost = (root["total_cost_usd"] as? NSNumber)?.doubleValue
@@ -140,7 +140,7 @@ public struct ClaudeCodeAssistant: AssistantProvider {
static func firstLine(_ text: String) -> String {
guard let line = text.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: "\n").first?
- .trimmingCharacters(in: .whitespaces), !line.isEmpty else { return "без объяснений" }
+ .trimmingCharacters(in: .whitespaces), !line.isEmpty else { return tr("без объяснений") }
return line.count > 200 ? line.prefix(200) + "…" : line
}
}
diff --git a/Sources/OffloadCore/AssistantOllama.swift b/Sources/OffloadCore/AssistantOllama.swift
index ff0bf1b..3859144 100644
--- a/Sources/OffloadCore/AssistantOllama.swift
+++ b/Sources/OffloadCore/AssistantOllama.swift
@@ -8,7 +8,7 @@ public struct OllamaAssistant: AssistantProvider {
static let preferred = ["qwen3", "qwen2.5", "gemma3", "llama3.1", "llama3.2", "mistral"]
let preferredModel: @Sendable () -> String?
- public var title: String { "Локальная модель (Ollama)" }
+ public var title: String { tr("Локальная модель (Ollama)") }
public init(preferredModel: @escaping @Sendable () -> String?) {
self.preferredModel = preferredModel
@@ -16,10 +16,10 @@ public struct OllamaAssistant: AssistantProvider {
public func problem() async -> String? {
guard let models = await Self.installed() else {
- return "Ollama не запущена. Установите её (ollama.com), запустите и скачайте модель: ollama pull qwen2.5:7b — затем «Проверить снова»."
+ return tr("Ollama не запущена. Установите её (ollama.com), запустите и скачайте модель: ollama pull qwen2.5:7b — затем «Проверить снова».")
}
if models.isEmpty {
- return "В Ollama нет ни одной модели. Скачайте, например: ollama pull qwen2.5:7b — затем «Проверить снова»."
+ return tr("В Ollama нет ни одной модели. Скачайте, например: ollama pull qwen2.5:7b — затем «Проверить снова».")
}
return nil
}
@@ -71,14 +71,14 @@ public struct OllamaAssistant: AssistantProvider {
} catch let error as URLError where error.code == .cancelled {
throw CancellationError()
} catch let error as URLError where error.code == .timedOut {
- throw AssistantError(.timedOut, "Локальная модель не ответила за десять минут — попробуйте модель поменьше или папку поменьше.")
+ throw AssistantError(.timedOut, tr("Локальная модель не ответила за десять минут — попробуйте модель поменьше или папку поменьше."))
} catch let error as URLError {
throw AssistantError(.failed, "Ollama: " + error.localizedDescription)
}
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
guard status == 200 else {
let said = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
- throw AssistantError(.failed, "Ollama ответила ошибкой \(status): \(said)")
+ throw AssistantError(.failed, tr("Ollama ответила ошибкой \(status): \(said)"))
}
let reply = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
let content = (reply?["message"] as? [String: Any])?["content"] as? String ?? ""
diff --git a/Sources/OffloadCore/BackupEngine.swift b/Sources/OffloadCore/BackupEngine.swift
index 9bff5b5..0b2e070 100644
--- a/Sources/OffloadCore/BackupEngine.swift
+++ b/Sources/OffloadCore/BackupEngine.swift
@@ -18,8 +18,8 @@ public enum BackupError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
- case .destinationInsideSource(let path): return "Папка бэкапа не может лежать внутри копируемой папки «\(path)»."
- case .destinationThroughLink(let path): return "Путь к папке бэкапа проходит через символическую ссылку «\(path)» — писать по нему не буду: бэкап оказался бы не там, где вы думаете."
+ case .destinationInsideSource(let path): return tr("Папка бэкапа не может лежать внутри копируемой папки «\(path)».")
+ case .destinationThroughLink(let path): return tr("Путь к папке бэкапа проходит через символическую ссылку «\(path)» — писать по нему не буду: бэкап оказался бы не там, где вы думаете.")
}
}
}
@@ -126,7 +126,7 @@ public enum BackupEngine {
for source in sources {
guard fm.fileExists(atPath: source.path) else {
- report.problems.append("Нет папки: \(source.path)")
+ report.problems.append(tr("Нет папки: \(source.path)"))
continue
}
let rootName = source.lastPathComponent
@@ -145,7 +145,7 @@ public enum BackupEngine {
}
return false
}, isCancelled: isCancelled)
- report.problems += walk.problems.map { "\(rootName)/\($0): нет доступа" }
+ report.problems += walk.problems.map { tr("\(rootName)/\($0): нет доступа") }
// Папки назначения, которые оказались не папками (например, ссылкой на чужом диске).
// O_NOFOLLOW защищает только последний компонент пути, поэтому внутрь такой папки
diff --git a/Sources/OffloadCore/Cleanup.swift b/Sources/OffloadCore/Cleanup.swift
index a435304..eb0a76e 100644
--- a/Sources/OffloadCore/Cleanup.swift
+++ b/Sources/OffloadCore/Cleanup.swift
@@ -254,7 +254,7 @@ public struct CleanupPlanner: Sendable {
// Кеш открытой программы не отмечается, даже если в прошлый раз его удаляли.
if regenerableReason != nil, let reason = busy[path] { return make(.keep, reason) }
if let remembered = memory[path], allowed.contains(remembered) {
- return make(remembered, "В прошлый раз вы выбрали это же.", learned: true)
+ return make(remembered, tr("В прошлый раз вы выбрали это же."), learned: true)
}
// Привычка решает, когда расходится с правилом. А когда совпадает с тем, что правила только
// предлагают, не отмечая (сейф), — отмечает сразу: вы так обычно и делаете. В остальном
@@ -274,21 +274,21 @@ public struct CleanupPlanner: Sendable {
if let regenerableReason { return (.trash, regenerableReason) }
if case .blocked(let reason) = item.verdict { return (.keep, reason) }
if item.isProject, allowed.contains(.backup) {
- return (.backup, "Похоже на проект (внутри git): его лучше держать в бэкапе, а не переносить.")
+ return (.backup, tr("Похоже на проект (внутри git): его лучше держать в бэкапе, а не переносить."))
}
if item.bytes >= bigBytes, let days, days >= staleDays, item.verdict == .safe {
- return (.safe, "Большое и давно не менялось — в сейфе не мешает, а вернуть можно в любой момент.")
+ return (.safe, tr("Большое и давно не менялось — в сейфе не мешает, а вернуть можно в любой момент."))
}
if allowed.contains(.trash) {
- return (.keep, "Старый установщик: если программа уже стоит, его можно скачать снова.")
+ return (.keep, tr("Старый установщик: если программа уже стоит, его можно скачать снова."))
}
if item.isEncryptedImage {
- return (.keep, "Зашифрованный образ диска — похоже, в нём ваши данные. Удалить его из разбора нельзя.")
+ return (.keep, tr("Зашифрованный образ диска — похоже, в нём ваши данные. Удалить его из разбора нельзя."))
}
- if let days, days < 30 { return (.keep, "Менялось недавно — похоже, вы этим пользуетесь.") }
- if item.bytes < bigBytes { return (.keep, "Места занимает немного.") }
- if case .caution = item.verdict { return (.keep, "Есть оговорки — решите сами.") }
- return (.keep, "Менялось не так давно — решите сами.")
+ if let days, days < 30 { return (.keep, tr("Менялось недавно — похоже, вы этим пользуетесь.")) }
+ if item.bytes < bigBytes { return (.keep, tr("Места занимает немного.")) }
+ if case .caution = item.verdict { return (.keep, tr("Есть оговорки — решите сами.")) }
+ return (.keep, tr("Менялось не так давно — решите сами."))
}
/// Предложения по плиткам (мусор, сейф, копии, установщики, проекты), внутри — по размеру.
@@ -366,33 +366,33 @@ public struct CleanupPlanner: Sendable {
/// Известные места, которые программы пересоздают сами. Только такие OffLoadAI отмечает сразу:
/// это его «база безопасности», как у CleanMyMac, — не догадки по именам папок.
public static let regenerableLocations: [RegenerableLocation] = [
- RegenerableLocation("Library/Developer/Xcode/DerivedData", "Промежуточные файлы сборки Xcode — пересоздаются при следующей сборке.",
+ RegenerableLocation("Library/Developer/Xcode/DerivedData", tr("Промежуточные файлы сборки Xcode — пересоздаются при следующей сборке."),
apps: ["com.apple.dt.Xcode"]),
- RegenerableLocation("Library/Developer/Xcode/iOS DeviceSupport", "Файлы для отладки на iPhone — Xcode скачает их снова, когда понадобятся.",
+ RegenerableLocation("Library/Developer/Xcode/iOS DeviceSupport", tr("Файлы для отладки на iPhone — Xcode скачает их снова, когда понадобятся."),
apps: ["com.apple.dt.Xcode"]),
- RegenerableLocation("Library/Developer/CoreSimulator/Caches", "Кеш симулятора iOS — пересоздаётся сам.",
+ RegenerableLocation("Library/Developer/CoreSimulator/Caches", tr("Кеш симулятора iOS — пересоздаётся сам."),
apps: ["com.apple.iphonesimulator"]),
- RegenerableLocation("Library/Caches/com.apple.dt.Xcode", "Кеш Xcode — пересоздаётся сам.", apps: ["com.apple.dt.Xcode"]),
- RegenerableLocation("Library/Caches/Homebrew", "Скачанные пакеты Homebrew — brew скачает их снова."),
- RegenerableLocation("Library/Caches/pip", "Кеш pip — пакеты скачаются снова."),
- RegenerableLocation("Library/Caches/Yarn", "Кеш Yarn — пакеты скачаются снова."),
- RegenerableLocation(".yarn/berry/cache", "Кеш Yarn — пакеты скачаются снова."),
- RegenerableLocation(".npm/_cacache", "Кеш npm — пакеты скачаются снова."),
- RegenerableLocation("Library/Caches/CocoaPods", "Кеш CocoaPods — поды скачаются снова."),
- RegenerableLocation("Library/Caches/go-build", "Кеш сборки Go — пересоздаётся при следующей сборке."),
- RegenerableLocation(".gradle/caches", "Кеш Gradle — зависимости скачаются снова."),
- RegenerableLocation(".cargo/registry/cache", "Скачанные пакеты Cargo — скачаются снова."),
- RegenerableLocation("Library/Caches/ms-playwright", "Браузеры Playwright — скачаются снова командой «playwright install»."),
- RegenerableLocation("Library/Caches/JetBrains", "Кеши и индексы сред JetBrains — пересоздаются при следующем запуске. Локальная история правок не затрагивается.",
+ RegenerableLocation("Library/Caches/com.apple.dt.Xcode", tr("Кеш Xcode — пересоздаётся сам."), apps: ["com.apple.dt.Xcode"]),
+ RegenerableLocation("Library/Caches/Homebrew", tr("Скачанные пакеты Homebrew — brew скачает их снова.")),
+ RegenerableLocation("Library/Caches/pip", tr("Кеш pip — пакеты скачаются снова.")),
+ RegenerableLocation("Library/Caches/Yarn", tr("Кеш Yarn — пакеты скачаются снова.")),
+ RegenerableLocation(".yarn/berry/cache", tr("Кеш Yarn — пакеты скачаются снова.")),
+ RegenerableLocation(".npm/_cacache", tr("Кеш npm — пакеты скачаются снова.")),
+ RegenerableLocation("Library/Caches/CocoaPods", tr("Кеш CocoaPods — поды скачаются снова.")),
+ RegenerableLocation("Library/Caches/go-build", tr("Кеш сборки Go — пересоздаётся при следующей сборке.")),
+ RegenerableLocation(".gradle/caches", tr("Кеш Gradle — зависимости скачаются снова.")),
+ RegenerableLocation(".cargo/registry/cache", tr("Скачанные пакеты Cargo — скачаются снова.")),
+ RegenerableLocation("Library/Caches/ms-playwright", tr("Браузеры Playwright — скачаются снова командой «playwright install».")),
+ RegenerableLocation("Library/Caches/JetBrains", tr("Кеши и индексы сред JetBrains — пересоздаются при следующем запуске. Локальная история правок не затрагивается."),
apps: ["com.jetbrains."], children: ["caches", "index"]),
- RegenerableLocation("Library/Application Support/Code/Cache", "Кеш VS Code — пересоздаётся сам.", apps: ["com.microsoft.VSCode"]),
- RegenerableLocation("Library/Application Support/Code/CachedData", "Кеш VS Code — пересоздаётся сам.",
+ RegenerableLocation("Library/Application Support/Code/Cache", tr("Кеш VS Code — пересоздаётся сам."), apps: ["com.microsoft.VSCode"]),
+ RegenerableLocation("Library/Application Support/Code/CachedData", tr("Кеш VS Code — пересоздаётся сам."),
apps: ["com.microsoft.VSCode"]),
- RegenerableLocation("Library/Caches/Google/Chrome", "Кеш Chrome — страницы подгрузятся снова; закладки, пароли и история не затрагиваются.",
+ RegenerableLocation("Library/Caches/Google/Chrome", tr("Кеш Chrome — страницы подгрузятся снова; закладки, пароли и история не затрагиваются."),
apps: ["com.google.Chrome"]),
- RegenerableLocation("Library/Caches/com.spotify.client", "Кеш Spotify — музыка подгрузится снова. Скачанное для прослушивания без сети, возможно, придётся скачать заново.",
+ RegenerableLocation("Library/Caches/com.spotify.client", tr("Кеш Spotify — музыка подгрузится снова. Скачанное для прослушивания без сети, возможно, придётся скачать заново."),
apps: ["com.spotify.client"]),
- RegenerableLocation("Library/iTunes/iPhone Software Updates", "Прошивки iPhone — Finder скачает нужную снова."),
+ RegenerableLocation("Library/iTunes/iPhone Software Updates", tr("Прошивки iPhone — Finder скачает нужную снова.")),
]
/// Восстанавливаемые места, которые действительно есть у этого человека.
@@ -414,7 +414,7 @@ public struct CleanupPlanner: Sendable {
let holders = running.filter { id, _ in location.apps.contains { id.hasPrefix($0) } }
guard let name = holders.sorted(by: { $0.key < $1.key }).first?.value else { continue }
for path in paths(of: location, home: home, fileManager: fileManager) {
- result[path] = "Сейчас открыт \(name): кеш занят. Закройте программу — и его можно будет удалить."
+ result[path] = tr("Сейчас открыт \(name): кеш занят. Закройте программу — и его можно будет удалить.")
}
}
return result
@@ -459,22 +459,22 @@ extension CleanupPlanner {
/// Почему эту копию удалять нельзя; nil — можно.
func keepReason(_ copy: DuplicateCopy) -> String? {
- if isIgnored(copy.url.path) { return "Вы просили не предлагать этот файл — эта копия остаётся." }
+ if isIgnored(copy.url.path) { return tr("Вы просили не предлагать этот файл — эта копия остаётся.") }
switch copy.verdict {
case .blocked(let reason): return reason
case .caution(let notes): return notes.first
case .safe: break
}
- if copy.sharesData { return "Клон другой копии: данные у них общие, и удаление места не освободит." }
+ if copy.sharesData { return tr("Клон другой копии: данные у них общие, и удаление места не освободит.") }
if let folder = managedFolder(copy.url) {
- return "Файл медиатеки в ~/\(folder): приложение найдёт его только на этом месте."
+ return tr("Файл медиатеки в ~/\(folder): приложение найдёт его только на этом месте.")
}
// Как и сами образы в разборе: зашифрованный .dmg — личные данные, к .iso бывает подключена виртуальная машина.
if copy.isEncryptedImage {
- return "Зашифрованный образ диска — похоже, в нём ваши данные. Удалить его из разбора нельзя."
+ return tr("Зашифрованный образ диска — похоже, в нём ваши данные. Удалить его из разбора нельзя.")
}
if copy.url.pathExtension.lowercased() == "iso" {
- return "Образ .iso: к нему бывает подключена виртуальная машина. Удалить его из разбора нельзя."
+ return tr("Образ .iso: к нему бывает подключена виртуальная машина. Удалить его из разбора нельзя.")
}
return nil
}
@@ -510,16 +510,16 @@ extension CleanupPlanner {
func keeperReason(_ keeper: DuplicateCopy, others: [DuplicateCopy]) -> String {
if let reason = keepReason(keeper) { return reason }
if !isTransient(keeper.url), others.contains(where: { isTransient($0.url) }) {
- return "Лежит на своём месте, а не в Загрузках или на Рабочем столе, — эта копия остаётся."
+ return tr("Лежит на своём месте, а не в Загрузках или на Рабочем столе, — эта копия остаётся.")
}
if !Self.looksLikeCopy(keeper.url.lastPathComponent), others.contains(where: { Self.looksLikeCopy($0.url.lastPathComponent) }) {
- return "Имя без «(1)» и «копия» — похоже на оригинал, он остаётся."
+ return tr("Имя без «(1)» и «копия» — похоже на оригинал, он остаётся.")
}
if let date = keeper.created ?? keeper.modified,
others.allSatisfy({ ($0.created ?? $0.modified).map { $0 > date } ?? true }) {
- return "Появилась раньше остальных — похоже на оригинал, он остаётся."
+ return tr("Появилась раньше остальных — похоже на оригинал, он остаётся.")
}
- return "Одна копия остаётся — эта."
+ return tr("Одна копия остаётся — эта.")
}
/// Строки одной группы: первая — копия, которая остаётся, остальные — лишние.
@@ -545,7 +545,7 @@ extension CleanupPlanner {
reason = reasonToKeep
} else {
action = .trash
- reason = "Лишняя копия: содержимое то же, что у копии, которая остаётся."
+ reason = tr("Лишняя копия: содержимое то же, что у копии, которая остаётся.")
// Привычка может лишнюю копию только оставить, но не удалить: какую копию удалить,
// решают правила, а та, что остаётся, привычкам не подчиняется.
let features = DecisionFeatures.of(path: copy.url.path, home: home, kind: .copy, bytes: copy.allocated,
@@ -559,7 +559,7 @@ extension CleanupPlanner {
var learned = false
if let remembered = memory[copy.url.path], allowed.contains(remembered) {
action = remembered
- reason = "В прошлый раз вы выбрали это же."
+ reason = tr("В прошлый раз вы выбрали это же.")
learned = true
habit = false
}
diff --git a/Sources/OffloadCore/CloudRestore.swift b/Sources/OffloadCore/CloudRestore.swift
index 6acd7a5..8241426 100644
--- a/Sources/OffloadCore/CloudRestore.swift
+++ b/Sources/OffloadCore/CloudRestore.swift
@@ -88,13 +88,13 @@ public enum CloudRestore {
public var errorDescription: String? {
switch self {
case .resticMissing:
- return "Для восстановления нужна программа restic. Установите её: brew install restic — и нажмите «Проверить снова»."
+ return tr("Для восстановления нужна программа restic. Установите её: brew install restic — и нажмите «Проверить снова».")
case .wrongPassword:
- return "Пароль не подходит к этому хранилищу."
+ return tr("Пароль не подходит к этому хранилищу.")
case .notARepository(let path):
- return "В папке «\(path)» нет хранилища restic."
+ return tr("В папке «\(path)» нет хранилища restic.")
case .notEnoughSpace(let needed, let available):
- return "Не хватает места: нужно \(Format.bytes(needed)), свободно \(Format.bytes(available))."
+ return tr("Не хватает места: нужно \(Format.bytes(needed)), свободно \(Format.bytes(available)).")
case .failed(let message):
return message
}
@@ -163,7 +163,7 @@ public enum CloudRestore {
default:
let message = readable(stderr).suffix(3).joined(separator: " ")
if message.localizedCaseInsensitiveContains("wrong password") { return .wrongPassword }
- return .failed("restic завершился с кодом \(status)" + (message.isEmpty ? "." : ": \(message)"))
+ return .failed(tr("restic завершился с кодом \(status)") + (message.isEmpty ? "." : ": \(message)"))
}
}
@@ -275,7 +275,7 @@ public enum CloudRestore {
for path in broken.all { removeBroken(path, in: target) }
}
guard fm.fileExists(atPath: item.path) else {
- throw fail(result.succeeded ? RestoreError.failed("restic ничего не восстановил: «\(entry.path)» нет в снимке.")
+ throw fail(result.succeeded ? RestoreError.failed(tr("restic ничего не восстановил: «\(entry.path)» нет в снимке."))
: Self.error(status: result.status, stderr: result.stderr, repository: repository))
}
let counted = count(item)
@@ -349,13 +349,13 @@ public enum CloudRestore {
public static func failedItem(_ line: String) -> (item: String, message: String)? {
if let object = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any] {
guard object["message_type"] as? String == "error" else { return nil }
- let message = (object["error"] as? [String: Any])?["message"] as? String ?? "ошибка"
+ let message = (object["error"] as? [String: Any])?["message"] as? String ?? tr("ошибка")
return (object["item"] as? String ?? "", message)
}
let prefix = "ignoring error for "
guard line.hasPrefix(prefix) else { return nil }
let rest = line.dropFirst(prefix.count)
- guard let colon = rest.range(of: ": ") else { return (String(rest), "ошибка") }
+ guard let colon = rest.range(of: ": ") else { return (String(rest), tr("ошибка")) }
return (String(rest[.. URL {
let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "ru_RU")
+ formatter.locale = AppLanguage.locale
formatter.dateFormat = "dd.MM.yyyy HH-mm"
- let base = "Из бэкапа \(formatter.string(from: snapshot.time))"
+ let base = tr("Из бэкапа \(formatter.string(from: snapshot.time))")
var candidate = folder.appendingPathComponent(base, isDirectory: true)
var number = 2
while FileManager.default.fileExists(atPath: candidate.path) {
diff --git a/Sources/OffloadCore/DecisionStore.swift b/Sources/OffloadCore/DecisionStore.swift
index 29ff354..b071521 100644
--- a/Sources/OffloadCore/DecisionStore.swift
+++ b/Sources/OffloadCore/DecisionStore.swift
@@ -53,7 +53,7 @@ public final class DecisionStore: @unchecked Sendable {
public var errorDescription: String? {
switch self {
- case .sqlite(let message): return "База решений: \(message)"
+ case .sqlite(let message): return tr("База решений: \(message)")
}
}
}
@@ -89,7 +89,7 @@ public final class DecisionStore: @unchecked Sendable {
}
var handle: OpaquePointer?
guard sqlite3_open_v2(path, &handle, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK else {
- let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? "не открывается"
+ let message = handle.map { String(cString: sqlite3_errmsg($0)) } ?? tr("не открывается")
sqlite3_close(handle)
throw StoreError.sqlite(message)
}
@@ -357,7 +357,7 @@ public final class DecisionStore: @unchecked Sendable {
}
private func error() -> StoreError {
- StoreError.sqlite(db.map { String(cString: sqlite3_errmsg($0)) } ?? "база закрыта")
+ StoreError.sqlite(db.map { String(cString: sqlite3_errmsg($0)) } ?? tr("база закрыта"))
}
private func execute(_ sql: String) throws {
diff --git a/Sources/OffloadCore/DockerService.swift b/Sources/OffloadCore/DockerService.swift
index f7ed0dc..109c12f 100644
--- a/Sources/OffloadCore/DockerService.swift
+++ b/Sources/OffloadCore/DockerService.swift
@@ -89,18 +89,18 @@ public enum DockerError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
- case .notInstalled: return "Docker не установлен."
- case .notRunning: return "Docker не запущен. Откройте Docker Desktop и повторите."
- case .invalidName(let name): return "Недопустимое имя тома: «\(name)»."
- case .inUse(let name, let containers): return "Том «\(name)» используется контейнерами: \(containers.joined(separator: ", "))."
- case .alreadyExists(let name): return "Том «\(name)» уже существует — перезаписывать не буду."
- case .diskGuard(let reason): return "Остановлено, чтобы не забить диск Mac: \(reason)"
+ case .notInstalled: return tr("Docker не установлен.")
+ case .notRunning: return tr("Docker не запущен. Откройте Docker Desktop и повторите.")
+ case .invalidName(let name): return tr("Недопустимое имя тома: «\(name)».")
+ case .inUse(let name, let containers): return tr("Том «\(name)» используется контейнерами: \(containers.joined(separator: ", ")).")
+ case .alreadyExists(let name): return tr("Том «\(name)» уже существует — перезаписывать не буду.")
+ case .diskGuard(let reason): return tr("Остановлено, чтобы не забить диск Mac: \(reason)")
case .failed(let message): return message
- case .verificationFailed(let name): return "Архив тома «\(name)» не совпал с томом — том не тронут."
+ case .verificationFailed(let name): return tr("Архив тома «\(name)» не совпал с томом — том не тронут.")
case .pruneIncomplete(let done, let reclaimed, let message):
- let freed = reclaimed.map { ", освобождено \(Format.bytes($0))" } ?? ""
- return "Docker очистил только часть выбранного (\(done) из выбранных пунктов\(freed)), а дальше остановился: \(message)"
- case .remoteDaemon(let host): return "Docker сейчас смотрит не на этот Mac, а на «\(host)» (контекст Docker или DOCKER_HOST). Очищать и архивировать чужой Docker OffLoadAI не будет: переключитесь на локальный контекст (docker context use desktop-linux или default) и повторите."
+ let freed = reclaimed.map { tr(", освобождено \(Format.bytes($0))") } ?? ""
+ return tr("Docker очистил только часть выбранного (\(done) из выбранных пунктов\(freed)), а дальше остановился: \(message)")
+ case .remoteDaemon(let host): return tr("Docker сейчас смотрит не на этот Mac, а на «\(host)» (контекст Docker или DOCKER_HOST). Очищать и архивировать чужой Docker OffLoadAI не будет: переключитесь на локальный контекст (docker context use desktop-linux или default) и повторите.")
}
}
}
@@ -206,7 +206,7 @@ public struct DockerService: Sendable {
// Контекст Docker (docker context use …) или DOCKER_HOST могут вести на сервер. Тогда
// «Очистить Docker» удалил бы кеш и образы там, а архивация — перекачала бы и удалила его тома.
let host = endpoint()
- guard let host, host.hasPrefix("unix://") else { throw DockerError.remoteDaemon(host ?? "неизвестно") }
+ guard let host, host.hasPrefix("unix://") else { throw DockerError.remoteDaemon(host ?? tr("неизвестно")) }
}
/// Куда смотрит клиент docker: DOCKER_HOST или адрес текущего контекста.
@@ -393,7 +393,7 @@ public struct DockerService: Sendable {
try ensureRunning()
let users = try containers(using: name)
guard users.isEmpty else { throw DockerError.inUse(name, users) }
- status("Подготовка образа с GNU tar")
+ status(tr("Подготовка образа с GNU tar"))
try ensureHelperImage()
let fm = FileManager.default
@@ -402,25 +402,25 @@ public struct DockerService: Sendable {
if rawDiskBytes() == nil {
// Не Docker Desktop (OrbStack, Colima) или Docker.raw лежит в другом месте: за ростом его
// диска следить нечем. Остаётся защита по свободному месту на Mac — и об этом надо сказать.
- status("Диск Docker не найден: за его ростом не слежу, только за свободным местом на Mac")
+ status(tr("Диск Docker не найден: за его ростом не слежу, только за свободным местом на Mac"))
}
let final = SafeMover.unique(directory.appendingPathComponent(compressed ? "\(name).tar.zst" : "\(name).tar"))
let partial = directory.appendingPathComponent(".\(name).partial-\(UUID().uuidString)")
do {
- status("Упаковка тома")
+ status(tr("Упаковка тома"))
// --hard-dereference: жёсткая ссылка иначе ляжет в архив ссылкой без содержимого,
// и сверка содержимого её не увидит.
_ = try stream(["-v", "\(name):/v:ro", Self.helperImage, "tar", "--hard-dereference", "-cf", "-", "-C", "/v", "."],
feed: nil, sink: compressed ? .compress(partial) : .file(partial),
guard: .archiving, isCancelled: isCancelled)
- status("Сверка: список файлов тома")
+ status(tr("Сверка: список файлов тома"))
let volumeDigest = try digest(ofVolume: name, isCancelled: isCancelled)
- status("Сверка: список файлов архива")
+ status(tr("Сверка: список файлов архива"))
let archiveDigest = try digest(ofArchive: partial, compressed: compressed, isCancelled: isCancelled)
guard !volumeDigest.isEmpty, volumeDigest == archiveDigest else { throw DockerError.verificationFailed(name) }
// Оба отпечатка считает один и тот же вспомогательный образ. Независимо от него архив
// читает системный tar на Mac: число записей должно совпасть с числом путей в томе.
- status("Сверка: архив читается на Mac")
+ status(tr("Сверка: архив читается на Mac"))
guard let listed = Self.archiveEntryCount(partial, compressed: compressed, isCancelled: isCancelled),
listed == Int(volumeDigest.split(separator: " ").first ?? "") else {
throw DockerError.verificationFailed(name)
@@ -457,9 +457,9 @@ public struct DockerService: Sendable {
let inspect = try Runner.run("docker", ["volume", "inspect", name], timeout: 30)
if inspect.succeeded { throw DockerError.alreadyExists(name) }
guard inspect.stderr.lowercased().contains("no such volume") else {
- throw DockerError.failed("Не удалось проверить, есть ли уже том «\(name)»: \(inspect.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
+ throw DockerError.failed(tr("Не удалось проверить, есть ли уже том «\(name)»: \(inspect.stderr.trimmingCharacters(in: .whitespacesAndNewlines))"))
}
- status("Подготовка образа с GNU tar")
+ status(tr("Подготовка образа с GNU tar"))
try ensureHelperImage()
let compressed = archive.pathExtension.lowercased() == "zst"
// Метка с одноразовым значением: по ней видно, что том создан этим вызовом. Если том
@@ -469,11 +469,11 @@ public struct DockerService: Sendable {
try Runner.check("docker", ["volume", "create", "--label", "\(Self.restoreLabel)=\(mark)", name], timeout: 60)
guard restoreMark(of: name) == mark else { throw DockerError.alreadyExists(name) }
do {
- status("Распаковка в том")
+ status(tr("Распаковка в том"))
_ = try stream(["-i", "-v", "\(name):/v", Self.helperImage, "tar", "-xpf", "-", "-C", "/v"],
feed: compressed ? .decompress(archive) : .file(archive), sink: .capture,
guard: .restoring, isCancelled: isCancelled)
- status("Сверка")
+ status(tr("Сверка"))
let volumeDigest = try digest(ofVolume: name, isCancelled: isCancelled)
let archiveDigest = try digest(ofArchive: archive, compressed: compressed, isCancelled: isCancelled)
guard !volumeDigest.isEmpty, volumeDigest == archiveDigest else { throw DockerError.verificationFailed(name) }
@@ -692,10 +692,10 @@ public struct DockerService: Sendable {
try? readHandle?.close()
group.wait()
guard docker.terminationStatus == 0 else {
- throw DockerError.failed("docker завершился с кодом \(docker.terminationStatus)")
+ throw DockerError.failed(tr("docker завершился с кодом \(docker.terminationStatus)"))
}
if let helper = helpers.first(where: { $0.terminationStatus != 0 }) {
- throw DockerError.failed("zstd завершился с кодом \(helper.terminationStatus)")
+ throw DockerError.failed(tr("zstd завершился с кодом \(helper.terminationStatus)"))
}
return String(decoding: collected.out, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -712,11 +712,11 @@ public struct DockerService: Sendable {
lastCheck = Date()
if let limit = diskGuard.maxRawGrowth, let startRaw, let now = rawDiskBytes(), now - startRaw > limit {
stop(processes, container: container)
- throw DockerError.diskGuard("Docker.raw вырос на \(Format.bytes(now - startRaw)).")
+ throw DockerError.diskGuard(tr("Docker.raw вырос на \(Format.bytes(now - startRaw))."))
}
if let minimum = diskGuard.minFreeBytes, let free = Volumes.info(for: home)?.availableBytes, free < minimum {
stop(processes, container: container)
- throw DockerError.diskGuard("на Mac осталось \(Format.bytes(free)).")
+ throw DockerError.diskGuard(tr("на Mac осталось \(Format.bytes(free))."))
}
}
Thread.sleep(forTimeInterval: 0.2)
diff --git a/Sources/OffloadCore/Duplicates.swift b/Sources/OffloadCore/Duplicates.swift
index 04a88d4..c785c41 100644
--- a/Sources/OffloadCore/Duplicates.swift
+++ b/Sources/OffloadCore/Duplicates.swift
@@ -323,7 +323,7 @@ public struct DuplicateFinder: Sendable {
public var errorDescription: String? {
switch self {
- case .notRegularFile(let name): return "«\(name)» — уже не обычный файл."
+ case .notRegularFile(let name): return tr("«\(name)» — уже не обычный файл.")
}
}
}
diff --git a/Sources/OffloadCore/Format.swift b/Sources/OffloadCore/Format.swift
index ad0792e..9422095 100644
--- a/Sources/OffloadCore/Format.swift
+++ b/Sources/OffloadCore/Format.swift
@@ -10,7 +10,7 @@ public enum Format {
/// Оперативная память считается двоичными единицами: 18 ГБ, а не 19.3.
public static func memory(_ count: UInt64) -> String { size(Int64(clamping: count), base: 1024) }
- static let units = ["Б", "КБ", "МБ", "ГБ", "ТБ", "ПБ"]
+ static let units = [tr("Б"), tr("КБ"), tr("МБ"), tr("ГБ"), tr("ТБ"), tr("ПБ")]
static func size(_ count: Int64, base: Double) -> String {
let sign = count < 0 ? "−" : ""
@@ -37,7 +37,7 @@ public enum Format {
public static func relative(_ date: Date, now: Date = Date()) -> String {
let formatter = RelativeDateTimeFormatter()
- formatter.locale = Locale(identifier: "ru_RU")
+ formatter.locale = AppLanguage.locale
formatter.unitsStyle = .full
// «Сейчас» вместо «через 0 секунд»: у файла, изменённого только что, дата может оказаться
// на долю секунды впереди часов, и числовой стиль показывал её как будущую.
diff --git a/Sources/OffloadCore/Habits.swift b/Sources/OffloadCore/Habits.swift
index 637ee52..77691e6 100644
--- a/Sources/OffloadCore/Habits.swift
+++ b/Sources/OffloadCore/Habits.swift
@@ -99,16 +99,16 @@ public struct HabitModel: Sendable {
/// Причина для строки предложения.
public var reason: String {
- "Похожее вы обычно \(Self.verb(action)) (\(agreeing) из \(total)): \(scope)."
+ tr("Похожее вы обычно \(Self.verb(action)) (\(agreeing) из \(total)): \(scope).")
}
/// «Оставляете», «убираете в сейф»…
public static func verb(_ action: CleanupAction) -> String {
switch action {
- case .trash: return "удаляете"
- case .safe: return "убираете в сейф"
- case .backup: return "добавляете в бэкап"
- case .keep: return "оставляете"
+ case .trash: return tr("удаляете")
+ case .safe: return tr("убираете в сейф")
+ case .backup: return tr("добавляете в бэкап")
+ case .keep: return tr("оставляете")
}
}
}
@@ -190,56 +190,56 @@ public struct HabitModel: Sendable {
static func noun(_ key: DecisionFeatures) -> String {
switch key.kind {
- case .folder: return "папки"
- case .project: return "проекты с git"
+ case .folder: return tr("папки")
+ case .project: return tr("проекты с git")
case .copy:
// У копий вид файла тоже различается: копии видео и копии документов — разные привычки.
switch key.category ?? .other {
- case .video: return "копии видео"
- case .audio: return "копии музыки и звука"
- case .image: return "копии фото и картинок"
- case .archive: return "копии архивов"
- case .installer: return "копии образов дисков и установщиков"
- case .document: return "копии документов"
- case .other: return "копии файлов"
+ case .video: return tr("копии видео")
+ case .audio: return tr("копии музыки и звука")
+ case .image: return tr("копии фото и картинок")
+ case .archive: return tr("копии архивов")
+ case .installer: return tr("копии образов дисков и установщиков")
+ case .document: return tr("копии документов")
+ case .other: return tr("копии файлов")
}
case .file:
switch key.category ?? .other {
- case .video: return "видео"
- case .audio: return "музыка и звук"
- case .image: return "фото и картинки"
- case .archive: return "архивы"
- case .installer: return "образы дисков и установщики"
- case .document: return "документы"
- case .other: return "файлы"
+ case .video: return tr("видео")
+ case .audio: return tr("музыка и звук")
+ case .image: return tr("фото и картинки")
+ case .archive: return tr("архивы")
+ case .installer: return tr("образы дисков и установщики")
+ case .document: return tr("документы")
+ case .other: return tr("файлы")
}
}
}
static let placeNames = [
- "Downloads": "в «Загрузках»", "Desktop": "на «Рабочем столе»", "Documents": "в «Документах»",
- "Movies": "в «Фильмах»", "Music": "в «Музыке»", "Pictures": "в «Изображениях»", "Library": "в «Библиотеке»",
+ "Downloads": tr("в «Загрузках»"), "Desktop": tr("на «Рабочем столе»"), "Documents": tr("в «Документах»"),
+ "Movies": tr("в «Фильмах»"), "Music": tr("в «Музыке»"), "Pictures": tr("в «Изображениях»"), "Library": tr("в «Библиотеке»"),
]
static func place(_ name: String) -> String {
- name.isEmpty ? "" : placeNames[name] ?? "в «~/\(name)»"
+ name.isEmpty ? "" : placeNames[name] ?? tr("в «~/\(name)»")
}
static func size(_ size: DecisionFeatures.Size) -> String {
switch size {
- case .small: return "меньше 100 МБ"
- case .medium: return "от 100 МБ до 1 ГБ"
- case .large: return "от 1 до 10 ГБ"
- case .huge: return "больше 10 ГБ"
+ case .small: return tr("меньше 100 МБ")
+ case .medium: return tr("от 100 МБ до 1 ГБ")
+ case .large: return tr("от 1 до 10 ГБ")
+ case .huge: return tr("больше 10 ГБ")
}
}
static func age(_ age: DecisionFeatures.Age) -> String? {
switch age {
- case .fresh: return "менялись в последний месяц"
- case .recent: return "менялись 1–3 месяца назад"
- case .old: return "не менялись от 3 месяцев до года"
- case .ancient: return "не менялись больше года"
+ case .fresh: return tr("менялись в последний месяц")
+ case .recent: return tr("менялись 1–3 месяца назад")
+ case .old: return tr("не менялись от 3 месяцев до года")
+ case .ancient: return tr("не менялись больше года")
case .unknown: return nil
}
}
diff --git a/Sources/OffloadCore/License.swift b/Sources/OffloadCore/License.swift
index 5b117d6..2b035a8 100644
--- a/Sources/OffloadCore/License.swift
+++ b/Sources/OffloadCore/License.swift
@@ -23,19 +23,19 @@ public enum ProFeature: String, CaseIterable, Sendable, Hashable {
public var title: String {
switch self {
- case .duplicates: return "Лишние копии"
- case .habits: return "Привычки"
- case .projectBackup: return "Бэкап проектов"
- case .dockerVolumes: return "Тома Docker в сейф"
+ case .duplicates: return tr("Лишние копии")
+ case .habits: return tr("Привычки")
+ case .projectBackup: return tr("Бэкап проектов")
+ case .dockerVolumes: return tr("Тома Docker в сейф")
}
}
public var detail: String {
switch self {
- case .duplicates: return "Одинаковые файлы находятся по SHA-256, одна копия остаётся всегда, лишние уходят в Корзину."
- case .habits: return "OffLoadAI учится на ваших ответах и сам кладёт похожее в нужный вопрос."
- case .projectBackup: return "Обновляемая копия папок с проектами в сейф: копируется только изменённое."
- case .dockerVolumes: return "Неиспользуемые тома упаковываются в сейф со сверкой каждого файла."
+ case .duplicates: return tr("Одинаковые файлы находятся по SHA-256, одна копия остаётся всегда, лишние уходят в Корзину.")
+ case .habits: return tr("OffLoadAI учится на ваших ответах и сам кладёт похожее в нужный вопрос.")
+ case .projectBackup: return tr("Обновляемая копия папок с проектами в сейф: копируется только изменённое.")
+ case .dockerVolumes: return tr("Неиспользуемые тома упаковываются в сейф со сверкой каждого файла.")
}
}
@@ -80,9 +80,9 @@ public enum LicenseError: Error, LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
- case .malformed: return "Это не похоже на ключ OffLoadAI Pro. Скопируйте его целиком, вместе с «OFFLOAD-»."
- case .badSignature: return "Ключ не подходит: подпись не сходится. Возможно, в нём опечатка — скопируйте его заново."
- case .unsupportedVersion: return "Ключ выпущен для более новой версии OffLoadAI. Обновите программу."
+ case .malformed: return tr("Это не похоже на ключ OffLoadAI Pro. Скопируйте его целиком, вместе с «OFFLOAD-».")
+ case .badSignature: return tr("Ключ не подходит: подпись не сходится. Возможно, в нём опечатка — скопируйте его заново.")
+ case .unsupportedVersion: return tr("Ключ выпущен для более новой версии OffLoadAI. Обновите программу.")
}
}
}
diff --git a/Sources/OffloadCore/Localization.swift b/Sources/OffloadCore/Localization.swift
new file mode 100644
index 0000000..1102e89
--- /dev/null
+++ b/Sources/OffloadCore/Localization.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+/// Перевод текста интерфейса. Ключ — сам русский текст: русский — язык, на котором программа написана,
+/// английский перевод лежит в en.lproj/Localizable.strings пакета OffLoadAI.app. Интерполяции становятся
+/// форматами (%@, %lld), как у String(localized:). Без перевода — в проверках и в сборке без ресурсов —
+/// возвращается русский текст как есть.
+public func tr(_ key: String.LocalizationValue) -> String {
+ String(localized: key, bundle: .main)
+}
+
+/// Перевод текста, который известен только во время работы (rawValue перечислений и т. п.).
+/// Ключ должен быть в Localizable.strings так же, как у tr.
+public func trDynamic(_ key: String) -> String {
+ Bundle.main.localizedString(forKey: key, value: key, table: nil)
+}
+
+public enum AppLanguage {
+ /// Интерфейс на русском: macOS выбрала русский из переводов программы, или английского перевода
+ /// в пакете нет вовсе (проверки, исполняемый файл без OffLoadAI.app).
+ public static var isRussian: Bool {
+ guard Bundle.main.localizations.contains("en") else { return true }
+ return Bundle.main.preferredLocalizations.first?.hasPrefix("ru") ?? true
+ }
+
+ /// Локаль для дат и относительного времени — того же языка, что интерфейс.
+ public static var locale: Locale { Locale(identifier: isRussian ? "ru_RU" : "en_US") }
+
+ /// Код языка для модели помощника: на нём она пишет пояснения.
+ public static var modelLanguage: String { isRussian ? "русский" : "English" }
+}
diff --git a/Sources/OffloadCore/MemoryStats.swift b/Sources/OffloadCore/MemoryStats.swift
index 72f27e2..c881e59 100644
--- a/Sources/OffloadCore/MemoryStats.swift
+++ b/Sources/OffloadCore/MemoryStats.swift
@@ -9,10 +9,10 @@ public enum MemoryPressure: Int, Sendable {
public var title: String {
switch self {
- case .normal: return "Нормальное"
- case .warning: return "Повышенное"
- case .critical: return "Критическое"
- case .unknown: return "Неизвестно"
+ case .normal: return tr("Нормальное")
+ case .warning: return tr("Повышенное")
+ case .critical: return tr("Критическое")
+ case .unknown: return tr("Неизвестно")
}
}
}
@@ -54,7 +54,7 @@ public struct MemorySnapshot: Sendable {
}
public enum MemoryStats {
- public static let virtualMachinesName = "Виртуальные машины (Docker, UTM и др.)"
+ public static let virtualMachinesName = tr("Виртуальные машины (Docker, UTM и др.)")
public static func snapshot(top: Int = 8) -> MemorySnapshot {
let pageSize = UInt64(vm_kernel_page_size)
@@ -135,16 +135,16 @@ public enum MemoryStats {
var tips: [String] = []
let days = Int(snapshot.uptime / 86_400)
if snapshot.swapUsedBytes > snapshot.physicalBytes / 2, days >= 2 {
- tips.append("Перезагрузите Mac: в swap \(Format.memory(snapshot.swapUsedBytes)), и сам он не освободится. Mac работает без перезагрузки \(days) дн.")
+ tips.append(tr("Перезагрузите Mac: в swap \(Format.memory(snapshot.swapUsedBytes)), и сам он не освободится. Mac работает без перезагрузки \(days) дн."))
}
if snapshot.pressure == .warning || snapshot.pressure == .critical {
- tips.append("Оперативной памяти не хватает — закройте приложения, которыми сейчас не пользуетесь.")
+ tips.append(tr("Оперативной памяти не хватает — закройте приложения, которыми сейчас не пользуетесь."))
}
for app in snapshot.apps {
if app.name == virtualMachinesName, app.bytes > 2 << 30 {
- tips.append("Виртуальные машины занимают \(Format.memory(app.bytes)). Закройте Docker Desktop или UTM, если они сейчас не нужны.")
+ tips.append(tr("Виртуальные машины занимают \(Format.memory(app.bytes)). Закройте Docker Desktop или UTM, если они сейчас не нужны."))
} else if browsers.contains(app.name), app.bytes > 3 << 30 {
- tips.append("\(app.name) занимает \(Format.memory(app.bytes)) — закройте лишние вкладки.")
+ tips.append(tr("\(app.name) занимает \(Format.memory(app.bytes)) — закройте лишние вкладки."))
}
}
return tips
diff --git a/Sources/OffloadCore/PasswordStrength.swift b/Sources/OffloadCore/PasswordStrength.swift
index 2da279b..cb701fa 100644
--- a/Sources/OffloadCore/PasswordStrength.swift
+++ b/Sources/OffloadCore/PasswordStrength.swift
@@ -31,10 +31,10 @@ public struct PasswordStrength: Sendable, Equatable {
public var title: String {
switch level {
- case .weak: return "Слабый"
- case .fair: return "Так себе"
- case .good: return "Хороший"
- case .strong: return "Надёжный"
+ case .weak: return tr("Слабый")
+ case .fair: return tr("Так себе")
+ case .good: return tr("Хороший")
+ case .strong: return tr("Надёжный")
}
}
@@ -46,7 +46,7 @@ public struct PasswordStrength: Sendable, Equatable {
// сейф одной первой частью, а оценка считала бы весь пароль. Управляющим символам в пароле не место.
if password.unicodeScalars.contains(where: { $0.value < 0x20 || $0.value == 0x7F }) {
return PasswordStrength(bits: 0, level: .weak,
- advice: ["В пароле есть невидимые управляющие символы (перевод строки, табуляция, нулевой байт) — уберите их."],
+ advice: [tr("В пароле есть невидимые управляющие символы (перевод строки, табуляция, нулевой байт) — уберите их.")],
length: length)
}
@@ -116,25 +116,25 @@ public struct PasswordStrength: Sendable, Equatable {
"princess", "shadow", "москва", "moskva", "russia", "россия", "лето", "зима", "весна", "осень"]
for word in common where lowered.contains(word) || plain.contains(word) {
bits -= Double(word.count) * perCharacter * 0.8
- advice.append("В пароле есть «\(word)» — такие слова перебор пробует первыми, в том числе с заменами букв на цифры и знаки.")
+ advice.append(tr("В пароле есть «\(word)» — такие слова перебор пробует первыми, в том числе с заменами букв на цифры и знаки."))
}
// Год («2024», «1987») — сотня вариантов, а не четыре случайные цифры.
if let year = lowered.range(of: #"(19|20)\d\d"#, options: .regularExpression) {
bits -= max(0, 4 * perCharacter - 7)
- advice.append("«\(lowered[year])» похоже на год — перебор подставляет годы первыми.")
+ advice.append(tr("«\(lowered[year])» похоже на год — перебор подставляет годы первыми."))
}
bits = max(0, bits)
if length < minimumLength {
- advice.append("Нужно не меньше \(minimumLength) символов, сейчас \(length).")
+ advice.append(tr("Нужно не меньше \(minimumLength) символов, сейчас \(length)."))
} else if length < recommendedLength {
- advice.append("VeraCrypt советует от \(recommendedLength) символов. Проще всего — фраза из 4–6 случайных слов через пробел.")
+ advice.append(tr("VeraCrypt советует от \(recommendedLength) символов. Проще всего — фраза из 4–6 случайных слов через пробел."))
}
if (weakCharacters + repeated) * 3 >= length {
- advice.append("Много повторов и подряд идущих символов — их перебирают в первую очередь.")
+ advice.append(tr("Много повторов и подряд идущих символов — их перебирают в первую очередь."))
}
if classes.count == 1, length < recommendedLength {
- advice.append("Один вид символов: добавьте слов или длины — это надёжнее, чем цифры в конце.")
+ advice.append(tr("Один вид символов: добавьте слов или длины — это надёжнее, чем цифры в конце."))
}
let level: Level
diff --git a/Sources/OffloadCore/Runner.swift b/Sources/OffloadCore/Runner.swift
index 3827489..a8d64d2 100644
--- a/Sources/OffloadCore/Runner.swift
+++ b/Sources/OffloadCore/Runner.swift
@@ -17,9 +17,9 @@ public enum RunnerError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
- case .toolNotFound(let tool): return "Не найдена программа «\(tool)»."
- case .timedOut(let tool): return "Программа «\(tool)» не ответила вовремя."
- case .failed(let tool, let status, let message): return "«\(tool)» завершилась с кодом \(status): \(message)"
+ case .toolNotFound(let tool): return tr("Не найдена программа «\(tool)».")
+ case .timedOut(let tool): return tr("Программа «\(tool)» не ответила вовремя.")
+ case .failed(let tool, let status, let message): return tr("«\(tool)» завершилась с кодом \(status): \(message)")
}
}
}
diff --git a/Sources/OffloadCore/SafeMover.swift b/Sources/OffloadCore/SafeMover.swift
index f5267f7..3c76f89 100644
--- a/Sources/OffloadCore/SafeMover.swift
+++ b/Sources/OffloadCore/SafeMover.swift
@@ -69,11 +69,11 @@ public enum MoveError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
case .blocked(let reason): return reason
- case .needsConfirmation(let notes): return "Нужно подтверждение: " + notes.joined(separator: " ")
+ case .needsConfirmation(let notes): return tr("Нужно подтверждение: ") + notes.joined(separator: " ")
case .destination(let blockers): return blockers.joined(separator: " ")
- case .unsafeRecord(let reason): return "Запись журнала выглядит небезопасной: \(reason)"
- case .alreadyExists(let path): return "«\(path)» уже существует — перезаписывать не буду."
- case .contentMismatch(let detail): return "Содержимое не совпало с проверкой, ничего не удалено: \(detail). Проверьте ещё раз."
+ case .unsafeRecord(let reason): return tr("Запись журнала выглядит небезопасной: \(reason)")
+ case .alreadyExists(let path): return tr("«\(path)» уже существует — перезаписывать не буду.")
+ case .contentMismatch(let detail): return tr("Содержимое не совпало с проверкой, ничего не удалено: \(detail). Проверьте ещё раз.")
}
}
}
@@ -127,7 +127,7 @@ public struct SafeMover: Sendable {
var verdict = rules.verdict(for: source, content: content, openBy: openBy ?? [])
if openBy == nil {
// Проверить не удалось — не делаем вид, что всё чисто.
- let note = "Не удалось проверить, открыты ли файлы в других приложениях. Закройте приложения, которые могут с ними работать."
+ let note = tr("Не удалось проверить, открыты ли файлы в других приложениях. Закройте приложения, которые могут с ними работать.")
switch verdict {
case .safe: verdict = .caution([note])
case .caution(let notes): verdict = .caution(notes + [note])
@@ -282,15 +282,15 @@ public struct SafeMover: Sendable {
/// зашифрованный целиком.
public func relocate(_ record: MoveRecord, into safe: VolumeInfo, isCancelled: () -> Bool = { false },
progress: (MoveProgress) -> Void = { _ in }) throws -> MoveRecord {
- guard safe.isEncryptedImage else { throw MoveError.unsafeRecord("сейф не открыт") }
+ guard safe.isEncryptedImage else { throw MoveError.unsafeRecord(tr("сейф не открыт")) }
let (archived, _) = try validate(record)
guard Self.exists(archived) else {
- throw MoveError.unsafeRecord("архив не найден — подключите диск «\(record.volumeName)»")
+ throw MoveError.unsafeRecord(tr("архив не найден — подключите диск «\(record.volumeName)»"))
}
let safeRoot = safe.mountPoint.standardizedFileURL.path
guard let host = Volumes.info(for: archived), host.mountPoint.standardizedFileURL.path != safeRoot,
!archived.path.hasPrefix(safeRoot + "/") else {
- throw MoveError.unsafeRecord("архив уже в сейфе")
+ throw MoveError.unsafeRecord(tr("архив уже в сейфе"))
}
// Внутри сейфа путь повторяет путь на диске, чтобы по архиву было видно, откуда он.
let hostRoot = host.mountPoint.standardizedFileURL.path
@@ -315,7 +315,7 @@ public struct SafeMover: Sendable {
try Self.assertMatches(entries, Inspector.inspect(archived, isCancelled: isCancelled), skippedFiles: all.count - entries.count)
let total = entries.reduce(Int64(0)) { $0 + $1.size }
guard total + (64 << 20) <= safe.availableBytes else {
- throw MoveError.destination(["В сейфе не хватает места: нужно \(Format.bytes(total)), свободно \(Format.bytes(safe.availableBytes)). Освободите место на диске «\(host.name)»."])
+ throw MoveError.destination([tr("В сейфе не хватает места: нужно \(Format.bytes(total)), свободно \(Format.bytes(safe.availableBytes)). Освободите место на диске «\(host.name)».")])
}
let parent = target.deletingLastPathComponent()
@@ -387,9 +387,9 @@ public struct SafeMover: Sendable {
var record = MoveRecord(originalPath: original.standardizedFileURL.path, archivedPath: archived.standardizedFileURL.path,
volumeName: "", files: 0, bytes: 0, originalRemoved: originalRemoved, note: note)
let (archivedURL, _) = try validate(record)
- guard Self.exists(archivedURL) else { throw MoveError.unsafeRecord("на диске нет «\(archivedURL.path)»") }
+ guard Self.exists(archivedURL) else { throw MoveError.unsafeRecord(tr("на диске нет «\(archivedURL.path)»")) }
guard let volume = Volumes.info(for: archivedURL), volume.mountPoint.path.hasPrefix("/Volumes/") else {
- throw MoveError.unsafeRecord("«\(archivedURL.path)» лежит не на внешнем диске")
+ throw MoveError.unsafeRecord(tr("«\(archivedURL.path)» лежит не на внешнем диске"))
}
let content = Inspector.inspect(archivedURL)
record.volumeName = volume.name
@@ -408,15 +408,15 @@ public struct SafeMover: Sendable {
let original = URL(fileURLWithPath: record.originalPath).standardizedFileURL
guard record.archivedPath.hasPrefix("/"), record.originalPath.hasPrefix("/"),
archived.path == record.archivedPath, original.path == record.originalPath else {
- throw MoveError.unsafeRecord("пути должны быть абсолютными и без «..»")
+ throw MoveError.unsafeRecord(tr("пути должны быть абсолютными и без «..»"))
}
// Не только папка Offload: переносы, сделанные вручную, лежат где угодно на внешнем диске.
let components = archived.pathComponents
guard components.count >= 4, components[1] == "Volumes" else {
- throw MoveError.unsafeRecord("архив должен лежать на внешнем диске, в /Volumes")
+ throw MoveError.unsafeRecord(tr("архив должен лежать на внешнем диске, в /Volumes"))
}
guard archived.resolvingSymlinksInPath().path == archived.path else {
- throw MoveError.unsafeRecord("путь к архиву проходит через символическую ссылку")
+ throw MoveError.unsafeRecord(tr("путь к архиву проходит через символическую ссылку"))
}
// pathVerdict разворачивает ссылки и в пути, которого ещё нет: без этого подложенная
// в журнал запись вида «Documents/Фото/old/LaunchAgents/…», где old — ссылка на
@@ -557,14 +557,14 @@ public struct SafeMover: Sendable {
// Приложение могло оставить на старом месте пустую папку (так делает LM Studio с папкой моделей) — её можно заменить.
if Self.exists(original), !Self.isEmptyDirectory(original) { throw MoveError.alreadyExists(original.path) }
guard let volume = Volumes.info(for: archived), Self.exists(archived) else {
- throw MoveError.unsafeRecord("архив не найден — подключите диск «\(record.volumeName)»")
+ throw MoveError.unsafeRecord(tr("архив не найден — подключите диск «\(record.volumeName)»"))
}
// Место с оговорками (скрытые папки программ и т. п.) — туда кладут то, что программа потом
// читает и исполняет. Такой возврат принимаем, только если перенос сделан на этом Mac:
// локальный журнал в ~/Library подложить с внешнего диска нельзя.
if case .caution = rules.pathVerdict(for: original),
!Journal.localRecords().contains(where: { $0.id == record.id && $0.originalPath == record.originalPath }) {
- throw MoveError.unsafeRecord("«\(original.path)» — место, откуда программы читают настройки и код, а запись об этом переносе есть только в журнале на диске, не на этом Mac. Если архив ваш, скопируйте его вручную.")
+ throw MoveError.unsafeRecord(tr("«\(original.path)» — место, откуда программы читают настройки и код, а запись об этом переносе есть только в журнале на диске, не на этом Mac. Если архив ваш, скопируйте его вручную."))
}
let fm = FileManager.default
// Служебные ._-двойники, которые macOS сама наплодила рядом с файлами на внешнем диске,
@@ -617,7 +617,7 @@ public struct SafeMover: Sendable {
// Настоящих прав взять неоткуда. Ставим самые узкие, при которых всё работает:
// приватные ключи, .env и базы паролей не должны вернуться читаемыми всем на машине.
Self.normalizeModes(entries, at: partial)
- notes.append("Диск «\(volume.name)» (\(volume.fsDisplayName)) не хранит права доступа, а списка прав рядом с архивом нет. Права выставлены только для вас: папки 700, файлы 600, исполняемые 700.")
+ notes.append(tr("Диск «\(volume.name)» (\(volume.fsDisplayName)) не хранит права доступа, а списка прав рядом с архивом нет. Права выставлены только для вас: папки 700, файлы 600, исполняемые 700."))
needsAttention = true
}
marker.remove(restoring: entries.first)
@@ -641,14 +641,14 @@ public struct SafeMover: Sendable {
do {
try VerifiedCopy.assertUnchanged(all, at: archived)
} catch {
- throw MoveError.blocked("пока шёл возврат, в архиве что-то изменилось, и вернулось не всё новое.")
+ throw MoveError.blocked(tr("пока шёл возврат, в архиве что-то изменилось, и вернулось не всё новое."))
}
try Self.assertNotOpen(archived, strict: true)
try fm.removeItem(at: archived)
try? fm.removeItem(at: Self.checksumURL(for: archived))
try? fm.removeItem(at: Self.modesURL(for: archived))
} catch {
- notes.append("Данные вернулись на Mac и сверены, а архив удалить не удалось: \(error.localizedDescription) Он остался в «\(archived.path)» — удалите его сами, когда будет удобно.")
+ notes.append(tr("Данные вернулись на Mac и сверены, а архив удалить не удалось: \(error.localizedDescription) Он остался в «\(archived.path)» — удалите его сами, когда будет удобно."))
needsAttention = true
}
}
@@ -678,10 +678,10 @@ public struct SafeMover: Sendable {
let stored: [String: String]
switch list {
case .missing:
- return StoredChecksumReport(notes: ["Рядом с архивом нет списка контрольных сумм, записанного при переносе, — сверить архив с его прежним состоянием не с чем. Все \(present.count) файлов сверены с тем, что лежит на диске сейчас, и вернулись такими."],
+ return StoredChecksumReport(notes: [tr("Рядом с архивом нет списка контрольных сумм, записанного при переносе, — сверить архив с его прежним состоянием не с чем. Все \(present.count) файлов сверены с тем, что лежит на диске сейчас, и вернулись такими.")],
hasDifferences: true)
case .unreadable:
- return StoredChecksumReport(notes: ["Список контрольных сумм рядом с архивом не читается — сверить архив с его прежним состоянием не с чем. Все \(present.count) файлов сверены с тем, что лежит на диске сейчас, и вернулись такими."],
+ return StoredChecksumReport(notes: [tr("Список контрольных сумм рядом с архивом не читается — сверить архив с его прежним состоянием не с чем. Все \(present.count) файлов сверены с тем, что лежит на диске сейчас, и вернулись такими.")],
hasDifferences: true)
case .list(let hashes):
stored = hashes
@@ -695,15 +695,15 @@ public struct SafeMover: Sendable {
var report = StoredChecksumReport()
// Первым делом — главное: сверено со списком переноса столько-то из стольких-то.
// Иначе интерфейс скажет «каждый файл сверен» и про изменившиеся файлы там, где это неправда.
- report.notes.append("Со списком, записанным при переносе, сверено \(common.count - changed.count) файлов из \(present.count) в архиве.")
+ report.notes.append(tr("Со списком, записанным при переносе, сверено \(common.count - changed.count) файлов из \(present.count) в архиве."))
if !changed.isEmpty {
- report.notes.append("С момента переноса на диске изменилось файлов: \(changed.count) (\(listing(changed, 5))). Вернулось то, что лежит в архиве сейчас, — оно сверено побайтово.")
+ report.notes.append(tr("С момента переноса на диске изменилось файлов: \(changed.count) (\(listing(changed, 5))). Вернулось то, что лежит в архиве сейчас, — оно сверено побайтово."))
}
if !extra.isEmpty {
- report.notes.append("В архиве появилось \(extra.count) файлов, которых при переносе не было (\(listing(extra, 5))). Они тоже вернулись, но сверить их не с чем — откуда они, программа не знает.")
+ report.notes.append(tr("В архиве появилось \(extra.count) файлов, которых при переносе не было (\(listing(extra, 5))). Они тоже вернулись, но сверить их не с чем — откуда они, программа не знает."))
}
if !missing.isEmpty {
- report.notes.append("В архиве не хватает \(missing.count) файлов из списка, записанного при переносе (\(listing(missing, 3))). Остальное сверено и возвращено.")
+ report.notes.append(tr("В архиве не хватает \(missing.count) файлов из списка, записанного при переносе (\(listing(missing, 3))). Остальное сверено и возвращено."))
}
report.hasDifferences = !changed.isEmpty || !extra.isEmpty || !missing.isEmpty
return report
@@ -759,7 +759,7 @@ public struct SafeMover: Sendable {
let directories = entries.filter(\.isDirectory).count
let symlinks = entries.filter { if case .symlink = $0.kind { return true } else { return false } }.count
guard files == expectedFiles, directories == content.directories, symlinks == content.symlinkCount else {
- throw MoveError.contentMismatch("проверка насчитала файлов \(expectedFiles), папок \(content.directories), ссылок \(content.symlinkCount), а обход — \(files), \(directories) и \(symlinks)")
+ throw MoveError.contentMismatch(tr("проверка насчитала файлов \(expectedFiles), папок \(content.directories), ссылок \(content.symlinkCount), а обход — \(files), \(directories) и \(symlinks)"))
}
}
@@ -774,10 +774,10 @@ public struct SafeMover: Sendable {
static func undeletableReason(_ content: ContentReport, source: URL) -> String? {
let parent = source.deletingLastPathComponent()
if access(parent.path, W_OK | X_OK) != 0 {
- return "Оригинал не удалить: нет права записи в «\(parent.lastPathComponent)». Копия на диске цела, оригинал не тронут."
+ return tr("Оригинал не удалить: нет права записи в «\(parent.lastPathComponent)». Копия на диске цела, оригинал не тронут.")
}
guard content.undeletable > 0 else { return nil }
- return "Оригинал не удалить целиком: внутри папки только для чтения или защищённые файлы (\(content.undeletableExamples.prefix(3).joined(separator: ", "))). Ничего не удалено; перенесите без удаления оригинала или снимите защиту."
+ return tr("Оригинал не удалить целиком: внутри папки только для чтения или защищённые файлы (\(content.undeletableExamples.prefix(3).joined(separator: ", "))). Ничего не удалено; перенесите без удаления оригинала или снимите защиту.")
}
/// Предел для служебных файлов рядом с архивом: они лежат на недоверенном диске.
@@ -831,11 +831,11 @@ public struct SafeMover: Sendable {
/// проверка пропускается: о ней уже предупредили в плане, и человек подтвердил.
static func assertNotOpen(_ url: URL, strict: Bool = false) throws {
guard let holders = openFiles(in: url) else {
- if strict { throw MoveError.blocked("Не удалось проверить, открыты ли файлы «\(url.lastPathComponent)» в других программах. Повторите чуть позже.") }
+ if strict { throw MoveError.blocked(tr("Не удалось проверить, открыты ли файлы «\(url.lastPathComponent)» в других программах. Повторите чуть позже.")) }
return
}
guard holders.isEmpty else {
- throw MoveError.blocked("Файлы сейчас открыты: \(holders.prefix(3).joined(separator: ", ")). Закройте приложение и повторите.")
+ throw MoveError.blocked(tr("Файлы сейчас открыты: \(holders.prefix(3).joined(separator: ", ")). Закройте приложение и повторите."))
}
}
diff --git a/Sources/OffloadCore/Safety.swift b/Sources/OffloadCore/Safety.swift
index 841a222..c88578c 100644
--- a/Sources/OffloadCore/Safety.swift
+++ b/Sources/OffloadCore/Safety.swift
@@ -98,24 +98,24 @@ public struct SafetyRules: Sendable {
} else if path.hasPrefix("/Users/Shared/") {
let shared = path.dropFirst("/Users/Shared/".count).split(separator: "/").map(String.init)
if shared.first == "Library" {
- return .blocked("Данные приложений в /Users/Shared/Library (например, движок BlueStacks): приложение перестанет их находить.")
+ return .blocked(tr("Данные приложений в /Users/Shared/Library (например, движок BlueStacks): приложение перестанет их находить."))
}
if let bundle = shared.first(where: Self.isRegisteredBundle) { return Self.bundleBlocked(bundle) }
return .safe
} else if path == homePath {
- return .blocked("Домашнюю папку целиком переносить нельзя.")
+ return .blocked(tr("Домашнюю папку целиком переносить нельзя."))
} else {
- return .blocked("Переносить можно только из домашней папки и /Users/Shared.")
+ return .blocked(tr("Переносить можно только из домашней папки и /Users/Shared."))
}
- guard let first = parts.first else { return .blocked("Домашнюю папку целиком переносить нельзя.") }
+ guard let first = parts.first else { return .blocked(tr("Домашнюю папку целиком переносить нельзя.")) }
// Служебная папка git: из неё git сам запускает хуки, а без неё проект — уже не репозиторий.
// Регистр не важен: на обычном диске Mac «.GIT» и «.git» — одна папка.
if parts.contains(where: { $0.lowercased() == ".git" }) {
- return .blocked("Служебная папка git-репозитория. Переносите проект целиком.")
+ return .blocked(tr("Служебная папка git-репозитория. Переносите проект целиком."))
}
if parts.count == 1, Self.standardFolders.contains(first) {
- return .blocked("«\(first)» — стандартная папка macOS. Переносите её содержимое, а не саму папку.")
+ return .blocked(tr("«\(first)» — стандартная папка macOS. Переносите её содержимое, а не саму папку."))
}
// У ~/Library свои правила и свой поиск пакетов: здесь папки приложений названы
// идентификаторами, а «com.utmapp.UTM» оканчивается на «.UTM», как пакет виртуальной машины.
@@ -125,12 +125,12 @@ public struct SafetyRules: Sendable {
// Регистр не важен: путь, которого ещё нет, realpath не приводит к настоящему регистру,
// а «~/.SSH» на обычном диске Mac — та же папка, что «~/.ssh».
if Self.pinnedHiddenFolders.contains(where: { $0.lowercased() == first.lowercased() }) {
- return .blocked("«~/\(first)» — настройки, ключи или инструменты разработки. Им нужно оставаться на месте.")
+ return .blocked(tr("«~/\(first)» — настройки, ключи или инструменты разработки. Им нужно оставаться на месте."))
}
if parts.count == 1 {
- return .blocked("Скрытая папка приложения целиком: приложение перестанет работать. Переносите отдельные данные внутри неё.")
+ return .blocked(tr("Скрытая папка приложения целиком: приложение перестанет работать. Переносите отдельные данные внутри неё."))
}
- return .caution(["Приложение, которому принадлежит «~/\(first)», будет искать эти данные по старому пути. Переносите, только если в нём можно указать новую папку (как папку моделей в LM Studio)."])
+ return .caution([tr("Приложение, которому принадлежит «~/\(first)», будет искать эти данные по старому пути. Переносите, только если в нём можно указать новую папку (как папку моделей в LM Studio).")])
}
return .safe
}
@@ -145,7 +145,7 @@ public struct SafetyRules: Sendable {
}
static func bundleBlocked(_ bundle: String) -> Verdict {
- .blocked("«\(bundle)» зарегистрирован в приложении (виртуальная машина, медиатека или проект). После переноса приложение его потеряет, даже если данные целы.")
+ .blocked(tr("«\(bundle)» зарегистрирован в приложении (виртуальная машина, медиатека или проект). После переноса приложение его потеряет, даже если данные целы."))
}
func libraryVerdict(_ parts: [String]) -> Verdict {
@@ -156,16 +156,16 @@ public struct SafetyRules: Sendable {
// Известные крупные места — с объяснением, как освободить их правильно.
if under(["Library", "Containers", "com.docker.docker"]) {
- return .blocked("Диск Docker. Место в нём освобождается в разделе «Docker»: очисткой образов и кеша сборки и архивацией неиспользуемых томов.")
+ return .blocked(tr("Диск Docker. Место в нём освобождается в разделе «Docker»: очисткой образов и кеша сборки и архивацией неиспользуемых томов."))
}
if under(["Library", "Containers", "com.utmapp.UTM"]) {
- return .blocked("Виртуальные машины UTM. Удаляйте и переносите их через сам UTM, иначе он их потеряет.")
+ return .blocked(tr("Виртуальные машины UTM. Удаляйте и переносите их через сам UTM, иначе он их потеряет."))
}
if parts.count >= 3, parts[1] == "Group Containers", parts[2].hasSuffix(".ru.keepcoder.Telegram") {
- return .blocked("База и кеш Telegram. Кеш очищается в самом Telegram: Настройки → Данные и память → Использование памяти.")
+ return .blocked(tr("База и кеш Telegram. Кеш очищается в самом Telegram: Настройки → Данные и память → Использование памяти."))
}
if under(["Library", "Application Support", "Claude", "vm_bundles"]) {
- return .blocked("Виртуальная машина приложения Claude — она нужна ему для работы.")
+ return .blocked(tr("Виртуальная машина приложения Claude — она нужна ему для работы."))
}
// Пакеты в ~/Library запрещены, как и везде. Кроме имени сразу под Containers, Caches
// и т. п., похожего на идентификатор: это папка приложения, а не пакет. Только там:
@@ -182,10 +182,10 @@ public struct SafetyRules: Sendable {
}
if inside(["Library", "Logs"]) { return .safe }
if inside(["Library", "Application Support", "MobileSync", "Backup"]) {
- return .caution(["Finder не увидит эту резервную копию iPhone, пока вы не вернёте её на место."])
+ return .caution([tr("Finder не увидит эту резервную копию iPhone, пока вы не вернёте её на место.")])
}
if let last = parts.last, (last as NSString).pathExtension.lowercased() == "log" { return .safe }
- return .blocked("Данные приложений в ~/Library: приложение перестанет их находить. Отсюда можно переносить только прошивки iPhone, логи и резервные копии iPhone.")
+ return .blocked(tr("Данные приложений в ~/Library: приложение перестанет их находить. Отсюда можно переносить только прошивки iPhone, логи и резервные копии iPhone."))
}
/// Итоговое решение с учётом содержимого и открытых файлов.
@@ -197,27 +197,27 @@ public struct SafetyRules: Sendable {
case .safe: break
}
if !openBy.isEmpty {
- return .blocked("Файлы сейчас открыты: \(openBy.prefix(3).joined(separator: ", ")). Закройте приложение и повторите.")
+ return .blocked(tr("Файлы сейчас открыты: \(openBy.prefix(3).joined(separator: ", ")). Закройте приложение и повторите."))
}
guard let content else { return notes.isEmpty ? .safe : .caution(notes) }
if let mounted = content.mountedVolume {
- return .blocked("Внутри смонтирован другой диск («\(mounted)»). Отключите его или переносите по частям.")
+ return .blocked(tr("Внутри смонтирован другой диск («\(mounted)»). Отключите его или переносите по частям."))
}
if let bundle = content.registeredBundle {
- return .blocked("Внутри лежит «\(bundle)» — пакет, зарегистрированный в приложении (например, виртуальная машина UTM). После переноса приложение его потеряет.")
+ return .blocked(tr("Внутри лежит «\(bundle)» — пакет, зарегистрированный в приложении (например, виртуальная машина UTM). После переноса приложение его потеряет."))
}
if content.unreadable > 0 {
- return .blocked("Нет доступа к \(content.unreadable) объектам внутри. Выдайте OffLoadAI полный доступ к диску в Системных настройках.")
+ return .blocked(tr("Нет доступа к \(content.unreadable) объектам внутри. Выдайте OffLoadAI полный доступ к диску в Системных настройках."))
}
if content.truncated {
- return .blocked("Файлов слишком много, проверка не закончена — переносите по частям.")
+ return .blocked(tr("Файлов слишком много, проверка не закончена — переносите по частям."))
}
if let date = content.newestModification, now.timeIntervalSince(date) < activeWithin {
- notes.append("Менялось \(Format.relative(date, now: now)) — возможно, ещё используется.")
+ notes.append(tr("Менялось \(Format.relative(date, now: now)) — возможно, ещё используется."))
}
- if content.containsGitRepo { notes.append("Внутри git-репозиторий — похоже на рабочий проект.") }
+ if content.containsGitRepo { notes.append(tr("Внутри git-репозиторий — похоже на рабочий проект.")) }
if content.undeletable > 0 {
- notes.append("Удалить оригинал не получится: внутри папки только для чтения или защищённые файлы (\(content.undeletableExamples.prefix(3).joined(separator: ", "))). Перенести можно только копией, оставив оригинал на месте.")
+ notes.append(tr("Удалить оригинал не получится: внутри папки только для чтения или защищённые файлы (\(content.undeletableExamples.prefix(3).joined(separator: ", "))). Перенести можно только копией, оставив оригинал на месте."))
}
return notes.isEmpty ? .safe : .caution(notes)
}
@@ -235,20 +235,20 @@ extension SafetyRules {
public static func checkDestination(_ volume: VolumeInfo, sourceVolume: VolumeInfo?, content: ContentReport) -> DestinationCheck {
var check = DestinationCheck()
if volume.isReadOnly {
- check.blockers.append("Диск «\(volume.name)» доступен только для чтения (\(volume.fsDisplayName)).")
+ check.blockers.append(tr("Диск «\(volume.name)» доступен только для чтения (\(volume.fsDisplayName))."))
}
if let sourceVolume, sourceVolume.mountPoint == volume.mountPoint {
- check.blockers.append("Источник и назначение на одном диске — место не освободится.")
+ check.blockers.append(tr("Источник и назначение на одном диске — место не освободится."))
}
if content.symlinkCount > 0 {
if !volume.keepsSymlinks {
- check.blockers.append("\(volume.fsDisplayName) не хранит символические ссылки, а внутри их \(content.symlinkCount). Перенос бы их сломал.")
+ check.blockers.append(tr("\(volume.fsDisplayName) не хранит символические ссылки, а внутри их \(content.symlinkCount). Перенос бы их сломал."))
} else if volume.emulatesSymlinks {
- check.notes.append("Символических ссылок внутри: \(content.symlinkCount). На \(volume.fsDisplayName) macOS хранит их в своём формате: на Mac они работают, а Windows и Linux могут увидеть вместо них обычные файлы.")
+ check.notes.append(tr("Символических ссылок внутри: \(content.symlinkCount). На \(volume.fsDisplayName) macOS хранит их в своём формате: на Mac они работают, а Windows и Linux могут увидеть вместо них обычные файлы."))
}
}
if let limit = volume.maxFileSize, content.largestFile > limit {
- check.blockers.append("\(volume.fsDisplayName) не принимает файлы больше 4 ГБ, а самый большой здесь — \(Format.bytes(content.largestFile)).")
+ check.blockers.append(tr("\(volume.fsDisplayName) не принимает файлы больше 4 ГБ, а самый большой здесь — \(Format.bytes(content.largestFile))."))
}
// Ни одной из двух мер по отдельности верить нельзя, поэтому берём большую.
// Логический размер мал для дерева из тысяч мелких файлов: каждый занимает на диске
@@ -262,22 +262,22 @@ extension SafetyRules {
let margin: Int64 = 512 * 1024 * 1024
check.requiredBytes = max(content.logicalBytes, content.allocatedBytes) + overhead + margin
if volume.availableBytes < check.requiredBytes {
- check.blockers.append("На «\(volume.name)» свободно \(Format.bytes(volume.availableBytes)), а нужно около \(Format.bytes(check.requiredBytes)).")
+ check.blockers.append(tr("На «\(volume.name)» свободно \(Format.bytes(volume.availableBytes)), а нужно около \(Format.bytes(check.requiredBytes))."))
}
if content.sparseFiles > 0 {
- check.notes.append("Разрежённые или сжатые файлы (\(content.sparseFiles)) займут на диске полный размер: \(Format.bytes(content.logicalBytes)) вместо \(Format.bytes(content.allocatedBytes)).")
+ check.notes.append(tr("Разрежённые или сжатые файлы (\(content.sparseFiles)) займут на диске полный размер: \(Format.bytes(content.logicalBytes)) вместо \(Format.bytes(content.allocatedBytes))."))
}
if content.hardLinkedFiles > 0 {
- check.notes.append("Файлов, на которые ведёт несколько имён (жёсткие ссылки): \(content.hardLinkedFiles). В копии каждое имя станет отдельным файлом: места займёт больше, а правка одного больше не будет видна в остальных.")
+ check.notes.append(tr("Файлов, на которые ведёт несколько имён (жёсткие ссылки): \(content.hardLinkedFiles). В копии каждое имя станет отдельным файлом: места займёт больше, а правка одного больше не будет видна в остальных."))
}
if content.taggedFiles > 0 {
- check.notes.append("У \(content.taggedFiles) объектов есть метки Finder, комментарии или другие расширенные атрибуты. Данные и права копируются, а эти пометки — нет: после возврата их не будет.")
+ check.notes.append(tr("У \(content.taggedFiles) объектов есть метки Finder, комментарии или другие расширенные атрибуты. Данные и права копируются, а эти пометки — нет: после возврата их не будет."))
}
if volume.createsAppleDouble, content.appleDoubleNamed > 0 {
- check.blockers.append("На \(volume.fsDisplayName) macOS хранит служебные данные файла X в файле «._X» и перезаписывает его сама, а у вас такие файлы есть (\(content.appleDoubleExamples.prefix(3).joined(separator: ", "))). Целыми они туда не лягут — переносите в сейф или на диск APFS.")
+ check.blockers.append(tr("На \(volume.fsDisplayName) macOS хранит служебные данные файла X в файле «._X» и перезаписывает его сама, а у вас такие файлы есть (\(content.appleDoubleExamples.prefix(3).joined(separator: ", "))). Целыми они туда не лягут — переносите в сейф или на диск APFS."))
}
if volume.createsAppleDouble {
- check.notes.append("macOS создаст рядом служебные файлы ._* — OffLoadAI удалит их после сверки.")
+ check.notes.append(tr("macOS создаст рядом служебные файлы ._* — OffLoadAI удалит их после сверки."))
}
return check
}
diff --git a/Sources/OffloadCore/SecretsVault.swift b/Sources/OffloadCore/SecretsVault.swift
index 6d786bc..ab0999d 100644
--- a/Sources/OffloadCore/SecretsVault.swift
+++ b/Sources/OffloadCore/SecretsVault.swift
@@ -18,17 +18,17 @@ public enum VaultError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
case .weakPassword:
- return "Пароль слишком слабый: нужно не меньше \(SecretsVault.minimumPasswordLength) символов и стойкость от \(Int(PasswordStrength.acceptableBits)) бит."
- case .alreadyExists: return "Контейнер уже существует."
+ return tr("Пароль слишком слабый: нужно не меньше \(SecretsVault.minimumPasswordLength) символов и стойкость от \(Int(PasswordStrength.acceptableBits)) бит.")
+ case .alreadyExists: return tr("Контейнер уже существует.")
// Говорим именно «не подтверждено»: снаружи случай «образ без шифрования» и случай
// «подделанный заголовок, подсистема образов шифрования не видит» выглядят одинаково.
- case .notEncrypted: return "Шифрование образа не подтверждено — складывать в него ключи нельзя."
- case .wrongPassword: return "Неверный пароль."
- case .mountFailed(let message): return "Не удалось открыть сейф: \(message)"
- case .busy: return "В сейфе открыты файлы. Закройте их в других программах и повторите."
- case .headerRejected(let reason): return "Заголовок не восстановлен: \(reason)"
- case .growFailed(let reason): return "Предел сейфа не увеличен: \(reason)"
- case .closeFailed(let message): return "Не удалось закрыть сейф: \(message)"
+ case .notEncrypted: return tr("Шифрование образа не подтверждено — складывать в него ключи нельзя.")
+ case .wrongPassword: return tr("Неверный пароль.")
+ case .mountFailed(let message): return tr("Не удалось открыть сейф: \(message)")
+ case .busy: return tr("В сейфе открыты файлы. Закройте их в других программах и повторите.")
+ case .headerRejected(let reason): return tr("Заголовок не восстановлен: \(reason)")
+ case .growFailed(let reason): return tr("Предел сейфа не увеличен: \(reason)")
+ case .closeFailed(let message): return tr("Не удалось закрыть сейф: \(message)")
}
}
}
@@ -352,12 +352,12 @@ public struct SecretsVault: Sendable {
let current = sizeLimit ?? 0
// hdiutil округляет размер образа вверх, поэтому повтор с тем же пределом видит
// чуть больший нынешний — это не уменьшение.
- guard maxBytes >= current - (64 << 20) else { throw VaultError.growFailed("уменьшать сейф нельзя — только увеличивать") }
+ guard maxBytes >= current - (64 << 20) else { throw VaultError.growFailed(tr("уменьшать сейф нельзя — только увеличивать")) }
let pass = Data(password.utf8)
let content = try withRawDevices(pass) { _, partition in Self.diskInfo(partition)?["Content"] as? String }
guard content == "Apple_APFS" else {
- throw VaultError.growFailed("внутри не APFS (\(content ?? "неизвестно")) — такой сейф OffLoadAI не растягивает. Создайте новый сейф нужного размера и перенесите содержимое.")
+ throw VaultError.growFailed(tr("внутри не APFS (\(content ?? tr("неизвестно"))) — такой сейф OffLoadAI не растягивает. Создайте новый сейф нужного размера и перенесите содержимое."))
}
if maxBytes > current {
@@ -392,7 +392,7 @@ public struct SecretsVault: Sendable {
let reached = partitionSize()
guard reached >= target else {
let layout = (try? Runner.run("diskutil", ["list", whole], timeout: 30)).map(output) ?? ""
- throw VaultError.growFailed("раздел занимает \(reached) байт из \(target). " + (log + [layout]).joined(separator: "\n"))
+ throw VaultError.growFailed(tr("раздел занимает \(reached) байт из \(target). ") + (log + [layout]).joined(separator: "\n"))
}
}
}
@@ -405,19 +405,19 @@ public struct SecretsVault: Sendable {
guard attached.succeeded else { throw Self.growError(attached.stderr) }
let devices = Self.devices(fromAttachPlist: attached.stdout)
guard let whole = devices.whole else {
- throw VaultError.growFailed("hdiutil не сообщил, каким диском подключился образ")
+ throw VaultError.growFailed(tr("hdiutil не сообщил, каким диском подключился образ"))
}
// Отключаем что бы ни случилось — и не молчим, если не вышло: подключённый без монтирования
// образ держит ключ в памяти, а автозакрытие его не видит (точки монтирования нет).
func release() throws {
if (try? Runner.run("hdiutil", ["detach", whole], timeout: 120))?.succeeded == true { return }
if (try? Runner.run("hdiutil", ["detach", "-force", whole], timeout: 120))?.succeeded == true { return }
- throw VaultError.growFailed("образ остался подключённым как \(whole). Отключите его: hdiutil detach -force \(whole)")
+ throw VaultError.growFailed(tr("образ остался подключённым как \(whole). Отключите его: hdiutil detach -force \(whole)"))
}
let result: T
do {
guard let partition = devices.partition else {
- throw VaultError.growFailed("в образе не нашёлся раздел с файловой системой")
+ throw VaultError.growFailed(tr("в образе не нашёлся раздел с файловой системой"))
}
result = try body(whole, partition)
} catch {
@@ -554,10 +554,10 @@ public struct SecretsVault: Sendable {
guard let data = try? Data(contentsOf: file), data.count < 4 << 20,
let backup = try? decoder.decode(HeaderBackup.self, from: data),
backup.token.prefix(8) == Data("encrcdsa".utf8) else {
- throw VaultError.headerRejected("файл не похож на копию заголовка OffLoadAI")
+ throw VaultError.headerRejected(tr("файл не похож на копию заголовка OffLoadAI"))
}
if let expected = backup.uuid, let current = Self.encryptionInfo(of: imageURL)?.uuid, expected != current {
- throw VaultError.headerRejected("копия снята с другого сейфа")
+ throw VaultError.headerRejected(tr("копия снята с другого сейфа"))
}
let token = imageURL.appendingPathComponent("token")
let aside = imageURL.appendingPathComponent("token.offload-previous")
@@ -565,7 +565,7 @@ public struct SecretsVault: Sendable {
// Отложенный заголовок от прерванного восстановления может оказаться единственным настоящим:
// молча удалить его значило бы потерять сейф.
guard !SafeMover.exists(aside) else {
- throw VaultError.headerRejected("в сейфе остался отложенный заголовок от прерванного восстановления (token.offload-previous). Если сейф не открывается, верните его на место token вручную")
+ throw VaultError.headerRejected(tr("в сейфе остался отложенный заголовок от прерванного восстановления (token.offload-previous). Если сейф не открывается, верните его на место token вручную"))
}
if fm.fileExists(atPath: token.path) { try fm.moveItem(at: token, to: aside) }
do {
@@ -578,10 +578,10 @@ public struct SecretsVault: Sendable {
do {
try fm.moveItem(at: aside, to: token)
} catch {
- throw VaultError.headerRejected("копия не подошла, а прежний заголовок вернуть не удалось: \(error.localizedDescription). Он лежит в token.offload-previous внутри сейфа — переименуйте его в token")
+ throw VaultError.headerRejected(tr("копия не подошла, а прежний заголовок вернуть не удалось: \(error.localizedDescription). Он лежит в token.offload-previous внутри сейфа — переименуйте его в token"))
}
}
- if case VaultError.wrongPassword = error { throw VaultError.headerRejected("пароль к этой копии не подходит") }
+ if case VaultError.wrongPassword = error { throw VaultError.headerRejected(tr("пароль к этой копии не подходит")) }
throw error
}
// Старый заголовок внутри образа не оставляем: он открылся бы старым паролем.
@@ -610,7 +610,7 @@ public struct SecretsVault: Sendable {
if let whole = Self.devices(fromAttachPlist: result.stdout).whole {
_ = try? Runner.run("hdiutil", ["detach", "-force", whole], timeout: 120)
}
- throw VaultError.mountFailed("hdiutil не сообщил точку монтирования")
+ throw VaultError.mountFailed(tr("hdiutil не сообщил точку монтирования"))
}
let mountPoint = URL(fileURLWithPath: mount, isDirectory: true)
// Код возврата hdiutil здесь ничего не доказывает: к подделанному образу (token
@@ -626,7 +626,7 @@ public struct SecretsVault: Sendable {
// Дома их закрывали права ~/Documents; в сейфе закрываем корнем. APFS права хранит.
guard Self.restrictToOwner(mountPoint) else {
Self.detachIgnoringErrors(mountPoint)
- throw VaultError.mountFailed("не удалось закрыть сейф от других пользователей этого Mac (chmod 700)")
+ throw VaultError.mountFailed(tr("не удалось закрыть сейф от других пользователей этого Mac (chmod 700)"))
}
return mountPoint
}
@@ -713,7 +713,7 @@ public struct SecretsVault: Sendable {
let real = from.resolvingSymlinksInPath()
guard let attributes = try? fm.attributesOfItem(atPath: real.path),
attributes[.type] as? FileAttributeType == .typeRegular else {
- report.problems.append("\(from.path): символическая ссылка не на обычный файл — не скопирована")
+ report.problems.append(tr("\(from.path): символическая ссылка не на обычный файл — не скопирована"))
continue
}
try fm.createDirectory(at: to.deletingLastPathComponent(), withIntermediateDirectories: true)
@@ -782,7 +782,7 @@ public struct SecretsVault: Sendable {
var secrets: [TreeEntry] = []
for entry in walk.entries where entry.isFile && BackupEngine.isSecretPath(entry.relativePath, in: root) {
if let owner = claimed[entry.relativePath], owner != root.path {
- report.problems.append("\(root.lastPathComponent)/\(entry.relativePath): такой же путь уже есть в «\((owner as NSString).lastPathComponent)» — пропущен, чтобы не затереть")
+ report.problems.append(tr("\(root.lastPathComponent)/\(entry.relativePath): такой же путь уже есть в «\((owner as NSString).lastPathComponent)» — пропущен, чтобы не затереть"))
continue
}
claimed[entry.relativePath] = root.path
diff --git a/Sources/OffloadCore/UpdateCheck.swift b/Sources/OffloadCore/UpdateCheck.swift
index ef3af1d..4228fa8 100644
--- a/Sources/OffloadCore/UpdateCheck.swift
+++ b/Sources/OffloadCore/UpdateCheck.swift
@@ -26,11 +26,11 @@ public enum UpdateCheck {
/// Разбор ответа GitHub. Черновики, предварительные выпуски и непонятные номера не считаются.
public static func parse(_ data: Data) throws -> Release {
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
- throw Failure(message: "GitHub ответил не так, как ожидалось.")
+ throw Failure(message: tr("GitHub ответил не так, как ожидалось."))
}
guard object["draft"] as? Bool != true, object["prerelease"] as? Bool != true,
let tag = object["tag_name"] as? String, let numbers = numbers(tag.hasPrefix("v") ? String(tag.dropFirst()) : tag, strict: true)
- else { throw Failure(message: "У последнего выпуска на GitHub непонятный номер версии.") }
+ else { throw Failure(message: tr("У последнего выпуска на GitHub непонятный номер версии.")) }
return Release(version: numbers.map(String.init).joined(separator: "."))
}
@@ -60,10 +60,10 @@ public enum UpdateCheck {
do {
(data, response) = try await session.data(for: request)
} catch {
- throw Failure(message: "Нет связи с GitHub: \(error.localizedDescription)")
+ throw Failure(message: tr("Нет связи с GitHub: \(error.localizedDescription)"))
}
guard let status = (response as? HTTPURLResponse)?.statusCode, status == 200 else {
- throw Failure(message: "GitHub не ответил на вопрос о версии (код \((response as? HTTPURLResponse)?.statusCode ?? 0)).")
+ throw Failure(message: tr("GitHub не ответил на вопрос о версии (код \((response as? HTTPURLResponse)?.statusCode ?? 0))."))
}
return try parse(data)
}
diff --git a/Sources/OffloadCore/VerifiedCopy.swift b/Sources/OffloadCore/VerifiedCopy.swift
index b72e076..f7268c4 100644
--- a/Sources/OffloadCore/VerifiedCopy.swift
+++ b/Sources/OffloadCore/VerifiedCopy.swift
@@ -30,11 +30,11 @@ public enum CopyError: LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
- case .unreadable(let path): return "Не удалось прочитать «\(path)»."
- case .destinationExists(let path): return "На диске назначения уже есть «\(path)» — перезаписывать не буду."
- case .writeFailed(let path, let reason): return "Не удалось записать «\(path)»: \(reason)"
- case .verificationFailed(let path): return "Копия «\(path)» не совпала с оригиналом."
- case .changedDuringCopy(let path): return "«\(path)» изменился во время переноса — оригинал не тронут."
+ case .unreadable(let path): return tr("Не удалось прочитать «\(path)».")
+ case .destinationExists(let path): return tr("На диске назначения уже есть «\(path)» — перезаписывать не буду.")
+ case .writeFailed(let path, let reason): return tr("Не удалось записать «\(path)»: \(reason)")
+ case .verificationFailed(let path): return tr("Копия «\(path)» не совпала с оригиналом.")
+ case .changedDuringCopy(let path): return tr("«\(path)» изменился во время переноса — оригинал не тронут.")
}
}
}
@@ -74,8 +74,8 @@ public enum TreeWalker {
return TreeEntry(relativePath: relative, kind: .symlink(target: target), size: 0, modified: modified, permissions: permissions)
default:
// Сокеты, FIFO и устройства не копируются.
- if strict { throw CopyError.unreadable("\(relative) (особый файл)") }
- problems.append("\(relative) (особый файл пропущен)")
+ if strict { throw CopyError.unreadable(tr("\(relative) (особый файл)")) }
+ problems.append(tr("\(relative) (особый файл пропущен)"))
return nil
}
}
@@ -215,7 +215,7 @@ public enum VerifiedCopy {
progress: { progress(entry.relativePath, $0) })
case .symlink(let linkTarget):
do { try fm.createSymbolicLink(atPath: target.path, withDestinationPath: linkTarget) } catch {
- throw CopyError.writeFailed(target.path, "символическая ссылка не создана: \(error.localizedDescription)")
+ throw CopyError.writeFailed(target.path, tr("символическая ссылка не создана: \(error.localizedDescription)"))
}
}
}
diff --git a/scripts/build-app.sh b/scripts/build-app.sh
index cd5ad59..8e5a647 100755
--- a/scripts/build-app.sh
+++ b/scripts/build-app.sh
@@ -46,6 +46,9 @@ swift build --package-path "$ROOT" -c release --product Offload "${ARCH_FLAGS[@]
BIN_DIR="$(swift build --package-path "$ROOT" -c release "${ARCH_FLAGS[@]}" --show-bin-path)"
# Цель SwiftPM по-прежнему зовётся Offload; в пакете исполняемый файл называется, как программа.
cp "$BIN_DIR/Offload" "$APP/Contents/MacOS/OffLoadAI"
+# Переводы интерфейса: русский — язык, на котором программа написана (ru.lproj почти пуст), английский — в en.lproj.
+# macOS выбирает язык из настроек человека; для всех, кроме русского, — английский.
+cp -R "$ROOT/Localization/"*.lproj "$APP/Contents/Resources/"
echo "→ рисую иконку"
swiftc -O -o "$ASSETS/make-icon" "$HERE/icon.swift"
@@ -72,7 +75,8 @@ cat > "$APP/Contents/Info.plist" <CFBundleShortVersionString$VERSION
CFBundleVersion$VERSION
OffloadReleaseDate$RELEASE_DATE
- CFBundleDevelopmentRegionru
+ CFBundleDevelopmentRegionen
+ CFBundleLocalizationsenru
LSMinimumSystemVersion14.0
LSApplicationCategoryTypepublic.app-category.utilities
NSHighResolutionCapable
diff --git a/scripts/check-l10n.sh b/scripts/check-l10n.sh
new file mode 100755
index 0000000..639a08b
--- /dev/null
+++ b/scripts/check-l10n.sh
@@ -0,0 +1,45 @@
+#!/bin/zsh
+# Проверяет, что у каждой строки интерфейса Mac есть английский перевод.
+# Ключи собирает сам компилятор (-emit-localized-strings): Text("…"), tr("…"), String(localized:) и т. п.
+# Нет перевода — список ключей и код 1. Новую строку добавляйте в Localization/en.lproj/Localizable.strings.
+# Использование: scripts/check-l10n.sh
+set -euo pipefail
+
+HERE="${0:A:h}"
+ROOT="${HERE:h}"
+OUT="$(mktemp -d)"
+trap 'rm -rf "$OUT"' EXIT
+
+# Та же оговорка про SDK 27 без Xcode, что в build-app.sh.
+DEFAULT_SDK="$(xcrun --show-sdk-version 2>/dev/null || echo 0)"
+if [[ -z "${SDKROOT:-}" && "${DEFAULT_SDK%%.*}" -ge 27 ]] \
+ && ! find "$(xcode-select -p)" -path '*host/plugins/*SwiftUIMacros*' -print -quit 2>/dev/null | grep -q .; then
+ OLDER_SDK="$(ls -d /Library/Developer/CommandLineTools/SDKs/MacOSX26*.sdk 2>/dev/null | sort -V | tail -1)"
+ [[ -n "$OLDER_SDK" ]] && export SDKROOT="$OLDER_SDK"
+fi
+
+# Отдельная папка сборки: только полная пересборка выдаёт строки всех файлов.
+swift build --package-path "$ROOT" --product Offload --build-path "$OUT/build" \
+ -Xswiftc -emit-localized-strings -Xswiftc -emit-localized-strings-path -Xswiftc "$OUT/strings" >/dev/null
+
+python3 - "$OUT/strings" "$ROOT/Localization/en.lproj/Localizable.strings" <<'PY'
+import glob, json, re, subprocess, sys
+strings_dir, table = sys.argv[1], sys.argv[2]
+keys = set()
+for f in glob.glob(f'{strings_dir}/*.stringsdata'):
+ for entries in json.load(open(f))['tables'].values():
+ keys.update(e['key'] for e in entries)
+# Переводятся только строки с русскими буквами или кавычками «»; остальное (форматы, имена) — как есть.
+keys = {k for k in keys if re.search('[А-Яа-яЁё«»]', k)}
+translated = json.loads(subprocess.check_output(['plutil', '-convert', 'json', '-o', '-', table]))
+missing = sorted(keys - translated.keys())
+spec = re.compile(r'%(?:\d+\$)?(?:lld|ld|lf|d|@|f|%)')
+norm = lambda s: sorted(re.sub(r'\d+\$', '', m) for m in spec.findall(s))
+broken = sorted(k for k in keys & translated.keys() if norm(k) != norm(translated[k]))
+for k in missing:
+ print(f'нет перевода: {k}')
+for k in broken:
+ print(f'форматы не совпадают: {k} → {translated[k]}')
+print(f'строк интерфейса: {len(keys)}, без перевода: {len(missing)}, с ошибкой формата: {len(broken)}')
+sys.exit(1 if missing or broken else 0)
+PY
From f96813ad699b1c4a1b4c0b0fccf43a2c16965f6f Mon Sep 17 00:00:00 2001
From: q <301774981+audit0@users.noreply.github.com>
Date: Sun, 4 Oct 2026 20:17:48 +0700
Subject: [PATCH 2/2] =?UTF-8?q?=D0=92=D0=B5=D1=80=D1=81=D0=B8=D1=8F=200.5.?=
=?UTF-8?q?0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 267577d..8f0916f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.4.1
+0.5.0