diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8b678cc..a7ad317 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,6 +69,9 @@ jobs:
- name: Prepare artifacts
run: npm run prepare:release
+ - name: Prepare release notes
+ run: npm run prepare:release-notes
+
- name: Attest release artifacts
uses: actions/attest@v4
with:
@@ -79,6 +82,7 @@ jobs:
with:
name: ${{ github.ref_name }}
tag_name: ${{ github.ref_name }}
+ body_path: release-notes.md
files: |
build/manifest.json
build/main.js
diff --git a/.gitignore b/.gitignore
index 44ba96e..5d8e933 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
node_modules/
main.js
build/
+release-notes.md
dist-ts/
.DS_Store
Thumbs.db
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..1754588
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,18 @@
+# Changelog
+
+## Unreleased
+
+### Documentation
+- docs: add multilingual README documentation.
+
+### Fixes
+- fix: detect Obsidian interface language correctly
+- fix: complete and unify UI localization
+- fix: remove duplicate status bar tooltip
+
+### Refactoring
+- refactor: replace cache retention with safe compaction
+
+### Features
+- feat: add all languages to plugin UI.
+
diff --git a/README.md b/README.md
index eb1009b..ebfb718 100644
--- a/README.md
+++ b/README.md
@@ -1,51 +1,47 @@
-## Local Image Compress
+# Local Image Compress
-Compress PNG and JPEG images in your desktop vault locally with bundled WebAssembly codecs. No cloud services, no external binaries, and no `node_modules` setup are required for normal plugin use.
+Compress PNG and JPEG files directly in your Obsidian vault on your computer, without cloud services or APIs. Reduce the disk space used by images by 30–70% without sacrificing quality.
-[Русская версия](README.ru.md)
+Read in your language: [English](README.md) • [العربية](assets/README.ar.md) • [Deutsch](assets/README.de.md) • [Español](assets/README.es.md) • [فارسی](assets/README.fa.md) • [Français](assets/README.fr.md) • [Bahasa Indonesia](assets/README.id.md) • [Italiano](assets/README.it.md) • [Nederlands](assets/README.nl.md) • [Polski](assets/README.pl.md) • [Português](assets/README.pt.md) • [Português (Brasil)](assets/README.pt-br.md) • [Русский](assets/README.ru.md) • [ไทย](assets/README.th.md) • [Türkçe](assets/README.tr.md) • [Українська](assets/README.uk.md) • [Tiếng Việt](assets/README.vi.md) • [日本語](assets/README.ja.md) • [한국어](assets/README.ko.md) • [中文简体](assets/README.zh-cn.md) • [中文繁體](assets/README.zh-tw.md)
+
+
+
+### Table of contents
+- [Features](#features)
+- [Supported formats](#supported-formats)
+- [Settings](#settings)
+- [How it works](#how-it-works)
+- [Data storage and backups](#data-storage-and-backups)
+- [Automation](#automation)
+- [Interaction with Paste Image Rename](#interaction-with-paste-image-rename)
+- [Privacy and external behavior](#privacy-and-external-behavior)
+- [Tips](#tips)
+- [FAQ](#faq)
+- [License](#license)
### Features
-- **Local compression**: PNG through libimagequant-wasm plus PNG WASM decoding; JPEG through mozjpeg-wasm.
+- **Local compression**: PNG and JPEG images are compressed locally.
- **Commands**:
- - Compress all images in the current note
- - Compress all images in a folder
- - Compress all images in the entire vault
- - Move compressed files back to original locations
+ - **Compress all images in note**: Processes images referenced or used in the active note.
+ - **Compress all images in folder**: Lets you select a folder and compresses all supported images inside it, excluding the output folder.
+ - **Compress all images in vault**: Scans the entire vault, excluding the output folder.
+ - **Move compressed files**: Moves compressed results to the original file locations. Before moving, it creates backups of both the original and compressed versions.
- **Automation**:
- - Auto-compress new files on add
- - Background compression when inactive and threshold is exceeded
-- **UI & convenience**:
- - Context menu for files/folders
- - Space savings indicator with tooltip
- - Status bar progress
-- **Safety & reliability**:
+ - Automatically compress new files when they are added
+ - Background compression after user inactivity when the number of uncompressed images in the vault reaches the threshold.
+- **UI and convenience**:
+ - Context menu for files and folders
+ - Space savings indicator with a detailed tooltip
+ - Status bar progress indicator
+- **Safety and reliability**:
- Cache of processed files with cache backups
- - Backups before moving compressed files
-
-### Installation
-1. Install `Local Image Compress` from Obsidian Community Plugins, or copy the release files to `Vault/.obsidian/plugins/local-image-compress`.
-2. Enable the plugin in `Settings -> Community plugins`.
-3. Start compressing images.
-
-The release is self-contained. You do not need `pngquant`, `mozjpeg`, Homebrew, Scoop, or plugin-level `node_modules`.
-
-### Build and release model
-
-Source lives in `src-ts`. Root `main.js` is generated and ignored, so readable TypeScript rather than compiled output stays in Git. `npm run build` creates a production-minified local bundle; `npm run test:release` rebuilds it twice, requires deterministic bytes, validates inline WASM, and stages the release allowlist.
-
-The GitHub release artifact contains only Obsidian install files: `manifest.json`, `main.js`, and `styles.css`. `versions.json` stays in the repository for compatibility metadata, but it is not uploaded as a release asset. Production minification is not obfuscation: the complete source remains readable in the repository. Tags are exact numeric SemVer without a `v` prefix. See [RELEASE_POLICY.md](RELEASE_POLICY.md).
-
-### Commands
-- **Compress all images in note**: Processes images referenced/used in the active note.
-- **Compress all images in folder**: Lets you choose a folder and compress all supported images inside, excluding the output folder.
-- **Compress all images in vault**: Full scan of the vault, excluding the output folder.
-- **Move compressed files**: Moves compressed results back to original locations. Backups of originals and compressed versions are created beforehand.
+ - Backups created before moving compressed files, with automatic deletion.
### Supported formats
- PNG (`imagequant` WASM pipeline)
- JPEG/JPG (`mozjpeg` WASM pipeline)
-WebP, GIF, BMP, HEIC/HEIF, and AVIF are intentionally skipped in this release because no encoder pipeline for those formats is bundled.
+WebP, GIF, BMP, HEIC/HEIF, and AVIF are intentionally skipped in this release because the plugin does not include encoders for those formats.
### Settings
@@ -53,107 +49,84 @@ WebP, GIF, BMP, HEIC/HEIF, and AVIF are intentionally skipped in this release be
|---|---|---|---|
| PNG quality (min-max) | Quality range for lossy PNG quantization | 1-100 (e.g. `65-80`) | `65-80` |
| JPEG quality | JPEG compression quality | 1-95 | `85` |
-| Allowed roots | Relative paths where compression is allowed. Empty = everywhere | list of strings | empty |
-| Output folder | Where to store compressed files | string | `Compressed` |
-| Auto-compress new files | Compress new images on add | boolean | `false` |
-| Background compression | Compress in background when inactive | boolean | `true` |
-| Background threshold | Number of uncompressed images to auto-start | 10-1000 | `50` |
-| Inactivity threshold | Minutes without user input before background compression may start | 1-60 minutes | `2` |
-| Cache retention | Months to keep stale cache entries after last access | 1-60 months | `12` |
-| Auto-clean ghosts on start | Remove cache entries pointing to deleted files on startup | boolean | `false` |
-| Auto backup retention | Delete old move backups automatically | boolean | `false` |
-| Keep backups, days | Delete move backups older than N days when retention is enabled | 1-365 | `30` |
-| Auto-move compressed files | Move compressed outputs back to original locations on startup when enough files are ready | boolean | `false` |
-| Auto-move threshold | Number of movable compressed files required to trigger auto-move | 1-1000 | `50` |
+| Allowed roots | Relative paths where compression is allowed. Empty = entire vault | list of strings | empty |
+| Output folder | Folder where compressed files are saved | string | `Compressed` |
+| Auto-compress new files | Compress new images when they are added | boolean | `false` |
+| Background compression | Compress in the background when inactive | boolean | `true` |
+| Background threshold | Number of uncompressed images required to start background compression automatically | 10-1000 | `50` |
+| Inactivity threshold | Minutes without user activity before background compression starts | 1-60 minutes | `2` |
+| Auto backup retention | Automatically delete old pre-move backups | boolean | `false` |
+| Keep backups, days | Delete move backups older than N days when automatic retention is enabled | 1-365 | `30` |
+| Auto-move compressed files | Move compressed files back to the original image locations on startup, replacing the originals | boolean | `false` |
+| Auto-move threshold | Number of compressed files ready to move that triggers auto-move | 1-1000 | `50` |
-The settings page also shows WASM module status, space savings, cache controls, ghost-entry cleanup, and backup controls.
### How it works
-1. Compressed files are saved in the `Compressed` folder mirroring original paths.
-2. The cache records processed files and original sizes to avoid re-compression and calculate savings correctly.
-3. “Move compressed files” moves files from `Compressed` back to original locations if the original is in allowed roots. Backups are created before moving.
+1. Compressed files are saved in the `Compressed` folder while preserving the original path structure.
+2. The cache records processed files and their original sizes to prevent repeated compression and calculate savings correctly.
+3. “Move compressed files” moves files from `Compressed` back to their original locations when the original is inside an allowed root. A backup is created before moving.
-Minimum sizes for compression: tiny images are skipped, typically `<5KB` for PNG and `<10KB` for JPEG.
+Minimum sizes for compression: very small files are usually skipped (`<5KB` for PNG and `<10KB` for JPEG).
-Internal safety limits are fixed: files larger than `100 MB` are skipped before reading, images above `100 million` pixels are skipped after header validation, one compression job may run for up to `120 seconds`, and worker initialization may run for up to `60 seconds`.
+Internal safety limits are fixed: files larger than `100 MB` are skipped before reading, and images above `100 million` pixels are skipped after header validation.
-### Cache and backups
-- **Cache**: stored at `Vault/.obsidian/plugins/local-image-compress/tinyLocal-cache.json`.
-- **Cache backups**: created automatically on important changes in `Vault/.local-image-compress/backups/cache/` and capped at 50 files.
-- **Backups before moving**: stored in `Vault/.local-image-compress/backups/originals/`. Includes originals and compressed files by vault-relative path.
-- Existing backup folders from older versions are migrated automatically on startup. The primary cache file stays in the plugin directory.
+### Data storage and backups
+- **Primary cache:** stored in the plugin folder.
+- **Cache backups:** stored in `Vault/.local-image-compress/backups/cache/`; up to 50 files are kept.
+- **Image backups:** stored in `Vault/.local-image-compress/backups/originals/`; created before the originals are replaced.
### Automation
-- “Background compression” shows a threshold slider when enabled.
-- “Keep backups, days” shows a retention slider when enabled.
-- “Auto-move compressed files” shows an item threshold slider. On startup, if `Compressed` count is at or above the threshold, moving starts.
-
-### Compatibility
-- `isDesktopOnly: true`.
-- Requires Obsidian `1.4.0+`.
-- No native compressor binaries are required on Windows, macOS, or Linux.
-- Mobile support is not declared yet because cache, move, and backup file management still rely on desktop Node filesystem APIs.
-- During compression and move operations, the compatible plugin `obsidian-paste-image-rename` is temporarily disabled if enabled, to avoid naming/moving conflicts. The guard restores plugins it disabled and skips restore ownership if the plugin state changed externally during the operation.
+- Enabling “Background compression” makes two sliders available:
+ - Background compression threshold: 10–1000 images, default 50.
+ - Inactivity threshold: 1–60 minutes, default 2.
+- Enabling “Keep backups, days” shows the retention-period slider.
+- Enabling “Auto-move compressed files” shows the file-count threshold. On startup, moving begins when the number of files in `Compressed` meets or exceeds the threshold.
### Interaction with Paste Image Rename
-This plugin temporarily disables the third-party plugin `obsidian-paste-image-rename` while compressing or moving files. There is no opt-out setting because the compressed-output mapping depends on those fresh files not being renamed by another plugin.
+This plugin temporarily disables the third-party plugin `obsidian-paste-image-rename` while compressing or moving files. This protection cannot be turned off because mapping compressed output to its original depends on newly created files not being renamed by another plugin.
+
+
+Why this protection is needed
Why it is needed:
- Paste Image Rename registers a `vault.on("create")` handler that fires for every image added to the vault within about one second of creation. It always acts on files whose name starts with `Pasted image `, and on all other images when its "Handle all attachments" option is enabled.
-- While this plugin writes compressed copies into the output folder, those fresh files trigger that handler. With an active Markdown view, Paste Image Rename renames the just-written output (which breaks this plugin's compressed-to-original mapping that the move step relies on) or shows a rename modal for each file. With no active Markdown view, it shows an `Error: No active file found` notice for every created file, which spams the interface during batch runs.
-- Obsidian has no public API for one plugin to ask another to pause, so disabling that one plugin for the duration is the only reliable mitigation.
+- When this plugin writes compressed copies to the output folder, those new files trigger that handler. With an active Markdown view, Paste Image Rename either renames the newly written output, breaking the compressed-to-original mapping used by the move operation, or shows a rename dialog for every file. Without an active Markdown view, it shows an `Error: No active file found` notice for every created file, flooding the interface with errors during batch processing.
+- Obsidian has no public API that lets one plugin ask another to pause, so temporarily disabling this one plugin is the only reliable solution.
-How it is kept safe:
+How this is handled safely:
-- Only the single known plugin id `obsidian-paste-image-rename` is affected, and only while a compression or move operation is running.
-- The plugin is always restored afterwards, with retries. The guard tracks whether it was the one that disabled it and skips restore ownership if the plugin's state changed externally during the operation.
-- Enabling/disabling that plugin uses Obsidian's internal `app.plugins` API because there is no public equivalent; the calls are feature-detected and fail gracefully.
+- Only the known plugin ID `obsidian-paste-image-rename` is affected, and only during compression or move operations.
+- The plugin is restored afterward, with retries when needed, unless its state changes externally. The guard records whether it disabled the plugin and does not attempt to restore it after such a change.
+- Enabling or disabling the plugin uses Obsidian's internal `app.plugins` API because no public equivalent exists. Calls are guarded by feature detection, and errors are handled gracefully.
+
+
### Privacy and external behavior
- **Network**: the plugin makes no runtime network requests. PNG/JPEG codecs are bundled in `main.js`; images are not uploaded.
- **Telemetry and ads**: no analytics, telemetry, crash reporting, tracking, dynamic ads, or self-update mechanism is included.
-- **Accounts and payments**: no account, subscription, license key, or payment is required. The manifest funding link is optional and is not contacted by the plugin.
-- **Vault files**: the plugin reads supported images selected by commands, automation, or allowed roots. It writes compressed outputs to the configured vault-relative folder and can replace originals only through the documented move/auto-move flow after creating backups.
-- **Local state**: cache data is stored in the plugin directory. Cache and move backups are stored under `Vault/.local-image-compress/backups/`.
+- **Accounts and payments**: no account, subscription, license key, or payment is required. The optional funding link in the manifest is never accessed by the plugin.
+- **Vault files**: the plugin reads supported images selected by commands, automation, or allowed roots. It writes compressed output to the configured vault-relative folder and replaces originals only through the documented move or auto-move workflow after creating backups.
+- **Local state**: cache data is stored in the plugin folder. Cache and move backups are stored under `Vault/.local-image-compress/backups/`.
- **External files**: plugin-managed data stays inside the current vault. The “Open folder” actions only ask the operating system to reveal documented backup folders; they do not transmit data.
-- **Other plugins**: `obsidian-paste-image-rename` may be temporarily disabled during compression/move as disclosed above, then restored with ownership checks.
+- **Other plugins**: `obsidian-paste-image-rename` may be temporarily disabled during compression or move operations, as described above, and is then restored with ownership checks.
### Tips
- Reasonable quality ranges: PNG `65-80`, JPEG `75-90`.
- Configure “Allowed roots” if you want to compress only in specific folders, such as `files/` or `images/`.
-- Use background compression if you have many uncompressed images; it starts on inactivity and threshold.
+- Use background compression when the vault contains many uncompressed images.
### FAQ
-**The plugin says WebAssembly modules failed to initialize.**
-Reload the plugin. If it repeats, report a bug with the Obsidian version, platform, and console error.
+**The plugin reports that the WebAssembly modules failed to initialize.**
+Reload the plugin. If the error occurs again, include your Obsidian version, platform, and console error in the bug report.
-**Where do compressed files go?**
-Into `Compressed` by default. To replace originals, use “Move compressed files”.
+**Where are compressed files stored?**
+They are stored in `Compressed` by default. To replace the originals, use “Move compressed files”.
-**How is savings calculated?**
-Savings are exact when the cache has original/output sizes. For uncompressed PNG/JPEG files, the plugin uses conservative estimates with capped ratios; current compressed output sizes are read live when needed.
-
-**What are ghost entries?**
-Cache entries pointing to removed or missing files. You can clear them in settings.
-
-### Troubleshooting
-- Ensure files are large enough: very small images are skipped.
-- Check “Allowed roots”: files outside these paths are not processed.
-- If moving does not happen, make sure a compressed file has a corresponding original in an allowed root.
-- Watch app notices and the developer console for `Local Image Compress` logs.
-
-### Metadata
-- ID: `local-image-compress`
-- Name: `Local Image Compress`
-- Version: `1.0.1`
-- Min app version: `1.4.0`
-- Desktop-only: yes
-- Repository: `https://github.com/haperone/local-image-compress`
+**How are savings calculated?**
+Savings are exact when the cache contains the original and output sizes. For uncompressed PNG/JPEG files, the plugin uses conservative estimates with capped ratios; the current sizes of compressed files are read from disk when needed.
### License
-- Plugin distribution: GPL-3.0-or-later (see `LICENSE`).
-- The plugin bundles `imagequant`/libimagequant WebAssembly code under GPL v3, so the distributed plugin license is GPL-3.0-or-later.
-- Third-party codecs: see `THIRD_PARTY_NOTICES.md` and the exact tracked texts under `licenses/`.
+GPL-3.0-or-later. Third-party licenses and notices: [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
diff --git a/README.ru.md b/README.ru.md
deleted file mode 100644
index ec4fa25..0000000
--- a/README.ru.md
+++ /dev/null
@@ -1,159 +0,0 @@
-## Local Image Compress
-
-Сжимает PNG и JPEG файлы в desktop-хранилище локально через встроенные WebAssembly-кодеки. Облако не используется, внешние бинарники и `node_modules` для обычной установки не нужны.
-
-[English version](README.md)
-
-### Установка
-1. Установите `Local Image Compress` через Obsidian Community Plugins или скопируйте release-файлы в `Vault/.obsidian/plugins/local-image-compress`.
-2. Включите плагин в `Settings -> Community plugins`.
-3. Запускайте сжатие изображений.
-
-Release самодостаточный. Устанавливать `pngquant`, `mozjpeg`, Homebrew, Scoop или зависимости в папку плагина не нужно.
-
-### Модель сборки и релиза
-
-Исходники находятся в `src-ts`. Корневой `main.js` генерируется и игнорируется Git, поэтому в репозитории остаётся читаемый TypeScript, а не compiled output. `npm run build` создаёт production-minified локальный bundle; `npm run test:release` собирает его дважды, проверяет детерминированные байты, inline WASM и точный allowlist релиза.
-
-GitHub release artifact содержит только install-файлы Obsidian: `manifest.json`, `main.js` и `styles.css`. `versions.json` остаётся в репозитории как compatibility metadata, но не загружается как release asset. Минификация не является обфускацией: полный читаемый исходный код остаётся в репозитории. Теги имеют точный numeric SemVer без префикса `v`. См. [RELEASE_POLICY.md](RELEASE_POLICY.md).
-
-### Возможности
-- **Локальное сжатие**: PNG через libimagequant-wasm и PNG WASM decode; JPEG через mozjpeg-wasm.
-- **Команды**:
- - Сжать все изображения в текущей заметке
- - Сжать все изображения в папке
- - Сжать все изображения во всём vault
- - Переместить сжатые файлы на места оригиналов
-- **Автоматизация**:
- - Автосжатие новых файлов при добавлении
- - Фоновое сжатие при неактивности пользователя и превышении порога
-- **UI и удобства**:
- - Контекстное меню для файлов/папок
- - Индикатор экономии места и tooltip с деталями
- - Статус-бар с индикатором процесса
-- **Безопасность и надёжность**:
- - Кэш обработанных файлов с бэкапами кэша
- - Резервные копии перед перемещением сжатых файлов
-
-### Команды
-- **Сжать все изображения в заметке**: Обрабатывает изображения, упомянутые или используемые в активной заметке.
-- **Сжать все изображения в папке**: Даёт выбрать папку и сжимает все поддерживаемые изображения внутри, кроме папки вывода.
-- **Сжать все изображения в vault**: Полный проход по хранилищу, исключая папку вывода.
-- **Переместить сжатые файлы**: Переносит результаты сжатия туда, где лежали оригиналы. Перед перемещением создаётся резервная копия оригиналов и сжатых версий.
-
-### Поддерживаемые форматы
-- PNG (`imagequant` WASM pipeline)
-- JPEG/JPG (`mozjpeg` WASM pipeline)
-
-WebP, GIF, BMP, HEIC/HEIF и AVIF в этом релизе намеренно пропускаются: для них не встроен encoder pipeline.
-
-### Настройки
-
-| Параметр | Описание | Тип/диапазон | По умолчанию |
-|---|---|---|---|
-| Качество PNG (мин-макс) | Диапазон качества для lossy PNG quantization | 1-100 (например `65-80`) | `65-80` |
-| Качество JPEG | Качество сжатия JPEG | 1-95 | `85` |
-| Разрешённые корни | Относительные пути, где разрешено сжатие. Пусто = везде | список строк | пусто |
-| Выходная папка | Папка для сохранения сжатых файлов | строка | `Compressed` |
-| Автосжатие новых файлов | Сжимать новые изображения при добавлении | boolean | `false` |
-| Автоматическое фоновое сжатие | Сжимать в фоне при неактивности | boolean | `true` |
-| Порог фонового сжатия | Количество несжатых изображений для автозапуска | 10-1000 | `50` |
-| Порог неактивности | Минуты без действий пользователя перед запуском фонового сжатия | 1-60 минут | `2` |
-| Срок хранения кэша | Сколько месяцев хранить устаревшие записи кэша после последнего доступа | 1-60 месяцев | `12` |
-| Автоочистка призраков при старте | Удалять записи кэша на удалённые файлы при запуске | boolean | `false` |
-| Автохранение бэкапов | Автоматически удалять старые бэкапы перед переносом | boolean | `false` |
-| Хранить бэкапы, дней | Удалять бэкапы переноса старше N дней, если автохранение включено | 1-365 | `30` |
-| Автоперемещение сжатых файлов | При запуске переносить сжатые файлы обратно к оригиналам, если готово достаточно файлов | boolean | `false` |
-| Порог автоперемещения | Количество готовых к переносу сжатых файлов для автозапуска | 1-1000 | `50` |
-
-Страница настроек также показывает статус WASM-модулей, экономию места, управление кэшем, очистку призрачных записей и управление бэкапами.
-
-### Как это работает
-1. Сжатые файлы сохраняются в папку `Compressed` с повторением структуры путей.
-2. Кэш фиксирует факт обработки файлов и исходный размер, чтобы не сжимать повторно и корректно считать экономию.
-3. Команда «Переместить сжатые файлы» переносит файлы из `Compressed` на места оригиналов, если оригинал найден в разрешённых корнях. Перед перемещением создаётся бэкап.
-
-Минимальные размеры для сжатия: очень маленькие файлы обычно пропускаются (`<5KB` для PNG и `<10KB` для JPEG).
-
-Внутренние лимиты безопасности фиксированы: файлы больше `100 МБ` пропускаются до чтения, изображения выше `100 млн` пикселей пропускаются после проверки заголовка, одно задание сжатия может выполняться до `120 секунд`, а инициализация воркеров — до `60 секунд`.
-
-### Кэш и бэкапы
-- **Кэш**: `Vault/.obsidian/plugins/local-image-compress/tinyLocal-cache.json`.
-- **Бэкапы кэша**: автоматически создаются при важных изменениях в `Vault/.local-image-compress/backups/cache/`; хранится не более 50 файлов.
-- **Бэкапы перед переносом**: сохраняются в `Vault/.local-image-compress/backups/originals/`. Включают оригиналы и сжатые файлы по относительным путям vault.
-- Существующие папки бэкапов из старых версий автоматически переносятся при запуске. Основной файл кэша остается в директории плагина.
-
-### Автоматизация
-- «Автоматическое фоновое сжатие» при включении показывает ползунок порога.
-- «Хранить бэкапы, дней» при включении показывает ползунок срока хранения.
-- «Автоперемещение сжатых файлов» при включении показывает порог по количеству файлов. При запуске плагина, если в `Compressed` файлов не меньше порога, запускается перемещение.
-
-### Совместимость
-- `isDesktopOnly: true`.
-- Требуется Obsidian `1.4.0+`.
-- Нативные бинарники компрессоров не нужны на Windows, macOS и Linux.
-- Поддержка mobile пока не заявляется: управление кэшем, перемещение и бэкапы всё ещё используют desktop Node filesystem API.
-- Во время сжатия и перемещения совместимый плагин `obsidian-paste-image-rename` временно отключается, если он включён, чтобы избежать конфликтов имён и перемещения. Защита восстанавливает плагины, которые отключила сама, и не присваивает себе восстановление, если состояние плагина изменилось извне во время операции.
-
-### Взаимодействие с Paste Image Rename
-
-Плагин временно отключает сторонний плагин `obsidian-paste-image-rename` на время сжатия или перемещения файлов. Настройки для отключения этой защиты нет, потому что сопоставление сжатого вывода с оригиналом зависит от того, что свежие файлы не будут переименованы другим плагином.
-
-Зачем это нужно:
-
-- Paste Image Rename вешает обработчик `vault.on("create")`, который срабатывает на каждое изображение, добавленное в хранилище в пределах ~1 секунды с момента создания. Он всегда обрабатывает файлы с именем, начинающимся на `Pasted image `, и все остальные изображения, если включена его опция «Handle all attachments».
-- Когда наш плагин пишет сжатые копии в папку вывода, эти свежие файлы запускают тот обработчик. При активной Markdown-вкладке Paste Image Rename переименовывает только что записанный вывод (это ломает сопоставление «сжатый → оригинал», на которое опирается перемещение) или показывает модалку переименования на каждый файл. Без активной Markdown-вкладки он показывает уведомление `Error: No active file found` на каждый созданный файл — при пакетном прогоне это засыпает интерфейс ошибками.
-- В Obsidian нет публичного API, чтобы один плагин попросил другой приостановиться, поэтому временное отключение этого одного плагина — единственный надёжный способ.
-
-Как это сделано безопасно:
-
-- Затрагивается только один известный id `obsidian-paste-image-rename` и только пока идёт операция сжатия или перемещения.
-- Плагин всегда восстанавливается потом, с повторами. Защита отслеживает, она ли его отключила, и не присваивает себе восстановление, если состояние плагина изменилось извне во время операции.
-- Включение/отключение этого плагина использует внутренний API Obsidian `app.plugins`, так как публичного аналога нет; вызовы feature-detected и мягко обрабатывают ошибки.
-
-### Приватность и внешнее поведение
-
-- **Сеть**: плагин не выполняет runtime-сетевые запросы. PNG/JPEG-кодеки встроены в `main.js`; изображения не загружаются.
-- **Телеметрия и реклама**: нет аналитики, телеметрии, crash reporting, tracking, динамической рекламы или механизма самообновления.
-- **Аккаунты и платежи**: аккаунт, подписка, лицензионный ключ и оплата не нужны. Funding-ссылка в manifest необязательна и самим плагином не запрашивается.
-- **Файлы vault**: плагин читает поддерживаемые изображения, выбранные командами, автоматизацией или allowed roots. Сжатые результаты пишутся в настроенную vault-relative папку; оригиналы заменяются только через документированный move/auto-move flow после создания бэкапов.
-- **Локальное состояние**: кэш хранится в папке плагина. Бэкапы кэша и перемещения хранятся в `Vault/.local-image-compress/backups/`.
-- **Внешние файлы**: управляемые данные остаются внутри текущего vault. Команды «Открыть папку» только просят ОС показать документированные папки бэкапов и ничего не передают.
-- **Другие плагины**: `obsidian-paste-image-rename` может временно отключаться во время сжатия/перемещения, как описано выше, а затем восстанавливается с проверкой ownership.
-
-### Советы
-- Разумный диапазон качества: PNG `65-80`, JPEG `75-90`.
-- Настройте «Разрешённые корни», если хотите сжимать только определённые папки, например `files/` или `images/`.
-- Используйте фоновое сжатие, если в хранилище много несжатых изображений.
-
-### Частые вопросы
-**Плагин пишет, что WebAssembly-модули не инициализировались.**
-Перезагрузите плагин. Если ошибка повторяется, приложите версию Obsidian, платформу и ошибку из консоли к bug report.
-
-**Где оказываются сжатые файлы?**
-По умолчанию в `Compressed`. Для замены оригиналов используйте команду «Переместить сжатые файлы».
-
-**Как считается экономия?**
-Экономия точная, когда в кэше есть исходный и выходной размеры. Для несжатых PNG/JPEG плагин использует консервативную оценку с ограниченными коэффициентами; текущие размеры сжатых файлов при необходимости читаются с диска.
-
-**Что такое призрачные записи?**
-Записи кэша, которые указывают на удалённые или отсутствующие файлы. Их можно очистить в настройках.
-
-### Диагностика
-- Убедитесь, что файлы достаточно большие: слишком маленькие изображения пропускаются.
-- Проверьте «Разрешённые корни»: файлы вне этих путей не обрабатываются.
-- Если перенос не происходит, убедитесь, что у сжатого файла есть соответствующий оригинал в разрешённом корне.
-- Смотрите уведомления приложения и консоль разработчика для логов `Local Image Compress`.
-
-### Метаданные
-- ID: `local-image-compress`
-- Имя: `Local Image Compress`
-- Версия: `1.0.1`
-- Минимальная версия приложения: `1.4.0`
-- Desktop-only: да
-- Репозиторий: `https://github.com/haperone/local-image-compress`
-
-### Лицензия
-- Дистрибутив плагина: GPL-3.0-or-later (см. `LICENSE`).
-- Плагин включает WebAssembly-код `imagequant`/libimagequant под GPL v3, поэтому распространяемый плагин лицензирован как GPL-3.0-or-later.
-- Сторонние кодеки: см. `THIRD_PARTY_NOTICES.md` и точные отслеживаемые тексты в `licenses/`.
diff --git a/RELEASE_POLICY.md b/RELEASE_POLICY.md
index 3fa768a..ecab095 100644
--- a/RELEASE_POLICY.md
+++ b/RELEASE_POLICY.md
@@ -41,6 +41,10 @@ GitHub release assets are exactly:
- `main.js`
- `styles.css`
+The release body is generated from the `Unreleased` section of `CHANGELOG.md`.
+Promotion builds that section from the DEV Conventional Commit subjects, so
+release notes do not depend on reconstructing changes from memory.
+
`versions.json` remains tracked in the repository for compatibility metadata,
but it is not a GitHub Release asset.
@@ -57,6 +61,6 @@ provenance from the repository workflow.
## Version ownership
The current version must agree across `manifest.json`, `versions.json`, root
-`package.json`, README metadata, and the release tag. `minAppVersion` remains
+`package.json`, and the release tag. `minAppVersion` remains
`1.4.0` while current API types are used only as a compile-time review
surface.
diff --git a/assets/Features.gif b/assets/Features.gif
new file mode 100644
index 0000000..3a434b9
Binary files /dev/null and b/assets/Features.gif differ
diff --git a/assets/README.ar.md b/assets/README.ar.md
new file mode 100644
index 0000000..269d5ae
--- /dev/null
+++ b/assets/README.ar.md
@@ -0,0 +1,131 @@
+# Local Image Compress
+
+اضغط ملفات PNG وJPEG مباشرة داخل خزنة Obsidian على جهازك، من دون خدمات سحابية أو واجهات API. قلّل المساحة التي تشغلها الصور بنسبة 30–70% من دون التضحية بالجودة.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### المحتويات
+- [الميزات](#الميزات)
+- [التنسيقات المدعومة](#التنسيقات-المدعومة)
+- [الإعدادات](#الإعدادات)
+- [آلية العمل](#آلية-العمل)
+- [تخزين البيانات والنسخ الاحتياطية](#تخزين-البيانات-والنسخ-الاحتياطية)
+- [الأتمتة](#الأتمتة)
+- [التكامل مع Paste Image Rename](#التكامل-مع-paste-image-rename)
+- [الخصوصية والسلوك الخارجي](#الخصوصية-والسلوك-الخارجي)
+- [نصائح](#نصائح)
+- [الأسئلة الشائعة](#الأسئلة-الشائعة)
+- [الترخيص](#الترخيص)
+
+### الميزات
+- **ضغط محلي**: تُضغط صور PNG وJPEG محليًا.
+- **الأوامر**:
+ - **ضغط كل الصور في الملاحظة**: يعالج الصور المشار إليها أو المستخدمة في الملاحظة النشطة.
+ - **ضغط كل الصور في المجلد**: يتيح اختيار مجلد وضغط كل الصور المدعومة داخله مع استثناء مجلد الإخراج.
+ - **ضغط كل الصور في الخزنة**: يفحص الخزنة كاملة مع استثناء مجلد الإخراج.
+ - **نقل الملفات المضغوطة**: ينقل النتائج المضغوطة إلى مواقع الملفات الأصلية. قبل النقل، ينشئ نسخًا احتياطية من النسخ الأصلية والمضغوطة.
+- **الأتمتة**:
+ - ضغط الملفات الجديدة تلقائيًا عند إضافتها
+ - الضغط في الخلفية بعد خمول المستخدم عندما يبلغ عدد الصور غير المضغوطة الحد المحدد
+- **الواجهة وسهولة الاستخدام**:
+ - قائمة سياق للملفات والمجلدات
+ - مؤشر للمساحة الموفرة مع تلميح تفصيلي
+ - مؤشر تقدم في شريط الحالة
+- **الأمان والموثوقية**:
+ - ذاكرة مؤقتة للملفات المعالجة مع نسخ احتياطية
+ - نسخ احتياطية قبل نقل الملفات المضغوطة مع إمكانية الحذف التلقائي
+
+### التنسيقات المدعومة
+- PNG (مسار WASM باستخدام `imagequant`)
+- JPEG/JPG (مسار WASM باستخدام `mozjpeg`)
+
+يتم تخطي WebP وGIF وBMP وHEIC/HEIF وAVIF عمدًا في هذا الإصدار لأن الإضافة لا تتضمن برامج ترميز لهذه التنسيقات.
+
+### الإعدادات
+
+| الإعداد | الوصف | النوع/النطاق | الافتراضي |
+|---|---|---|---|
+| جودة PNG (الحد الأدنى-الأقصى) | نطاق جودة تكميم PNG مع فقد | 1-100 (مثل `65-80`) | `65-80` |
+| جودة JPEG | جودة ضغط JPEG | 1-95 | `85` |
+| الجذور المسموح بها | مسارات نسبية يُسمح بالضغط داخلها. فارغ = الخزنة كاملة | قائمة نصوص | فارغ |
+| مجلد الإخراج | المجلد الذي تُحفظ فيه الملفات المضغوطة | نص | `Compressed` |
+| ضغط الملفات الجديدة تلقائيًا | ضغط الصور الجديدة عند إضافتها | منطقي | `false` |
+| الضغط في الخلفية | الضغط في الخلفية عند الخمول | منطقي | `true` |
+| حد الضغط في الخلفية | عدد الصور غير المضغوطة المطلوب لبدء الضغط تلقائيًا | 10-1000 | `50` |
+| حد الخمول | دقائق بلا نشاط قبل بدء الضغط في الخلفية | 1-60 دقيقة | `2` |
+| الاحتفاظ التلقائي بالنسخ الاحتياطية | حذف نسخ ما قبل النقل القديمة تلقائيًا | منطقي | `false` |
+| مدة الاحتفاظ بالنسخ الاحتياطية | حذف نسخ النقل الأقدم من N يومًا عند تفعيل الاحتفاظ | 1-365 | `30` |
+| النقل التلقائي للملفات المضغوطة | نقل الملفات إلى مواقع الصور الأصلية عند بدء التشغيل واستبدالها | منطقي | `false` |
+| حد النقل التلقائي | عدد الملفات الجاهزة للنقل الذي يؤدي إلى بدء النقل تلقائيًا | 1-1000 | `50` |
+
+
+### آلية العمل
+1. تُحفظ الملفات المضغوطة في مجلد `Compressed` مع الحفاظ على بنية المسارات الأصلية.
+2. تسجل الذاكرة المؤقتة الملفات المعالجة وأحجامها الأصلية لمنع تكرار الضغط وحساب التوفير بدقة.
+3. ينقل أمر «نقل الملفات المضغوطة» الملفات من `Compressed` إلى مواقعها الأصلية عندما يكون الأصل داخل جذر مسموح به. تُنشأ نسخة احتياطية قبل النقل.
+
+عادةً ما يتم تخطي الملفات الصغيرة جدًا (`<5KB` لـ PNG و`<10KB` لـ JPEG).
+
+حدود الأمان ثابتة: يتم تخطي الملفات الأكبر من `100 MB` قبل قراءتها، والصور التي تتجاوز `100 مليون` بكسل بعد فحص الترويسة.
+
+### تخزين البيانات والنسخ الاحتياطية
+- **الذاكرة المؤقتة الأساسية:** تُحفظ في مجلد الإضافة.
+- **نسخ الذاكرة المؤقتة:** تُحفظ في `Vault/.local-image-compress/backups/cache/` ويُحتفظ بما يصل إلى 50 ملفًا.
+- **نسخ الصور:** تُحفظ في `Vault/.local-image-compress/backups/originals/` وتُنشأ قبل استبدال الملفات الأصلية.
+
+### الأتمتة
+- عند تفعيل «الضغط في الخلفية» يظهر شريطان:
+ - حد الضغط في الخلفية: 10–1000 صورة، الافتراضي 50.
+ - حد الخمول: 1–60 دقيقة، الافتراضي 2.
+- عند تفعيل «مدة الاحتفاظ بالنسخ الاحتياطية» يظهر شريط مدة الاحتفاظ.
+- عند تفعيل «النقل التلقائي للملفات المضغوطة» يظهر حد عدد الملفات. عند بدء التشغيل يبدأ النقل إذا بلغ عدد الملفات في `Compressed` الحد أو تجاوزه.
+
+### التكامل مع Paste Image Rename
+
+تعطّل هذه الإضافة مؤقتًا الإضافة الخارجية `obsidian-paste-image-rename` أثناء ضغط الملفات أو نقلها. لا يمكن إيقاف هذه الحماية لأن ربط الناتج المضغوط بملفه الأصلي يعتمد على عدم إعادة تسمية الملفات الجديدة بواسطة إضافة أخرى.
+
+
+لماذا تحتاج هذه الحماية
+
+سبب الحاجة إلى ذلك:
+
+- تسجل Paste Image Rename معالجًا من نوع `vault.on("create")` يعمل لكل صورة تُضاف إلى الخزنة خلال نحو ثانية من إنشائها. ويتعامل دائمًا مع الملفات التي يبدأ اسمها بـ `Pasted image `، ومع جميع الصور الأخرى عند تفعيل خيار "Handle all attachments".
+- عندما تكتب هذه الإضافة نسخًا مضغوطة في مجلد الإخراج، تشغّل الملفات الجديدة ذلك المعالج. مع وجود عرض Markdown نشط، تعيد Paste Image Rename تسمية الناتج الجديد، فتتعطل مطابقة الملف المضغوط مع الأصل، أو تعرض مربع إعادة تسمية لكل ملف. ومن دون عرض Markdown نشط، تعرض إشعار `Error: No active file found` لكل ملف، فتغمر الواجهة بالأخطاء أثناء المعالجة الجماعية.
+- لا توفر Obsidian واجهة عامة تسمح لإضافة بطلب إيقاف إضافة أخرى مؤقتًا؛ لذلك فإن تعطيل هذه الإضافة وحدها لمدة العملية هو الحل الموثوق الوحيد.
+
+كيفية تنفيذ ذلك بأمان:
+
+- لا تتأثر إلا الإضافة المعروفة ذات المعرّف `obsidian-paste-image-rename`، وفقط أثناء الضغط أو النقل.
+- تُستعاد الإضافة بعد العملية مع إعادة المحاولة عند الحاجة، ما لم تتغير حالتها خارجيًا. تسجل الحماية ما إذا كانت هي التي عطّلتها ولا تحاول استعادتها بعد تغير خارجي.
+- يستخدم التفعيل والتعطيل واجهة Obsidian الداخلية `app.plugins` لعدم وجود بديل عام. تُفحص الميزة قبل الاستدعاء وتُعالج الأخطاء دون تعطيل العملية.
+
+
+
+### الخصوصية والسلوك الخارجي
+- **الشبكة**: لا تنفذ الإضافة أي طلبات شبكة أثناء التشغيل. برامج ترميز PNG/JPEG مضمّنة في `main.js` ولا تُرفع الصور.
+- **القياس والإعلانات**: لا توجد تحليلات أو بيانات قياس أو تقارير أعطال أو تتبع أو إعلانات ديناميكية أو تحديث ذاتي.
+- **الحسابات والمدفوعات**: لا يلزم حساب أو اشتراك أو مفتاح ترخيص أو دفع. لا تصل الإضافة إلى رابط التمويل الاختياري في manifest.
+- **ملفات الخزنة**: تقرأ الإضافة الصور المدعومة التي تحددها الأوامر أو الأتمتة أو الجذور المسموح بها. تكتب النتائج في مجلد نسبي داخل الخزنة ولا تستبدل الأصل إلا عبر النقل اليدوي أو التلقائي الموثق بعد إنشاء النسخ الاحتياطية.
+- **الحالة المحلية**: تُحفظ الذاكرة المؤقتة في مجلد الإضافة، وتُحفظ نسخ الذاكرة المؤقتة والنقل تحت `Vault/.local-image-compress/backups/`.
+- **الملفات الخارجية**: تبقى البيانات المدارة داخل الخزنة الحالية. تطلب أوامر «فتح المجلد» من نظام التشغيل عرض مجلدات النسخ الموثقة فقط ولا تنقل بيانات.
+- **الإضافات الأخرى**: قد تُعطّل `obsidian-paste-image-rename` مؤقتًا أثناء الضغط أو النقل كما هو موضح أعلاه، ثم تُستعاد مع التحقق من ملكية التغيير.
+
+### نصائح
+- نطاقات جودة مناسبة: PNG `65-80` وJPEG `75-90`.
+- اضبط «الجذور المسموح بها» إذا أردت ضغط مجلدات محددة فقط، مثل `files/` أو `images/`.
+- استخدم الضغط في الخلفية عندما تحتوي الخزنة على صور كثيرة غير مضغوطة.
+
+### الأسئلة الشائعة
+**تذكر الإضافة أن وحدات WebAssembly لم تتم تهيئتها.**
+أعد تحميل الإضافة. إذا تكرر الخطأ، أرفق إصدار Obsidian والمنصة وخطأ وحدة التحكم بتقرير المشكلة.
+
+**أين تُحفظ الملفات المضغوطة؟**
+تُحفظ افتراضيًا في `Compressed`. لاستبدال الملفات الأصلية، استخدم «نقل الملفات المضغوطة».
+
+**كيف يُحسب التوفير؟**
+يكون الحساب دقيقًا عندما تحتوي الذاكرة المؤقتة على الحجم الأصلي وحجم الناتج. بالنسبة إلى ملفات PNG/JPEG غير المضغوطة، تستخدم الإضافة تقديرات محافظة بنسب محدودة؛ وتقرأ أحجام الملفات المضغوطة الحالية من القرص عند الحاجة.
+
+### الترخيص
+GPL-3.0-or-later. تراخيص وإشعارات الجهات الخارجية: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.de.md b/assets/README.de.md
new file mode 100644
index 0000000..eb33359
--- /dev/null
+++ b/assets/README.de.md
@@ -0,0 +1,131 @@
+# Local Image Compress
+
+Komprimieren Sie PNG- und JPEG-Dateien direkt in Ihrem Obsidian-Vault auf Ihrem Computer, ohne Cloud-Dienste oder APIs. Reduzieren Sie den von Bildern belegten Speicherplatz ohne Qualitätsverlust um 30–70 %.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Inhaltsverzeichnis
+- [Funktionen](#funktionen)
+- [Unterstützte Formate](#unterstützte-formate)
+- [Einstellungen](#einstellungen)
+- [Funktionsweise](#funktionsweise)
+- [Datenspeicherung und Sicherungen](#datenspeicherung-und-sicherungen)
+- [Automatisierung](#automatisierung)
+- [Zusammenspiel mit Paste Image Rename](#zusammenspiel-mit-paste-image-rename)
+- [Datenschutz und externes Verhalten](#datenschutz-und-externes-verhalten)
+- [Tipps](#tipps)
+- [Häufige Fragen](#häufige-fragen)
+- [Lizenz](#lizenz)
+
+### Funktionen
+- **Lokale Komprimierung**: PNG- und JPEG-Bilder werden lokal komprimiert.
+- **Befehle**:
+ - **Alle Bilder in der Notiz komprimieren**: Verarbeitet Bilder, die in der aktiven Notiz referenziert oder verwendet werden.
+ - **Alle Bilder im Ordner komprimieren**: Ermöglicht die Auswahl eines Ordners und komprimiert alle unterstützten Bilder darin; der Ausgabeordner wird ausgeschlossen.
+ - **Alle Bilder im Vault komprimieren**: Durchsucht den gesamten Vault; der Ausgabeordner wird ausgeschlossen.
+ - **Komprimierte Dateien verschieben**: Verschiebt komprimierte Ergebnisse an die Speicherorte der Originale. Zuvor werden sowohl die Originale als auch die komprimierten Versionen gesichert.
+- **Automatisierung**:
+ - Neue Dateien beim Hinzufügen automatisch komprimieren
+ - Hintergrundkomprimierung nach Benutzerinaktivität, sobald die Anzahl unkomprimierter Bilder den Schwellenwert erreicht
+- **Benutzeroberfläche und Komfort**:
+ - Kontextmenüs für Dateien und Ordner
+ - Anzeige der Speicherersparnis mit ausführlichem Tooltip
+ - Fortschrittsanzeige in der Statusleiste
+- **Sicherheit und Zuverlässigkeit**:
+ - Cache für verarbeitete Dateien mit Cache-Sicherungen
+ - Sicherungen vor dem Verschieben komprimierter Dateien mit automatischer Löschung
+
+### Unterstützte Formate
+- PNG (`imagequant`-WASM-Pipeline)
+- JPEG/JPG (`mozjpeg`-WASM-Pipeline)
+
+WebP, GIF, BMP, HEIC/HEIF und AVIF werden in dieser Version absichtlich übersprungen, weil das Plugin keine Encoder für diese Formate enthält.
+
+### Einstellungen
+
+| Einstellung | Beschreibung | Typ/Bereich | Standard |
+|---|---|---|---|
+| PNG-Qualität (Min.–Max.) | Qualitätsbereich für verlustbehaftete PNG-Quantisierung | 1-100 (z. B. `65-80`) | `65-80` |
+| JPEG-Qualität | Qualität der JPEG-Komprimierung | 1-95 | `85` |
+| Erlaubte Stammordner | Relative Pfade, in denen komprimiert werden darf. Leer = gesamter Vault | Liste von Zeichenfolgen | leer |
+| Ausgabeordner | Ordner für komprimierte Dateien | Zeichenfolge | `Compressed` |
+| Neue Dateien automatisch komprimieren | Neue Bilder beim Hinzufügen komprimieren | boolesch | `false` |
+| Hintergrundkomprimierung | Bei Inaktivität im Hintergrund komprimieren | boolesch | `true` |
+| Schwellenwert für Hintergrundkomprimierung | Anzahl unkomprimierter Bilder für den automatischen Start | 10-1000 | `50` |
+| Inaktivitätsschwelle | Minuten ohne Benutzereingabe vor dem Start | 1-60 Minuten | `2` |
+| Automatische Sicherungsaufbewahrung | Alte Sicherungen vor dem Verschieben automatisch löschen | boolesch | `false` |
+| Sicherungen aufbewahren, Tage | Verschiebesicherungen löschen, die älter als N Tage sind | 1-365 | `30` |
+| Komprimierte Dateien automatisch verschieben | Dateien beim Start an die Speicherorte der Originalbilder verschieben und diese ersetzen | boolesch | `false` |
+| Schwellenwert für automatisches Verschieben | Anzahl verschiebebereiter Dateien, die das automatische Verschieben auslöst | 1-1000 | `50` |
+
+
+### Funktionsweise
+1. Komprimierte Dateien werden unter Beibehaltung der ursprünglichen Pfadstruktur im Ordner `Compressed` gespeichert.
+2. Der Cache erfasst verarbeitete Dateien und ihre ursprünglichen Größen, um erneute Komprimierung zu vermeiden und die Ersparnis korrekt zu berechnen.
+3. „Komprimierte Dateien verschieben“ verschiebt Dateien aus `Compressed` an ihre ursprünglichen Speicherorte, wenn das Original innerhalb eines erlaubten Stammordners liegt. Vor dem Verschieben wird eine Sicherung erstellt.
+
+Sehr kleine Dateien werden üblicherweise übersprungen (`<5KB` für PNG und `<10KB` für JPEG).
+
+Die Sicherheitsgrenzen sind fest: Dateien über `100 MB` werden vor dem Lesen übersprungen, Bilder mit mehr als `100 Millionen` Pixeln nach der Header-Prüfung.
+
+### Datenspeicherung und Sicherungen
+- **Primärer Cache:** wird im Plugin-Ordner gespeichert.
+- **Cache-Sicherungen:** werden unter `Vault/.local-image-compress/backups/cache/` gespeichert; bis zu 50 Dateien bleiben erhalten.
+- **Bildsicherungen:** werden unter `Vault/.local-image-compress/backups/originals/` gespeichert und vor dem Ersetzen der Originale erstellt.
+
+### Automatisierung
+- Wenn „Hintergrundkomprimierung“ aktiviert ist, werden zwei Schieberegler eingeblendet:
+ - Schwellenwert: 10–1000 Bilder, Standard 50.
+ - Inaktivität: 1–60 Minuten, Standard 2.
+- Wenn „Sicherungen aufbewahren, Tage“ aktiviert ist, wird der Regler für die Aufbewahrungsdauer angezeigt.
+- Wenn „Komprimierte Dateien automatisch verschieben“ aktiviert ist, wird der Dateischwellenwert angezeigt. Beim Start beginnt das Verschieben, sobald die Anzahl der Dateien in `Compressed` den Schwellenwert erreicht oder überschreitet.
+
+### Zusammenspiel mit Paste Image Rename
+
+Dieses Plugin deaktiviert das Drittanbieter-Plugin `obsidian-paste-image-rename` vorübergehend, während Dateien komprimiert oder verschoben werden. Dieser Schutz kann nicht abgeschaltet werden, weil die Zuordnung komprimierter Ausgaben zu den Originalen davon abhängt, dass neu erstellte Dateien nicht von einem anderen Plugin umbenannt werden.
+
+
+Warum dieser Schutz nötig ist
+
+Warum das erforderlich ist:
+
+- Paste Image Rename registriert einen `vault.on("create")`-Handler, der für jedes Bild ausgelöst wird, das innerhalb von ungefähr einer Sekunde nach seiner Erstellung zum Vault hinzugefügt wird. Er verarbeitet immer Dateien, deren Name mit `Pasted image ` beginnt, sowie alle anderen Bilder, wenn „Handle all attachments“ aktiviert ist.
+- Wenn dieses Plugin komprimierte Kopien in den Ausgabeordner schreibt, lösen diese neuen Dateien den Handler aus. Bei einer aktiven Markdown-Ansicht benennt Paste Image Rename entweder die gerade geschriebene Ausgabe um und zerstört damit die Zuordnung zum Original, oder zeigt für jede Datei einen Umbenennungsdialog. Ohne aktive Markdown-Ansicht erscheint für jede Datei `Error: No active file found`, wodurch die Oberfläche bei Stapelverarbeitung mit Fehlern überflutet wird.
+- Obsidian bietet keine öffentliche API, über die ein Plugin ein anderes pausieren kann. Die vorübergehende Deaktivierung genau dieses Plugins ist daher die einzige zuverlässige Lösung.
+
+So wird die Sicherheit gewährleistet:
+
+- Betroffen ist nur die bekannte Plugin-ID `obsidian-paste-image-rename` und nur während Komprimierungs- oder Verschiebevorgängen.
+- Das Plugin wird anschließend bei Bedarf mit Wiederholungsversuchen wiederhergestellt, außer sein Zustand wurde extern geändert. Der Schutz merkt sich, ob er das Plugin deaktiviert hat, und versucht nach einer externen Änderung keine Wiederherstellung.
+- Zum Aktivieren und Deaktivieren wird mangels öffentlicher Alternative die interne Obsidian-API `app.plugins` verwendet. Die Verfügbarkeit wird vor dem Aufruf geprüft; Fehler werden ohne Abbruch behandelt.
+
+
+
+### Datenschutz und externes Verhalten
+- **Netzwerk**: Das Plugin stellt zur Laufzeit keine Netzwerkanfragen. Die PNG/JPEG-Codecs sind in `main.js` enthalten; Bilder werden nicht hochgeladen.
+- **Telemetrie und Werbung**: Es gibt keine Analysen, Telemetrie, Absturzberichte, Nachverfolgung, dynamische Werbung oder Selbstaktualisierung.
+- **Konten und Zahlungen**: Konto, Abonnement, Lizenzschlüssel und Zahlung sind nicht erforderlich. Der optionale Funding-Link im Manifest wird vom Plugin nicht aufgerufen.
+- **Vault-Dateien**: Das Plugin liest unterstützte Bilder, die durch Befehle, Automatisierung oder erlaubte Stammordner ausgewählt wurden. Ergebnisse werden in einen konfigurierten Vault-relativen Ordner geschrieben; Originale werden erst nach Sicherung über den dokumentierten manuellen oder automatischen Verschiebeablauf ersetzt.
+- **Lokaler Zustand**: Cache-Daten liegen im Plugin-Ordner. Cache- und Verschiebesicherungen liegen unter `Vault/.local-image-compress/backups/`.
+- **Externe Dateien**: Verwaltete Daten bleiben im aktuellen Vault. „Ordner öffnen“ bittet nur das Betriebssystem, die dokumentierten Sicherungsordner anzuzeigen, und überträgt keine Daten.
+- **Andere Plugins**: `obsidian-paste-image-rename` kann wie oben beschrieben während Komprimierung oder Verschieben vorübergehend deaktiviert und anschließend mit Zustandsprüfung wiederhergestellt werden.
+
+### Tipps
+- Sinnvolle Qualitätsbereiche: PNG `65-80`, JPEG `75-90`.
+- Konfigurieren Sie „Erlaubte Stammordner“, wenn nur bestimmte Ordner wie `files/` oder `images/` komprimiert werden sollen.
+- Verwenden Sie die Hintergrundkomprimierung, wenn der Vault viele unkomprimierte Bilder enthält.
+
+### Häufige Fragen
+**Das Plugin meldet, dass die WebAssembly-Module nicht initialisiert werden konnten.**
+Laden Sie das Plugin neu. Wenn der Fehler erneut auftritt, nennen Sie im Fehlerbericht die Obsidian-Version, die Plattform und den Konsolenfehler.
+
+**Wo werden komprimierte Dateien gespeichert?**
+Standardmäßig unter `Compressed`. Verwenden Sie „Komprimierte Dateien verschieben“, um die Originale zu ersetzen.
+
+**Wie wird die Ersparnis berechnet?**
+Die Ersparnis ist exakt, wenn der Cache Original- und Ausgabegröße enthält. Für unkomprimierte PNG/JPEG-Dateien verwendet das Plugin konservative Schätzungen mit begrenzten Verhältnissen; aktuelle Größen komprimierter Dateien werden bei Bedarf vom Datenträger gelesen.
+
+### Lizenz
+GPL-3.0-or-later. Lizenzen und Hinweise von Drittanbietern: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.es.md b/assets/README.es.md
new file mode 100644
index 0000000..c5d748e
--- /dev/null
+++ b/assets/README.es.md
@@ -0,0 +1,131 @@
+# Local Image Compress
+
+Comprime archivos PNG y JPEG directamente en tu bóveda de Obsidian y en tu ordenador, sin servicios en la nube ni API. Reduce entre un 30 y un 70 % el espacio ocupado por las imágenes sin sacrificar la calidad.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Índice
+- [Funciones](#funciones)
+- [Formatos compatibles](#formatos-compatibles)
+- [Ajustes](#ajustes)
+- [Funcionamiento](#funcionamiento)
+- [Almacenamiento y copias de seguridad](#almacenamiento-y-copias-de-seguridad)
+- [Automatización](#automatización)
+- [Interacción con Paste Image Rename](#interacción-con-paste-image-rename)
+- [Privacidad y comportamiento externo](#privacidad-y-comportamiento-externo)
+- [Consejos](#consejos)
+- [Preguntas frecuentes](#preguntas-frecuentes)
+- [Licencia](#licencia)
+
+### Funciones
+- **Compresión local**: las imágenes PNG y JPEG se comprimen localmente.
+- **Comandos**:
+ - **Comprimir todas las imágenes de la nota**: procesa las imágenes referenciadas o utilizadas en la nota activa.
+ - **Comprimir todas las imágenes de una carpeta**: permite elegir una carpeta y comprime todas las imágenes compatibles que contiene, salvo la carpeta de salida.
+ - **Comprimir todas las imágenes de la bóveda**: recorre toda la bóveda, salvo la carpeta de salida.
+ - **Mover archivos comprimidos**: mueve los resultados comprimidos a las ubicaciones de los originales. Antes crea copias de seguridad de las versiones originales y comprimidas.
+- **Automatización**:
+ - Comprimir automáticamente los archivos nuevos al añadirlos
+ - Comprimir en segundo plano después de un periodo de inactividad cuando se alcanza el umbral de imágenes sin comprimir
+- **Interfaz y comodidad**:
+ - Menú contextual para archivos y carpetas
+ - Indicador del espacio ahorrado con información detallada
+ - Indicador de progreso en la barra de estado
+- **Seguridad y fiabilidad**:
+ - Caché de archivos procesados con copias de seguridad
+ - Copias de seguridad antes de mover archivos comprimidos, con eliminación automática
+
+### Formatos compatibles
+- PNG (canal WASM de `imagequant`)
+- JPEG/JPG (canal WASM de `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF y AVIF se omiten deliberadamente en esta versión porque el plugin no incluye codificadores para esos formatos.
+
+### Ajustes
+
+| Ajuste | Descripción | Tipo/rango | Predeterminado |
+|---|---|---|---|
+| Calidad PNG (mín.-máx.) | Rango de calidad para cuantización PNG con pérdida | 1-100 (p. ej., `65-80`) | `65-80` |
+| Calidad JPEG | Calidad de compresión JPEG | 1-95 | `85` |
+| Raíces permitidas | Rutas relativas donde se permite comprimir. Vacío = toda la bóveda | lista de cadenas | vacío |
+| Carpeta de salida | Carpeta donde se guardan los archivos comprimidos | cadena | `Compressed` |
+| Comprimir archivos nuevos automáticamente | Comprimir imágenes nuevas al añadirlas | booleano | `false` |
+| Compresión en segundo plano | Comprimir en segundo plano durante la inactividad | booleano | `true` |
+| Umbral de compresión en segundo plano | Número de imágenes sin comprimir necesario para iniciar automáticamente | 10-1000 | `50` |
+| Umbral de inactividad | Minutos sin actividad antes de iniciar la compresión en segundo plano | 1-60 minutos | `2` |
+| Retención automática de copias | Eliminar automáticamente copias antiguas previas al movimiento | booleano | `false` |
+| Conservar copias, días | Eliminar copias de movimiento con más de N días cuando la retención está activa | 1-365 | `30` |
+| Mover archivos comprimidos automáticamente | Mover al iniciar los archivos a las ubicaciones originales y reemplazarlos | booleano | `false` |
+| Umbral de movimiento automático | Número de archivos listos que activa el movimiento automático | 1-1000 | `50` |
+
+
+### Funcionamiento
+1. Los archivos comprimidos se guardan en `Compressed`, conservando la estructura de rutas original.
+2. La caché registra los archivos procesados y sus tamaños originales para evitar compresiones repetidas y calcular correctamente el ahorro.
+3. «Mover archivos comprimidos» traslada los archivos desde `Compressed` a sus ubicaciones originales cuando el original está dentro de una raíz permitida. Antes del movimiento se crea una copia de seguridad.
+
+Los archivos muy pequeños suelen omitirse (`<5KB` para PNG y `<10KB` para JPEG).
+
+Los límites de seguridad son fijos: los archivos mayores de `100 MB` se omiten antes de leerlos y las imágenes de más de `100 millones` de píxeles después de validar la cabecera.
+
+### Almacenamiento y copias de seguridad
+- **Caché principal:** se guarda en la carpeta del plugin.
+- **Copias de la caché:** se guardan en `Vault/.local-image-compress/backups/cache/`; se conservan hasta 50 archivos.
+- **Copias de imágenes:** se guardan en `Vault/.local-image-compress/backups/originals/` y se crean antes de reemplazar los originales.
+
+### Automatización
+- Al activar «Compresión en segundo plano» aparecen dos controles:
+ - Umbral de imágenes: 10–1000, valor predeterminado 50.
+ - Umbral de inactividad: 1–60 minutos, valor predeterminado 2.
+- Al activar «Conservar copias, días» aparece el control del periodo de retención.
+- Al activar «Mover archivos comprimidos automáticamente» aparece el umbral de archivos. Al iniciar, el movimiento comienza cuando la cantidad de archivos en `Compressed` alcanza o supera el umbral.
+
+### Interacción con Paste Image Rename
+
+Este plugin desactiva temporalmente el plugin externo `obsidian-paste-image-rename` mientras comprime o mueve archivos. Esta protección no se puede desactivar porque la asociación entre la salida comprimida y su original depende de que ningún otro plugin cambie el nombre de los archivos recién creados.
+
+
+Por qué se necesita esta protección
+
+Por qué es necesario:
+
+- Paste Image Rename registra un controlador `vault.on("create")` que se activa para cada imagen añadida a la bóveda aproximadamente durante el primer segundo desde su creación. Siempre procesa los archivos cuyo nombre comienza por `Pasted image ` y todas las demás imágenes si está activada la opción "Handle all attachments".
+- Cuando este plugin escribe copias comprimidas en la carpeta de salida, los archivos nuevos activan ese controlador. Con una vista Markdown activa, Paste Image Rename cambia el nombre de la salida recién escrita, rompe la asociación con el original, o muestra un diálogo de cambio de nombre para cada archivo. Sin una vista Markdown activa, muestra `Error: No active file found` por cada archivo y llena la interfaz de errores durante el procesamiento por lotes.
+- Obsidian no ofrece una API pública para que un plugin pida a otro que se pause. Desactivar temporalmente solo este plugin es, por tanto, la única solución fiable.
+
+Cómo se mantiene la seguridad:
+
+- Solo se modifica el plugin conocido con ID `obsidian-paste-image-rename` y únicamente durante operaciones de compresión o movimiento.
+- El plugin se restaura después, con reintentos cuando hacen falta, salvo que su estado haya cambiado externamente. La protección registra si lo desactivó y no intenta restaurarlo después de un cambio externo.
+- Para activarlo y desactivarlo se usa la API interna de Obsidian `app.plugins`, porque no existe una alternativa pública. Se comprueba su disponibilidad antes de llamar y los errores se gestionan sin interrumpir la operación.
+
+
+
+### Privacidad y comportamiento externo
+- **Red**: el plugin no realiza solicitudes de red en tiempo de ejecución. Los códecs PNG/JPEG están incluidos en `main.js`; las imágenes no se suben.
+- **Telemetría y publicidad**: no incluye análisis, telemetría, informes de fallos, seguimiento, publicidad dinámica ni actualización automática.
+- **Cuentas y pagos**: no se necesita cuenta, suscripción, clave de licencia ni pago. El plugin no accede al enlace opcional de financiación del manifest.
+- **Archivos de la bóveda**: el plugin lee imágenes compatibles seleccionadas por comandos, automatización o raíces permitidas. Escribe los resultados en una carpeta relativa a la bóveda y solo reemplaza originales mediante los flujos documentados de movimiento manual o automático después de crear copias.
+- **Estado local**: la caché se guarda en la carpeta del plugin. Las copias de caché y movimiento se guardan bajo `Vault/.local-image-compress/backups/`.
+- **Archivos externos**: los datos gestionados permanecen dentro de la bóveda actual. «Abrir carpeta» solo pide al sistema operativo que muestre las carpetas documentadas y no transmite datos.
+- **Otros plugins**: `obsidian-paste-image-rename` puede desactivarse temporalmente durante la compresión o el movimiento, como se describe arriba, y después se restaura comprobando el estado.
+
+### Consejos
+- Rangos de calidad razonables: PNG `65-80`, JPEG `75-90`.
+- Configura «Raíces permitidas» si solo quieres comprimir carpetas concretas, como `files/` o `images/`.
+- Utiliza la compresión en segundo plano cuando la bóveda contenga muchas imágenes sin comprimir.
+
+### Preguntas frecuentes
+**El plugin informa de que no se han podido inicializar los módulos WebAssembly.**
+Recarga el plugin. Si vuelve a ocurrir, incluye en el informe la versión de Obsidian, la plataforma y el error de consola.
+
+**¿Dónde se guardan los archivos comprimidos?**
+De forma predeterminada, en `Compressed`. Para reemplazar los originales, utiliza «Mover archivos comprimidos».
+
+**¿Cómo se calcula el ahorro?**
+El ahorro es exacto cuando la caché contiene los tamaños original y de salida. Para archivos PNG/JPEG sin comprimir, el plugin utiliza estimaciones conservadoras con proporciones limitadas; los tamaños actuales se leen del disco cuando es necesario.
+
+### Licencia
+GPL-3.0-or-later. Licencias y avisos de terceros: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.fa.md b/assets/README.fa.md
new file mode 100644
index 0000000..34b7e20
--- /dev/null
+++ b/assets/README.fa.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+فایلهای PNG و JPEG را مستقیماً روی رایانه و در مخزن Obsidian خود، بدون سرویس ابری یا API، فشرده کنید. فضای اشغالشده توسط تصاویر را بدون افت کیفیت ۳۰ تا ۷۰ درصد کاهش دهید.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### فهرست
+- [ویژگیها](#ویژگیها)
+- [قالبهای پشتیبانیشده](#قالبهای-پشتیبانیشده)
+- [تنظیمات](#تنظیمات)
+- [نحوه کار](#نحوه-کار)
+- [ذخیرهسازی داده و نسخههای پشتیبان](#ذخیرهسازی-داده-و-نسخههای-پشتیبان)
+- [خودکارسازی](#خودکارسازی)
+- [تعامل با Paste Image Rename](#تعامل-با-paste-image-rename)
+- [حریم خصوصی و رفتار خارجی](#حریم-خصوصی-و-رفتار-خارجی)
+- [نکتهها](#نکتهها)
+- [پرسشهای متداول](#پرسشهای-متداول)
+- [مجوز](#مجوز)
+
+### ویژگیها
+- **فشردهسازی محلی**: تصاویر PNG و JPEG بهصورت محلی فشرده میشوند.
+- **فرمانها**:
+ - **فشردهسازی همه تصاویر یادداشت**: تصاویر ارجاعشده یا استفادهشده در یادداشت فعال را پردازش میکند.
+ - **فشردهسازی همه تصاویر پوشه**: امکان انتخاب پوشه را میدهد و همه تصاویر پشتیبانیشده در آن را، بهجز پوشه خروجی، فشرده میکند.
+ - **فشردهسازی همه تصاویر مخزن**: کل مخزن را بهجز پوشه خروجی اسکن میکند.
+ - **انتقال فایلهای فشرده**: خروجیهای فشرده را به محل فایلهای اصلی منتقل میکند. پیش از انتقال، از نسخه اصلی و فشرده پشتیبان میگیرد.
+- **خودکارسازی**:
+ - فشردهسازی خودکار فایلهای جدید هنگام افزودهشدن
+ - فشردهسازی پسزمینه پس از بیفعالیتی کاربر، وقتی تعداد تصاویر فشردهنشده به آستانه برسد
+- **رابط کاربری و راحتی**:
+ - منوی زمینه برای فایلها و پوشهها
+ - نمایشگر فضای ذخیرهشده با راهنمای جزئیات
+ - نمایشگر پیشرفت در نوار وضعیت
+- **ایمنی و اطمینان**:
+ - کش فایلهای پردازششده همراه با نسخههای پشتیبان کش
+ - تهیه نسخه پشتیبان پیش از انتقال فایلهای فشرده، با حذف خودکار
+
+### قالبهای پشتیبانیشده
+- PNG (خط پردازش WASM مبتنی بر `imagequant`)
+- JPEG/JPG (خط پردازش WASM مبتنی بر `mozjpeg`)
+
+WebP، GIF، BMP، HEIC/HEIF و AVIF عمداً در این نسخه نادیده گرفته میشوند، زیرا افزونه رمزگذار این قالبها را در بر ندارد.
+
+### تنظیمات
+
+| تنظیم | توضیح | نوع/بازه | پیشفرض |
+|---|---|---|---|
+| کیفیت PNG (کمینه-بیشینه) | بازه کیفیت کوانتیزهسازی بااتلاف PNG | 1-100 (برای نمونه `65-80`) | `65-80` |
+| کیفیت JPEG | کیفیت فشردهسازی JPEG | 1-95 | `85` |
+| ریشههای مجاز | مسیرهای نسبی مجاز برای فشردهسازی؛ خالی = کل مخزن | فهرست رشتهها | خالی |
+| پوشه خروجی | پوشه ذخیره فایلهای فشرده | رشته | `Compressed` |
+| فشردهسازی خودکار فایلهای جدید | فشردهسازی تصاویر جدید هنگام افزودهشدن | بولی | `false` |
+| فشردهسازی پسزمینه | فشردهسازی در پسزمینه هنگام بیفعالیتی | بولی | `true` |
+| آستانه پسزمینه | تعداد تصاویر فشردهنشده لازم برای شروع خودکار فشردهسازی پسزمینه | 10-1000 | `50` |
+| آستانه بیفعالیتی | دقیقههای بدون فعالیت کاربر پیش از شروع فشردهسازی پسزمینه | 1-60 دقیقه | `2` |
+| نگهداری خودکار نسخه پشتیبان | حذف خودکار نسخههای قدیمی پیش از انتقال | بولی | `false` |
+| نگهداری نسخههای پشتیبان، روز | در صورت فعالبودن نگهداری خودکار، حذف نسخههای انتقال قدیمیتر از N روز | 1-365 | `30` |
+| انتقال خودکار فایلهای فشرده | انتقال فایلهای فشرده به محل تصاویر اصلی و جایگزینی آنها هنگام اجرا | بولی | `false` |
+| آستانه انتقال خودکار | تعداد فایلهای فشرده آماده انتقال که انتقال خودکار را آغاز میکند | 1-1000 | `50` |
+
+
+### نحوه کار
+1. فایلهای فشرده با حفظ ساختار مسیر اصلی در پوشه `Compressed` ذخیره میشوند.
+2. کش، فایلهای پردازششده و اندازه اصلی آنها را ثبت میکند تا از فشردهسازی تکراری جلوگیری شود و صرفهجویی درست محاسبه گردد.
+3. «انتقال فایلهای فشرده» فایلها را از `Compressed` به محل اصلی برمیگرداند، به شرط آنکه فایل اصلی در یکی از ریشههای مجاز باشد. پیش از انتقال نسخه پشتیبان ساخته میشود.
+
+حداقل اندازه برای فشردهسازی: فایلهای بسیار کوچک معمولاً نادیده گرفته میشوند (`<5KB` برای PNG و `<10KB` برای JPEG).
+
+محدودیتهای ایمنی ثابتاند: فایلهای بزرگتر از `100 MB` پیش از خواندن و تصاویر بیش از `100 میلیون` پیکسل پس از اعتبارسنجی سرآیند نادیده گرفته میشوند.
+
+### ذخیرهسازی داده و نسخههای پشتیبان
+- **کش اصلی:** در پوشه افزونه ذخیره میشود.
+- **نسخههای پشتیبان کش:** در `Vault/.local-image-compress/backups/cache/` ذخیره میشوند؛ حداکثر ۵۰ فایل نگهداری میشود.
+- **نسخههای پشتیبان تصاویر:** در `Vault/.local-image-compress/backups/originals/` ذخیره میشوند؛ پیش از جایگزینی فایلهای اصلی ساخته میشوند.
+
+### خودکارسازی
+- با فعالکردن «فشردهسازی پسزمینه» دو لغزنده در دسترس قرار میگیرد:
+ - آستانه فشردهسازی پسزمینه: 10–1000 تصویر، پیشفرض 50.
+ - آستانه بیفعالیتی: 1–60 دقیقه، پیشفرض 2.
+- با فعالکردن «نگهداری نسخههای پشتیبان، روز» لغزنده مدت نگهداری نمایش داده میشود.
+- با فعالکردن «انتقال خودکار فایلهای فشرده» آستانه تعداد فایل نمایش داده میشود. هنگام اجرا، اگر تعداد فایلهای `Compressed` برابر یا بیشتر از آستانه باشد، انتقال آغاز میشود.
+
+### تعامل با Paste Image Rename
+
+این افزونه هنگام فشردهسازی یا انتقال فایلها، افزونه شخص ثالث `obsidian-paste-image-rename` را موقتاً غیرفعال میکند. این محافظت قابل خاموشکردن نیست، زیرا نگاشت خروجی فشرده به فایل اصلی وابسته به این است که افزونه دیگری نام فایلهای تازهساختهشده را تغییر ندهد.
+
+
+چرا این محافظت لازم است
+
+دلیل نیاز:
+
+- Paste Image Rename یک گرداننده `vault.on("create")` ثبت میکند که برای هر تصویر افزودهشده به مخزن، حدود یک ثانیه پس از ایجاد، اجرا میشود. این افزونه همیشه فایلهایی را که نامشان با `Pasted image ` آغاز میشود پردازش میکند و در صورت فعالبودن گزینه «Handle all attachments»، روی همه تصاویر دیگر نیز عمل میکند.
+- وقتی این افزونه نسخههای فشرده را در پوشه خروجی مینویسد، فایلهای جدید آن گرداننده را فعال میکنند. در صورت وجود نمای Markdown فعال، Paste Image Rename یا نام خروجی جدید را تغییر میدهد و نگاشت لازم برای انتقال را میشکند، یا برای هر فایل گفتوگوی تغییر نام نشان میدهد. بدون نمای Markdown فعال، برای هر فایل پیام `Error: No active file found` نمایش میدهد و رابط کاربری را هنگام پردازش گروهی از خطا پر میکند.
+- Obsidian هیچ API عمومی برای درخواست توقف موقت یک افزونه توسط افزونه دیگر ندارد؛ بنابراین غیرفعالکردن موقت همین افزونه تنها راه قابل اعتماد است.
+
+نحوه مدیریت ایمن:
+
+- فقط شناسه شناختهشده `obsidian-paste-image-rename` و فقط هنگام فشردهسازی یا انتقال تحت تأثیر قرار میگیرد.
+- افزونه پس از پایان، در صورت نیاز با تلاش مجدد، بازیابی میشود؛ مگر آنکه وضعیتش از بیرون تغییر کند. محافظ ثبت میکند که آیا خودش افزونه را غیرفعال کرده است و پس از چنین تغییری برای بازیابی آن اقدام نمیکند.
+- فعال یا غیرفعالکردن افزونه از API داخلی `app.plugins` در Obsidian استفاده میکند، زیرا معادل عمومی وجود ندارد. وجود قابلیت پیش از فراخوانی بررسی میشود و خطاها بدون اختلال مدیریت میشوند.
+
+
+
+### حریم خصوصی و رفتار خارجی
+
+- **شبکه**: افزونه هنگام اجرا هیچ درخواست شبکهای نمیفرستد. کدکهای PNG/JPEG در `main.js` گنجانده شدهاند و تصاویر بارگذاری نمیشوند.
+- **تلهمتری و تبلیغات**: هیچ تحلیل آماری، تلهمتری، گزارش خرابی، ردیابی، تبلیغ پویا یا سازوکار بهروزرسانی خودکار وجود ندارد.
+- **حساب و پرداخت**: حساب، اشتراک، کلید مجوز یا پرداخت لازم نیست. افزونه هرگز به پیوند اختیاری حمایت مالی در manifest دسترسی پیدا نمیکند.
+- **فایلهای مخزن**: افزونه تصاویر پشتیبانیشدهای را میخواند که با فرمانها، خودکارسازی یا ریشههای مجاز انتخاب شدهاند. خروجی را در پوشه نسبی تنظیمشده داخل مخزن مینویسد و فقط از مسیر مستند انتقال یا انتقال خودکار، پس از تهیه نسخه پشتیبان، فایلهای اصلی را جایگزین میکند.
+- **وضعیت محلی**: داده کش در پوشه افزونه ذخیره میشود. نسخههای پشتیبان کش و انتقال زیر `Vault/.local-image-compress/backups/` نگهداری میشوند.
+- **فایلهای خارجی**: دادههای مدیریتشده توسط افزونه در مخزن فعلی میمانند. فرمانهای «بازکردن پوشه» فقط از سیستمعامل میخواهند پوشههای مستند را نمایش دهد و دادهای منتقل نمیکنند.
+- **افزونههای دیگر**: همانطور که بالاتر گفته شد، ممکن است `obsidian-paste-image-rename` هنگام فشردهسازی یا انتقال موقتاً غیرفعال شود و سپس با بررسی مالکیت بازیابی گردد.
+
+### نکتهها
+- بازههای کیفیت مناسب: PNG برابر `65-80` و JPEG برابر `75-90`.
+- اگر میخواهید فقط پوشههای مشخصی مانند `files/` یا `images/` فشرده شوند، «ریشههای مجاز» را تنظیم کنید.
+- برای مخزنهایی با تصاویر فشردهنشده فراوان از فشردهسازی پسزمینه استفاده کنید.
+
+### پرسشهای متداول
+**افزونه گزارش میدهد که ماژولهای WebAssembly راهاندازی نشدند.**
+افزونه را دوباره بارگذاری کنید. اگر خطا تکرار شد، نسخه Obsidian، پلتفرم و خطای کنسول را در گزارش اشکال بنویسید.
+
+**فایلهای فشرده کجا ذخیره میشوند؟**
+بهطور پیشفرض در `Compressed`. برای جایگزینی فایلهای اصلی از «انتقال فایلهای فشرده» استفاده کنید.
+
+**صرفهجویی چگونه محاسبه میشود؟**
+وقتی کش اندازه فایل اصلی و خروجی را داشته باشد، محاسبه دقیق است. برای فایلهای PNG/JPEG فشردهنشده، افزونه از برآوردهای محتاطانه با نسبتهای محدود استفاده میکند؛ اندازه فعلی فایلهای فشرده در صورت نیاز از دیسک خوانده میشود.
+
+### مجوز
+GPL-3.0-or-later. مجوزها و اطلاعیههای شخص ثالث: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.fr.md b/assets/README.fr.md
new file mode 100644
index 0000000..90ef840
--- /dev/null
+++ b/assets/README.fr.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Compressez les fichiers PNG et JPEG directement dans votre coffre Obsidian sur votre ordinateur, sans service cloud ni API. Réduisez de 30 à 70 % l’espace occupé par les images sans sacrifier la qualité.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Sommaire
+- [Fonctionnalités](#fonctionnalités)
+- [Formats pris en charge](#formats-pris-en-charge)
+- [Réglages](#réglages)
+- [Fonctionnement](#fonctionnement)
+- [Stockage des données et sauvegardes](#stockage-des-données-et-sauvegardes)
+- [Automatisation](#automatisation)
+- [Interaction avec Paste Image Rename](#interaction-avec-paste-image-rename)
+- [Confidentialité et comportement externe](#confidentialité-et-comportement-externe)
+- [Conseils](#conseils)
+- [Questions fréquentes](#questions-fréquentes)
+- [Licence](#licence)
+
+### Fonctionnalités
+- **Compression locale** : les images PNG et JPEG sont compressées localement.
+- **Commandes** :
+ - **Compresser toutes les images de la note** : traite les images référencées ou utilisées dans la note active.
+ - **Compresser toutes les images du dossier** : permet de sélectionner un dossier et compresse toutes ses images compatibles, sauf le dossier de sortie.
+ - **Compresser toutes les images du coffre** : analyse l’ensemble du coffre, sauf le dossier de sortie.
+ - **Déplacer les fichiers compressés** : déplace les résultats vers les emplacements d’origine. Une sauvegarde des versions originale et compressée est créée auparavant.
+- **Automatisation** :
+ - Compresser automatiquement les nouveaux fichiers lors de leur ajout
+ - Compresser en arrière-plan après une période d’inactivité lorsque le nombre d’images non compressées atteint le seuil
+- **Interface et commodité** :
+ - Menu contextuel pour les fichiers et dossiers
+ - Indicateur d’espace économisé avec infobulle détaillée
+ - Indicateur de progression dans la barre d’état
+- **Sécurité et fiabilité** :
+ - Cache des fichiers traités avec sauvegardes du cache
+ - Sauvegardes avant le déplacement des fichiers compressés, avec suppression automatique
+
+### Formats pris en charge
+- PNG (pipeline WASM `imagequant`)
+- JPEG/JPG (pipeline WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF et AVIF sont volontairement ignorés dans cette version, car le plugin n’inclut pas d’encodeurs pour ces formats.
+
+### Réglages
+
+| Réglage | Description | Type/plage | Valeur par défaut |
+|---|---|---|---|
+| Qualité PNG (min-max) | Plage de qualité pour la quantification PNG avec perte | 1-100 (par ex. `65-80`) | `65-80` |
+| Qualité JPEG | Qualité de compression JPEG | 1-95 | `85` |
+| Racines autorisées | Chemins relatifs où la compression est autorisée. Vide = coffre entier | liste de chaînes | vide |
+| Dossier de sortie | Dossier où sont enregistrés les fichiers compressés | chaîne | `Compressed` |
+| Compression automatique des nouveaux fichiers | Compresse les nouvelles images lors de leur ajout | booléen | `false` |
+| Compression en arrière-plan | Compresse en arrière-plan pendant l’inactivité | booléen | `true` |
+| Seuil d’arrière-plan | Nombre d’images non compressées requis pour lancer automatiquement la compression | 10-1000 | `50` |
+| Seuil d’inactivité | Minutes sans activité avant la compression en arrière-plan | 1-60 minutes | `2` |
+| Conservation automatique des sauvegardes | Supprime automatiquement les anciennes sauvegardes créées avant déplacement | booléen | `false` |
+| Conservation des sauvegardes, jours | Supprime les sauvegardes de déplacement de plus de N jours lorsque la conservation est activée | 1-365 | `30` |
+| Déplacement automatique des fichiers compressés | Remet au démarrage les fichiers compressés aux emplacements d’origine en les remplaçant | booléen | `false` |
+| Seuil de déplacement automatique | Nombre de fichiers prêts qui déclenche le déplacement automatique | 1-1000 | `50` |
+
+
+### Fonctionnement
+1. Les fichiers compressés sont enregistrés dans `Compressed` en conservant la structure des chemins d’origine.
+2. Le cache enregistre les fichiers traités et leur taille d’origine pour éviter les compressions répétées et calculer correctement les économies.
+3. « Déplacer les fichiers compressés » remet les fichiers de `Compressed` à leur emplacement d’origine si celui-ci se trouve dans une racine autorisée. Une sauvegarde est créée auparavant.
+
+Les très petits fichiers sont généralement ignorés (`<5KB` pour PNG et `<10KB` pour JPEG).
+
+Les limites de sécurité sont fixes : les fichiers de plus de `100 MB` sont ignorés avant lecture et les images de plus de `100 millions` de pixels après validation de leur en-tête.
+
+### Stockage des données et sauvegardes
+- **Cache principal :** stocké dans le dossier du plugin.
+- **Sauvegardes du cache :** stockées dans `Vault/.local-image-compress/backups/cache/` ; jusqu’à 50 fichiers sont conservés.
+- **Sauvegardes des images :** stockées dans `Vault/.local-image-compress/backups/originals/` ; créées avant le remplacement des originaux.
+
+### Automatisation
+- « Compression en arrière-plan » rend disponibles deux curseurs :
+ - Seuil de compression : 10–1000 images, 50 par défaut.
+ - Seuil d’inactivité : 1–60 minutes, 2 par défaut.
+- « Conservation des sauvegardes, jours » affiche le curseur de durée de conservation.
+- « Déplacement automatique des fichiers compressés » affiche le seuil du nombre de fichiers. Au démarrage, le déplacement commence lorsque le nombre de fichiers dans `Compressed` atteint ou dépasse ce seuil.
+
+### Interaction avec Paste Image Rename
+
+Ce plugin désactive temporairement `obsidian-paste-image-rename` pendant la compression ou le déplacement. Cette protection ne peut pas être désactivée : associer chaque sortie à son original exige qu’aucun autre plugin ne renomme les nouveaux fichiers.
+
+
+Pourquoi cette protection est nécessaire
+
+Pourquoi est-ce nécessaire :
+
+- Paste Image Rename enregistre un gestionnaire `vault.on("create")` exécuté pour chaque image ajoutée au coffre environ une seconde après sa création. Il traite toujours les noms commençant par `Pasted image ` et toutes les autres images si « Handle all attachments » est activé.
+- Les copies écrites dans le dossier de sortie déclenchent ce gestionnaire. Avec une vue Markdown active, il renomme la sortie et rompt l’association nécessaire au déplacement, ou affiche une boîte de dialogue par fichier. Sans vue active, il affiche `Error: No active file found` pour chaque fichier et inonde l’interface pendant les traitements par lots.
+- Obsidian ne fournit aucune API publique permettant à un plugin d’en mettre un autre en pause. La désactivation temporaire de ce seul plugin est la seule solution fiable.
+
+Gestion sécurisée :
+
+- Seul l’identifiant `obsidian-paste-image-rename` est concerné, uniquement pendant la compression ou le déplacement.
+- Le plugin est ensuite rétabli, avec de nouvelles tentatives si nécessaire, sauf si son état change de l’extérieur. La protection sait si elle l’a désactivé et ne le rétablit pas après un tel changement.
+- L’activation et la désactivation utilisent l’API interne `app.plugins`, faute d’équivalent public. La présence des fonctions est vérifiée et les erreurs sont gérées proprement.
+
+
+
+### Confidentialité et comportement externe
+
+- **Réseau** : aucune requête réseau à l’exécution. Les codecs PNG/JPEG sont intégrés à `main.js` ; les images ne sont pas téléversées.
+- **Télémétrie et publicité** : aucune analyse, télémétrie, remontée d’incident, mesure d’usage, publicité dynamique ni mise à jour automatique.
+- **Comptes et paiements** : aucun compte, abonnement, clé de licence ou paiement. Le lien de financement facultatif du manifeste n’est jamais ouvert par le plugin.
+- **Fichiers du coffre** : le plugin lit les images choisies par les commandes, l’automatisation ou les racines autorisées. Il écrit dans le dossier relatif configuré et ne remplace les originaux que par le déplacement manuel ou automatique documenté, après sauvegarde.
+- **État local** : le cache est dans le dossier du plugin ; les sauvegardes du cache et des déplacements sont sous `Vault/.local-image-compress/backups/`.
+- **Fichiers externes** : les données gérées restent dans le coffre actuel. « Ouvrir le dossier » demande seulement au système d’afficher les dossiers documentés et ne transmet rien.
+- **Autres plugins** : `obsidian-paste-image-rename` peut être désactivé temporairement comme décrit ci-dessus, puis rétabli avec vérification de la responsabilité du changement.
+
+### Conseils
+- Plages de qualité raisonnables : PNG `65-80`, JPEG `75-90`.
+- Configurez « Racines autorisées » pour limiter la compression à des dossiers comme `files/` ou `images/`.
+- Utilisez la compression en arrière-plan lorsque le coffre contient de nombreuses images non compressées.
+
+### Questions fréquentes
+**L’initialisation des modules WebAssembly a échoué.**
+Rechargez le plugin. Si l’erreur se reproduit, indiquez votre version d’Obsidian, votre plateforme et l’erreur de la console dans le rapport.
+
+**Où sont enregistrés les fichiers compressés ?**
+Dans `Compressed` par défaut. Pour remplacer les originaux, utilisez « Déplacer les fichiers compressés ».
+
+**Comment les économies sont-elles calculées ?**
+Le calcul est exact quand le cache contient les tailles d’origine et de sortie. Pour les PNG/JPEG non compressés, des estimations prudentes à ratios plafonnés sont utilisées ; la taille actuelle des fichiers compressés est lue sur le disque au besoin.
+
+### Licence
+GPL-3.0-or-later. Licences et mentions de tiers : [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.id.md b/assets/README.id.md
new file mode 100644
index 0000000..17d9946
--- /dev/null
+++ b/assets/README.id.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Kompres file PNG dan JPEG langsung di vault Obsidian pada komputer Anda, tanpa layanan cloud atau API. Kurangi ruang disk yang digunakan gambar sebesar 30–70% tanpa mengorbankan kualitas.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Daftar isi
+- [Fitur](#fitur)
+- [Format yang didukung](#format-yang-didukung)
+- [Pengaturan](#pengaturan)
+- [Cara kerja](#cara-kerja)
+- [Penyimpanan data dan cadangan](#penyimpanan-data-dan-cadangan)
+- [Otomatisasi](#otomatisasi)
+- [Interaksi dengan Paste Image Rename](#interaksi-dengan-paste-image-rename)
+- [Privasi dan perilaku eksternal](#privasi-dan-perilaku-eksternal)
+- [Kiat](#kiat)
+- [Pertanyaan umum](#pertanyaan-umum)
+- [Lisensi](#lisensi)
+
+### Fitur
+- **Kompresi lokal**: gambar PNG dan JPEG dikompres secara lokal.
+- **Perintah**:
+ - **Kompres semua gambar di catatan**: memproses gambar yang dirujuk atau digunakan dalam catatan aktif.
+ - **Kompres semua gambar di folder**: memungkinkan Anda memilih folder dan mengompres semua gambar yang didukung di dalamnya, kecuali folder keluaran.
+ - **Kompres semua gambar di vault**: memindai seluruh vault, kecuali folder keluaran.
+ - **Pindahkan file terkompresi**: memindahkan hasil kompresi ke lokasi file asli. Sebelumnya, versi asli dan terkompresi dicadangkan.
+- **Otomatisasi**:
+ - Kompres file baru secara otomatis saat ditambahkan
+ - Kompresi latar belakang setelah pengguna tidak aktif ketika jumlah gambar yang belum dikompres mencapai ambang
+- **Antarmuka dan kemudahan**:
+ - Menu konteks untuk file dan folder
+ - Indikator penghematan ruang dengan tooltip terperinci
+ - Indikator progres di bilah status
+- **Keamanan dan keandalan**:
+ - Cache file yang telah diproses beserta cadangannya
+ - Cadangan sebelum pemindahan file terkompresi, dengan penghapusan otomatis
+
+### Format yang didukung
+- PNG (pipeline WASM `imagequant`)
+- JPEG/JPG (pipeline WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF, dan AVIF sengaja dilewati dalam rilis ini karena plugin tidak menyertakan encoder untuk format tersebut.
+
+### Pengaturan
+
+| Pengaturan | Deskripsi | Jenis/rentang | Bawaan |
+|---|---|---|---|
+| Kualitas PNG (min-maks) | Rentang kualitas kuantisasi PNG lossy | 1-100 (mis. `65-80`) | `65-80` |
+| Kualitas JPEG | Kualitas kompresi JPEG | 1-95 | `85` |
+| Akar yang diizinkan | Jalur relatif tempat kompresi diizinkan. Kosong = seluruh vault | daftar string | kosong |
+| Folder keluaran | Folder tempat file terkompresi disimpan | string | `Compressed` |
+| Kompres otomatis file baru | Kompres gambar baru saat ditambahkan | boolean | `false` |
+| Kompresi latar belakang | Kompres di latar belakang saat tidak aktif | boolean | `true` |
+| Ambang latar belakang | Jumlah gambar belum terkompresi untuk memulai kompresi latar belakang otomatis | 10-1000 | `50` |
+| Ambang tidak aktif | Menit tanpa aktivitas sebelum kompresi latar belakang dimulai | 1-60 menit | `2` |
+| Retensi cadangan otomatis | Hapus otomatis cadangan lama sebelum pemindahan | boolean | `false` |
+| Simpan cadangan, hari | Hapus cadangan pemindahan yang lebih lama dari N hari saat retensi otomatis aktif | 1-365 | `30` |
+| Pindahkan file terkompresi otomatis | Pindahkan file kembali ke lokasi gambar asli saat mulai dan ganti file asli | boolean | `false` |
+| Ambang pindah otomatis | Jumlah file siap dipindahkan yang memicu pemindahan otomatis | 1-1000 | `50` |
+
+
+### Cara kerja
+1. File terkompresi disimpan dalam folder `Compressed` sambil mempertahankan struktur jalur asli.
+2. Cache mencatat file yang diproses dan ukuran aslinya untuk mencegah kompresi berulang dan menghitung penghematan dengan benar.
+3. “Pindahkan file terkompresi” mengembalikan file dari `Compressed` ke lokasi asal jika file asli berada dalam akar yang diizinkan. Cadangan dibuat sebelumnya.
+
+File yang sangat kecil biasanya dilewati (`<5KB` untuk PNG dan `<10KB` untuk JPEG).
+
+Batas keamanan internal bersifat tetap: file lebih besar dari `100 MB` dilewati sebelum dibaca, dan gambar di atas `100 juta` piksel dilewati setelah validasi header.
+
+### Penyimpanan data dan cadangan
+- **Cache utama:** disimpan dalam folder plugin.
+- **Cadangan cache:** disimpan di `Vault/.local-image-compress/backups/cache/`; maksimal 50 file dipertahankan.
+- **Cadangan gambar:** disimpan di `Vault/.local-image-compress/backups/originals/`; dibuat sebelum file asli diganti.
+
+### Otomatisasi
+- Mengaktifkan “Kompresi latar belakang” menyediakan dua slider:
+ - Ambang kompresi latar belakang: 10–1000 gambar, bawaan 50.
+ - Ambang tidak aktif: 1–60 menit, bawaan 2.
+- Mengaktifkan “Simpan cadangan, hari” menampilkan slider masa retensi.
+- Mengaktifkan “Pindahkan file terkompresi otomatis” menampilkan ambang jumlah file. Saat mulai, pemindahan berlangsung jika jumlah file dalam `Compressed` mencapai atau melebihi ambang.
+
+### Interaksi dengan Paste Image Rename
+
+Plugin ini menonaktifkan sementara plugin pihak ketiga `obsidian-paste-image-rename` selama kompresi atau pemindahan. Perlindungan ini tidak dapat dimatikan karena pemetaan hasil kompresi ke file asli bergantung pada file baru yang tidak diganti namanya oleh plugin lain.
+
+
+Mengapa perlindungan ini diperlukan
+
+Mengapa diperlukan:
+
+- Paste Image Rename mendaftarkan handler `vault.on("create")` yang berjalan untuk setiap gambar yang ditambahkan ke vault sekitar satu detik setelah dibuat. Handler selalu memproses nama yang diawali `Pasted image `, dan semua gambar lain jika “Handle all attachments” aktif.
+- Salinan terkompresi yang ditulis ke folder keluaran memicu handler tersebut. Dengan tampilan Markdown aktif, handler mengganti nama hasil dan merusak pemetaan untuk pemindahan, atau menampilkan dialog ganti nama bagi setiap file. Tanpa tampilan aktif, pesan `Error: No active file found` muncul bagi setiap file dan memenuhi antarmuka selama pemrosesan massal.
+- Obsidian tidak memiliki API publik agar satu plugin dapat menjeda plugin lain. Menonaktifkan sementara plugin ini saja merupakan satu-satunya solusi yang andal.
+
+Penanganan yang aman:
+
+- Hanya ID `obsidian-paste-image-rename` yang terpengaruh, dan hanya selama kompresi atau pemindahan.
+- Plugin dipulihkan setelahnya, dengan percobaan ulang bila perlu, kecuali statusnya berubah secara eksternal. Pelindung mencatat apakah ia yang menonaktifkan plugin dan tidak memulihkannya setelah perubahan semacam itu.
+- Pengaktifan dan penonaktifan memakai API internal Obsidian `app.plugins` karena tidak ada padanan publik. Ketersediaan fitur diperiksa dan galat ditangani dengan baik.
+
+
+
+### Privasi dan perilaku eksternal
+
+- **Jaringan**: plugin tidak membuat permintaan jaringan saat berjalan. Codec PNG/JPEG disertakan dalam `main.js`; gambar tidak diunggah.
+- **Telemetri dan iklan**: tidak ada analitik, telemetri, pelaporan crash, pelacakan, iklan dinamis, atau mekanisme pembaruan mandiri.
+- **Akun dan pembayaran**: tidak perlu akun, langganan, kunci lisensi, atau pembayaran. Tautan pendanaan opsional dalam manifest tidak pernah diakses plugin.
+- **File vault**: plugin membaca gambar yang dipilih oleh perintah, otomatisasi, atau akar yang diizinkan. Hasil ditulis ke folder relatif vault yang dikonfigurasi; file asli hanya diganti melalui alur pindah manual atau otomatis yang didokumentasikan setelah cadangan dibuat.
+- **Status lokal**: data cache disimpan dalam folder plugin. Cadangan cache dan pemindahan berada di bawah `Vault/.local-image-compress/backups/`.
+- **File eksternal**: data yang dikelola plugin tetap di vault saat ini. Tindakan “Buka folder” hanya meminta sistem operasi menampilkan folder cadangan yang didokumentasikan dan tidak mengirim data.
+- **Plugin lain**: `obsidian-paste-image-rename` dapat dinonaktifkan sementara seperti dijelaskan di atas, lalu dipulihkan dengan pemeriksaan kepemilikan perubahan.
+
+### Kiat
+- Rentang kualitas yang wajar: PNG `65-80`, JPEG `75-90`.
+- Atur “Akar yang diizinkan” jika hanya ingin mengompres folder tertentu, seperti `files/` atau `images/`.
+- Gunakan kompresi latar belakang ketika vault memiliki banyak gambar yang belum dikompres.
+
+### Pertanyaan umum
+**Plugin melaporkan bahwa modul WebAssembly gagal diinisialisasi.**
+Muat ulang plugin. Jika galat berulang, sertakan versi Obsidian, platform, dan galat konsol dalam laporan bug.
+
+**Di mana file terkompresi disimpan?**
+Secara bawaan di `Compressed`. Untuk mengganti file asli, gunakan “Pindahkan file terkompresi”.
+
+**Bagaimana penghematan dihitung?**
+Perhitungan tepat jika cache memiliki ukuran asli dan keluaran. Untuk PNG/JPEG yang belum dikompres, plugin memakai perkiraan konservatif dengan rasio terbatas; ukuran file terkompresi saat ini dibaca dari disk bila perlu.
+
+### Lisensi
+GPL-3.0-or-later. Lisensi dan pemberitahuan pihak ketiga: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.it.md b/assets/README.it.md
new file mode 100644
index 0000000..8cb731e
--- /dev/null
+++ b/assets/README.it.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Comprimi i file PNG e JPEG direttamente nel tuo vault Obsidian sul computer, senza servizi cloud o API. Riduci del 30–70% lo spazio occupato dalle immagini senza sacrificare la qualità.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Indice
+- [Funzionalità](#funzionalità)
+- [Formati supportati](#formati-supportati)
+- [Impostazioni](#impostazioni)
+- [Come funziona](#come-funziona)
+- [Archiviazione dei dati e backup](#archiviazione-dei-dati-e-backup)
+- [Automazione](#automazione)
+- [Interazione con Paste Image Rename](#interazione-con-paste-image-rename)
+- [Privacy e comportamento esterno](#privacy-e-comportamento-esterno)
+- [Suggerimenti](#suggerimenti)
+- [Domande frequenti](#domande-frequenti)
+- [Licenza](#licenza)
+
+### Funzionalità
+- **Compressione locale**: le immagini PNG e JPEG vengono compresse localmente.
+- **Comandi**:
+ - **Comprimi tutte le immagini nella nota**: elabora le immagini citate o usate nella nota attiva.
+ - **Comprimi tutte le immagini nella cartella**: consente di scegliere una cartella e comprime tutte le immagini supportate al suo interno, esclusa la cartella di output.
+ - **Comprimi tutte le immagini nel vault**: analizza l’intero vault, esclusa la cartella di output.
+ - **Sposta i file compressi**: sposta i risultati nelle posizioni dei file originali. Prima dello spostamento crea un backup sia della versione originale sia di quella compressa.
+- **Automazione**:
+ - Comprimi automaticamente i nuovi file quando vengono aggiunti
+ - Compressione in background dopo l’inattività, quando le immagini non compresse raggiungono la soglia
+- **Interfaccia e praticità**:
+ - Menu contestuale per file e cartelle
+ - Indicatore dello spazio risparmiato con descrizione dettagliata
+ - Indicatore di avanzamento nella barra di stato
+- **Sicurezza e affidabilità**:
+ - Cache dei file elaborati con relativi backup
+ - Backup prima dello spostamento dei file compressi, con eliminazione automatica
+
+### Formati supportati
+- PNG (pipeline WASM `imagequant`)
+- JPEG/JPG (pipeline WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF e AVIF vengono intenzionalmente ignorati in questa versione, perché il plugin non include encoder per tali formati.
+
+### Impostazioni
+
+| Impostazione | Descrizione | Tipo/intervallo | Predefinito |
+|---|---|---|---|
+| Qualità PNG (min-max) | Intervallo di qualità per la quantizzazione PNG con perdita | 1-100 (es. `65-80`) | `65-80` |
+| Qualità JPEG | Qualità della compressione JPEG | 1-95 | `85` |
+| Radici consentite | Percorsi relativi in cui è consentita la compressione. Vuoto = intero vault | elenco di stringhe | vuoto |
+| Cartella di output | Cartella in cui vengono salvati i file compressi | stringa | `Compressed` |
+| Comprimi automaticamente i nuovi file | Comprimi le nuove immagini quando vengono aggiunte | booleano | `false` |
+| Compressione in background | Comprimi in background durante l’inattività | booleano | `true` |
+| Soglia background | Numero di immagini non compresse richiesto per avviare automaticamente la compressione | 10-1000 | `50` |
+| Soglia di inattività | Minuti senza attività prima di avviare la compressione in background | 1-60 minuti | `2` |
+| Conservazione automatica dei backup | Elimina automaticamente i vecchi backup precedenti allo spostamento | booleano | `false` |
+| Conserva i backup, giorni | Elimina i backup di spostamento più vecchi di N giorni quando la conservazione è attiva | 1-365 | `30` |
+| Sposta automaticamente i file compressi | Riporta all’avvio i file compressi nelle posizioni originali sostituendoli | booleano | `false` |
+| Soglia di spostamento automatico | Numero di file pronti che attiva lo spostamento automatico | 1-1000 | `50` |
+
+
+### Come funziona
+1. I file compressi vengono salvati in `Compressed` mantenendo la struttura dei percorsi originali.
+2. La cache registra i file elaborati e le dimensioni originali per evitare compressioni ripetute e calcolare correttamente il risparmio.
+3. «Sposta i file compressi» riporta i file da `Compressed` alle posizioni originali se l’originale si trova in una radice consentita. Prima viene creato un backup.
+
+I file molto piccoli vengono normalmente ignorati (`<5KB` per PNG e `<10KB` per JPEG).
+
+I limiti di sicurezza interni sono fissi: i file oltre `100 MB` vengono ignorati prima della lettura e le immagini oltre `100 milioni` di pixel dopo la convalida dell’intestazione.
+
+### Archiviazione dei dati e backup
+- **Cache principale:** memorizzata nella cartella del plugin.
+- **Backup della cache:** memorizzati in `Vault/.local-image-compress/backups/cache/`; vengono conservati al massimo 50 file.
+- **Backup delle immagini:** memorizzati in `Vault/.local-image-compress/backups/originals/`; creati prima della sostituzione degli originali.
+
+### Automazione
+- Attivando «Compressione in background» diventano disponibili due cursori:
+ - Soglia di compressione in background: 10–1000 immagini, predefinita 50.
+ - Soglia di inattività: 1–60 minuti, predefinita 2.
+- Attivando «Conserva i backup, giorni» compare il cursore del periodo di conservazione.
+- Attivando «Sposta automaticamente i file compressi» compare la soglia del numero di file. All’avvio, lo spostamento inizia quando i file in `Compressed` raggiungono o superano la soglia.
+
+### Interazione con Paste Image Rename
+
+Durante la compressione o lo spostamento, questo plugin disattiva temporaneamente il plugin di terze parti `obsidian-paste-image-rename`. La protezione non può essere disattivata: l’associazione tra output compresso e originale richiede che un altro plugin non rinomini i file appena creati.
+
+
+Perché questa protezione è necessaria
+
+Perché è necessaria:
+
+- Paste Image Rename registra un gestore `vault.on("create")` che viene eseguito per ogni immagine aggiunta al vault circa un secondo dopo la creazione. Interviene sempre sui nomi che iniziano con `Pasted image ` e su tutte le altre immagini se «Handle all attachments» è attivo.
+- Le copie compresse scritte nella cartella di output attivano il gestore. Con una vista Markdown attiva, esso rinomina l’output rompendo l’associazione usata dallo spostamento, oppure mostra una finestra di rinomina per ogni file. Senza una vista attiva, mostra `Error: No active file found` per ogni file e riempie l’interfaccia di errori durante l’elaborazione in blocco.
+- Obsidian non offre un’API pubblica che consenta a un plugin di sospenderne un altro. Disattivare temporaneamente questo solo plugin è l’unica soluzione affidabile.
+
+Gestione sicura:
+
+- È interessato soltanto l’ID noto `obsidian-paste-image-rename`, e soltanto durante compressione o spostamento.
+- Il plugin viene ripristinato, ritentando se necessario, salvo modifiche esterne al suo stato. La protezione registra se è stata lei a disattivarlo e non tenta il ripristino dopo una modifica esterna.
+- Attivazione e disattivazione usano l’API interna `app.plugins` di Obsidian perché non esiste un equivalente pubblico. Le funzioni vengono verificate prima dell’uso e gli errori sono gestiti correttamente.
+
+
+
+### Privacy e comportamento esterno
+
+- **Rete**: il plugin non effettua richieste di rete durante l’esecuzione. I codec PNG/JPEG sono inclusi in `main.js`; le immagini non vengono caricate.
+- **Telemetria e pubblicità**: non sono presenti analisi, telemetria, segnalazione degli arresti anomali, tracciamento, pubblicità dinamica o aggiornamento automatico.
+- **Account e pagamenti**: non servono account, abbonamenti, chiavi di licenza o pagamenti. Il plugin non accede mai al collegamento facoltativo per le donazioni nel manifest.
+- **File del vault**: il plugin legge le immagini scelte da comandi, automazione o radici consentite. Scrive l’output nella cartella relativa configurata e sostituisce gli originali solo tramite lo spostamento manuale o automatico documentato, dopo aver creato i backup.
+- **Stato locale**: i dati della cache sono nella cartella del plugin. I backup della cache e degli spostamenti sono in `Vault/.local-image-compress/backups/`.
+- **File esterni**: i dati gestiti restano nel vault corrente. «Apri cartella» chiede solo al sistema operativo di mostrare le cartelle documentate e non trasmette dati.
+- **Altri plugin**: `obsidian-paste-image-rename` può essere disattivato temporaneamente come descritto sopra, quindi ripristinato verificando chi ne ha modificato lo stato.
+
+### Suggerimenti
+- Intervalli di qualità ragionevoli: PNG `65-80`, JPEG `75-90`.
+- Configura «Radici consentite» per comprimere soltanto cartelle specifiche, come `files/` o `images/`.
+- Usa la compressione in background quando il vault contiene molte immagini non compresse.
+
+### Domande frequenti
+**Il plugin segnala che non è stato possibile inizializzare i moduli WebAssembly.**
+Ricarica il plugin. Se l’errore ricompare, includi nel rapporto la versione di Obsidian, la piattaforma e l’errore della console.
+
+**Dove vengono salvati i file compressi?**
+In `Compressed` per impostazione predefinita. Per sostituire gli originali usa «Sposta i file compressi».
+
+**Come viene calcolato il risparmio?**
+Il calcolo è esatto quando la cache contiene le dimensioni originali e finali. Per PNG/JPEG non compressi vengono usate stime conservative con rapporti limitati; le dimensioni attuali dei file compressi vengono lette dal disco quando necessario.
+
+### Licenza
+GPL-3.0-or-later. Licenze e avvisi di terze parti: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.ja.md b/assets/README.ja.md
new file mode 100644
index 0000000..61db933
--- /dev/null
+++ b/assets/README.ja.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+クラウドサービスや API を使わず、コンピューター上の Obsidian Vault 内で PNG と JPEG ファイルを直接圧縮します。品質を損なわずに、画像が占めるディスク容量を 30~70% 削減できます。
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### 目次
+- [機能](#機能)
+- [対応形式](#対応形式)
+- [設定](#設定)
+- [仕組み](#仕組み)
+- [データ保存とバックアップ](#データ保存とバックアップ)
+- [自動化](#自動化)
+- [Paste Image Rename との連携](#paste-image-rename-との連携)
+- [プライバシーと外部動作](#プライバシーと外部動作)
+- [ヒント](#ヒント)
+- [よくある質問](#よくある質問)
+- [ライセンス](#ライセンス)
+
+### 機能
+- **ローカル圧縮**: PNG と JPEG 画像をローカルで圧縮します。
+- **コマンド**:
+ - **ノート内の全画像を圧縮**: アクティブなノートから参照または使用されている画像を処理します。
+ - **フォルダー内の全画像を圧縮**: 選択したフォルダー内の対応画像を、出力フォルダーを除いて圧縮します。
+ - **Vault 内の全画像を圧縮**: 出力フォルダーを除く Vault 全体をスキャンします。
+ - **圧縮済みファイルを移動**: 圧縮結果を元ファイルの場所へ移動します。移動前に元版と圧縮版の両方をバックアップします。
+- **自動化**:
+ - 新しいファイルが追加されたときに自動圧縮
+ - 未圧縮画像数がしきい値に達し、ユーザーが非アクティブになった後にバックグラウンド圧縮
+- **UI と利便性**:
+ - ファイルとフォルダーのコンテキストメニュー
+ - 詳細なツールチップ付きの削減容量表示
+ - ステータスバーの進行状況表示
+- **安全性と信頼性**:
+ - 処理済みファイルのキャッシュとキャッシュバックアップ
+ - 圧縮済みファイルを移動する前のバックアップと自動削除
+
+### 対応形式
+- PNG(`imagequant` WASM パイプライン)
+- JPEG/JPG(`mozjpeg` WASM パイプライン)
+
+WebP、GIF、BMP、HEIC/HEIF、AVIF は、このリリースにエンコーダーが含まれていないため意図的にスキップされます。
+
+### 設定
+
+| 設定 | 説明 | 型/範囲 | 既定値 |
+|---|---|---|---|
+| PNG 品質(最小-最大) | 非可逆 PNG 量子化の品質範囲 | 1-100(例: `65-80`) | `65-80` |
+| JPEG 品質 | JPEG 圧縮品質 | 1-95 | `85` |
+| 許可ルート | 圧縮を許可する相対パス。空 = Vault 全体 | 文字列の一覧 | 空 |
+| 出力フォルダー | 圧縮済みファイルの保存先 | 文字列 | `Compressed` |
+| 新規ファイルを自動圧縮 | 追加された新しい画像を圧縮 | 真偽値 | `false` |
+| バックグラウンド圧縮 | 非アクティブ時にバックグラウンドで圧縮 | 真偽値 | `true` |
+| バックグラウンドしきい値 | 自動開始に必要な未圧縮画像数 | 10-1000 | `50` |
+| 非アクティブしきい値 | 開始前にユーザー操作がない時間 | 1-60 分 | `2` |
+| バックアップを自動整理 | 移動前に作られた古いバックアップを自動削除 | 真偽値 | `false` |
+| バックアップ保持日数 | 自動整理が有効な場合に N 日より古い移動バックアップを削除 | 1-365 | `30` |
+| 圧縮済みファイルを自動移動 | 起動時に元の画像位置へ戻して原本を置換 | 真偽値 | `false` |
+| 自動移動しきい値 | 自動移動を開始する準備済みファイル数 | 1-1000 | `50` |
+
+
+### 仕組み
+1. 圧縮済みファイルは元のパス構造を維持して `Compressed` フォルダーに保存されます。
+2. キャッシュは処理済みファイルと元のサイズを記録し、重複圧縮を防いで削減量を正しく計算します。
+3. 「圧縮済みファイルを移動」は、元ファイルが許可ルート内にある場合、`Compressed` から元の場所へ戻します。移動前にバックアップを作成します。
+
+非常に小さいファイルは通常スキップされます(PNG は `<5KB`、JPEG は `<10KB`)。
+
+安全上の上限は固定されています。`100 MB` を超えるファイルは読み込み前に、`1億` ピクセルを超える画像はヘッダー検証後にスキップされます。
+
+### データ保存とバックアップ
+- **メインキャッシュ:** プラグインフォルダーに保存されます。
+- **キャッシュバックアップ:** `Vault/.local-image-compress/backups/cache/` に保存され、最大 50 ファイルが保持されます。
+- **画像バックアップ:** `Vault/.local-image-compress/backups/originals/` に保存され、原本の置換前に作成されます。
+
+### 自動化
+- 「バックグラウンド圧縮」を有効にすると 2 つのスライダーが表示されます。
+ - バックグラウンド圧縮しきい値: 10~1000 画像、既定値 50。
+ - 非アクティブしきい値: 1~60 分、既定値 2。
+- 「バックアップ保持日数」を有効にすると保持期間スライダーが表示されます。
+- 「圧縮済みファイルを自動移動」を有効にするとファイル数しきい値が表示されます。起動時に `Compressed` 内のファイル数がしきい値以上なら移動を開始します。
+
+### Paste Image Rename との連携
+
+このプラグインは圧縮または移動中、サードパーティ製の `obsidian-paste-image-rename` を一時的に無効化します。この保護は解除できません。圧縮出力と原本の対応付けには、新規ファイルが別のプラグインに改名されないことが必要だからです。
+
+
+この保護が必要な理由
+
+必要な理由:
+
+- Paste Image Rename は `vault.on("create")` ハンドラーを登録し、Vault に追加された各画像に対して作成から約 1 秒後に実行します。`Pasted image ` で始まる名前には常に作用し、「Handle all attachments」が有効なら他の全画像にも作用します。
+- 出力フォルダーに圧縮コピーを書き込むと、このハンドラーが動作します。アクティブな Markdown ビューがある場合、出力を改名して移動に必要な対応付けを壊すか、ファイルごとに改名ダイアログを表示します。アクティブなビューがなければ、作成ファイルごとに `Error: No active file found` を表示し、一括処理中の画面をエラーで埋めます。
+- Obsidian には、あるプラグインが別のプラグインを一時停止するための公開 API がありません。そのため、このプラグインだけを一時的に無効化する方法が唯一確実です。
+
+安全な処理:
+
+- 対象は既知の ID `obsidian-paste-image-rename` のみで、圧縮または移動中だけです。
+- 必要なら再試行して処理後に復元しますが、状態が外部から変えられた場合は復元しません。保護機構は自ら無効化したかを記録し、そのような外部変更後には復元を試みません。
+- 有効化と無効化には公開の代替手段がないため Obsidian の内部 API `app.plugins` を使います。機能の存在を確認し、エラーは適切に処理します。
+
+
+
+### プライバシーと外部動作
+
+- **ネットワーク**: 実行時のネットワーク要求はありません。PNG/JPEG コーデックは `main.js` に内蔵され、画像はアップロードされません。
+- **テレメトリと広告**: 分析、テレメトリ、クラッシュ報告、追跡、動的広告、自動更新機構はありません。
+- **アカウントと支払い**: アカウント、サブスクリプション、ライセンスキー、支払いは不要です。manifest の任意の支援リンクにはアクセスしません。
+- **Vault ファイル**: コマンド、自動化、許可ルートで選ばれた対応画像を読みます。設定した Vault 相対フォルダーへ出力し、バックアップ後の文書化された移動または自動移動でのみ原本を置換します。
+- **ローカル状態**: キャッシュはプラグインフォルダーに保存されます。キャッシュと移動のバックアップは `Vault/.local-image-compress/backups/` 以下に保存されます。
+- **外部ファイル**: 管理対象データは現在の Vault 内に残ります。「フォルダーを開く」は OS に文書化されたバックアップフォルダーを表示させるだけで、データを送信しません。
+- **他のプラグイン**: 前述のとおり `obsidian-paste-image-rename` を一時的に無効化し、変更主体を確認して復元することがあります。
+
+### ヒント
+- 妥当な品質範囲: PNG `65-80`、JPEG `75-90`。
+- `files/` や `images/` など特定のフォルダーだけを圧縮する場合は「許可ルート」を設定してください。
+- 未圧縮画像が多い Vault ではバックグラウンド圧縮を使用してください。
+
+### よくある質問
+**WebAssembly モジュールの初期化に失敗したと表示されます。**
+プラグインを再読み込みしてください。再発する場合は、Obsidian のバージョン、プラットフォーム、コンソールエラーを不具合報告に含めてください。
+
+**圧縮済みファイルはどこに保存されますか?**
+既定では `Compressed` です。原本を置換するには「圧縮済みファイルを移動」を使用してください。
+
+**削減量はどのように計算されますか?**
+キャッシュに元サイズと出力サイズがあれば正確です。未圧縮の PNG/JPEG には上限付きの保守的な比率で推定し、必要に応じて現在の圧縮済みファイルサイズをディスクから読み取ります。
+
+### ライセンス
+GPL-3.0-or-later. サードパーティのライセンスと通知: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.ko.md b/assets/README.ko.md
new file mode 100644
index 0000000..13d6b9d
--- /dev/null
+++ b/assets/README.ko.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+클라우드 서비스나 API 없이 컴퓨터의 Obsidian 보관함에서 PNG 및 JPEG 파일을 직접 압축합니다. 품질 저하 없이 이미지가 차지하는 디스크 공간을 30–70% 줄일 수 있습니다.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### 목차
+- [기능](#기능)
+- [지원 형식](#지원-형식)
+- [설정](#설정)
+- [작동 방식](#작동-방식)
+- [데이터 저장 및 백업](#데이터-저장-및-백업)
+- [자동화](#자동화)
+- [Paste Image Rename 연동](#paste-image-rename-연동)
+- [개인정보 보호 및 외부 동작](#개인정보-보호-및-외부-동작)
+- [팁](#팁)
+- [자주 묻는 질문](#자주-묻는-질문)
+- [라이선스](#라이선스)
+
+### 기능
+- **로컬 압축**: PNG 및 JPEG 이미지를 로컬에서 압축합니다.
+- **명령**:
+ - **노트의 모든 이미지 압축**: 활성 노트에서 참조하거나 사용하는 이미지를 처리합니다.
+ - **폴더의 모든 이미지 압축**: 폴더를 선택하고 출력 폴더를 제외한 내부의 모든 지원 이미지를 압축합니다.
+ - **보관함의 모든 이미지 압축**: 출력 폴더를 제외한 전체 보관함을 검사합니다.
+ - **압축 파일 이동**: 압축 결과를 원본 위치로 옮깁니다. 이동 전에 원본과 압축 버전을 모두 백업합니다.
+- **자동화**:
+ - 새 파일이 추가될 때 자동 압축
+ - 압축되지 않은 이미지 수가 임계값에 도달하고 사용자가 비활성 상태가 되면 백그라운드 압축
+- **UI 및 편의성**:
+ - 파일과 폴더용 컨텍스트 메뉴
+ - 상세 툴팁이 있는 절약 공간 표시
+ - 상태 표시줄 진행률
+- **안전성 및 신뢰성**:
+ - 처리된 파일 캐시와 캐시 백업
+ - 압축 파일 이동 전 백업 및 자동 삭제
+
+### 지원 형식
+- PNG(`imagequant` WASM 파이프라인)
+- JPEG/JPG(`mozjpeg` WASM 파이프라인)
+
+WebP, GIF, BMP, HEIC/HEIF 및 AVIF는 이 릴리스에 해당 인코더가 포함되지 않아 의도적으로 건너뜁니다.
+
+### 설정
+
+| 설정 | 설명 | 유형/범위 | 기본값 |
+|---|---|---|---|
+| PNG 품질(최소-최대) | 손실 PNG 양자화 품질 범위 | 1-100(예: `65-80`) | `65-80` |
+| JPEG 품질 | JPEG 압축 품질 | 1-95 | `85` |
+| 허용 루트 | 압축이 허용되는 상대 경로. 비어 있음 = 전체 보관함 | 문자열 목록 | 비어 있음 |
+| 출력 폴더 | 압축 파일 저장 폴더 | 문자열 | `Compressed` |
+| 새 파일 자동 압축 | 새 이미지가 추가될 때 압축 | 불리언 | `false` |
+| 백그라운드 압축 | 비활성 상태에서 백그라운드로 압축 | 불리언 | `true` |
+| 백그라운드 임계값 | 자동 시작에 필요한 미압축 이미지 수 | 10-1000 | `50` |
+| 비활성 임계값 | 백그라운드 압축 전 사용자 활동이 없는 시간 | 1-60분 | `2` |
+| 백업 자동 보존 | 이동 전에 만든 오래된 백업을 자동 삭제 | 불리언 | `false` |
+| 백업 보관 일수 | 자동 보존 사용 시 N일보다 오래된 이동 백업 삭제 | 1-365 | `30` |
+| 압축 파일 자동 이동 | 시작 시 원본 이미지 위치로 옮겨 원본 교체 | 불리언 | `false` |
+| 자동 이동 임계값 | 자동 이동을 시작하는 준비된 압축 파일 수 | 1-1000 | `50` |
+
+
+### 작동 방식
+1. 압축 파일은 원본 경로 구조를 유지하여 `Compressed` 폴더에 저장됩니다.
+2. 캐시는 처리한 파일과 원본 크기를 기록하여 반복 압축을 방지하고 절약량을 정확히 계산합니다.
+3. “압축 파일 이동”은 원본이 허용 루트 안에 있을 때 `Compressed`의 파일을 원래 위치로 되돌립니다. 이동 전에 백업을 만듭니다.
+
+매우 작은 파일은 일반적으로 건너뜁니다(PNG `<5KB`, JPEG `<10KB`).
+
+내부 안전 한도는 고정되어 있습니다. `100 MB`보다 큰 파일은 읽기 전에, `1억` 픽셀을 초과하는 이미지는 헤더 검증 후 건너뜁니다.
+
+### 데이터 저장 및 백업
+- **기본 캐시:** 플러그인 폴더에 저장됩니다.
+- **캐시 백업:** `Vault/.local-image-compress/backups/cache/`에 저장되며 최대 50개 파일을 유지합니다.
+- **이미지 백업:** `Vault/.local-image-compress/backups/originals/`에 저장되며 원본 교체 전에 생성됩니다.
+
+### 자동화
+- “백그라운드 압축”을 켜면 두 개의 슬라이더가 표시됩니다.
+ - 백그라운드 압축 임계값: 10–1000개 이미지, 기본값 50.
+ - 비활성 임계값: 1–60분, 기본값 2.
+- “백업 보관 일수”를 켜면 보존 기간 슬라이더가 표시됩니다.
+- “압축 파일 자동 이동”을 켜면 파일 수 임계값이 표시됩니다. 시작 시 `Compressed`의 파일 수가 임계값 이상이면 이동을 시작합니다.
+
+### Paste Image Rename 연동
+
+이 플러그인은 압축 또는 이동 중 타사 플러그인 `obsidian-paste-image-rename`을 일시적으로 비활성화합니다. 압축 출력과 원본을 연결하려면 새 파일 이름이 다른 플러그인에 의해 바뀌지 않아야 하므로 이 보호 기능은 끌 수 없습니다.
+
+
+이 보호가 필요한 이유
+
+필요한 이유:
+
+- Paste Image Rename은 보관함에 이미지가 추가될 때 생성 후 약 1초 안에 실행되는 `vault.on("create")` 핸들러를 등록합니다. 이름이 `Pasted image `로 시작하는 파일에는 항상 작동하며, “Handle all attachments”가 켜져 있으면 모든 이미지에 작동합니다.
+- 이 플러그인이 출력 폴더에 압축 사본을 쓰면 해당 핸들러가 실행됩니다. 활성 Markdown 보기가 있으면 출력 이름을 바꿔 이동에 필요한 연결을 깨뜨리거나 파일마다 이름 변경 대화 상자를 표시합니다. 활성 보기가 없으면 생성된 파일마다 `Error: No active file found` 알림을 표시하여 일괄 처리 중 인터페이스를 오류로 채웁니다.
+- Obsidian에는 한 플러그인이 다른 플러그인을 일시 중지하도록 요청할 수 있는 공개 API가 없습니다. 따라서 이 플러그인 하나만 일시적으로 비활성화하는 것이 유일하게 신뢰할 수 있는 방법입니다.
+
+안전한 처리:
+
+- 알려진 ID `obsidian-paste-image-rename`만 압축 또는 이동 중에 영향을 받습니다.
+- 필요한 경우 재시도하여 작업 후 복원하지만, 상태가 외부에서 변경되면 복원하지 않습니다. 보호기는 자신이 플러그인을 비활성화했는지 기록하며 그런 변경 후에는 복원을 시도하지 않습니다.
+- 공개 대안이 없으므로 활성화와 비활성화에 Obsidian 내부 `app.plugins` API를 사용합니다. 기능 유무를 확인하고 오류를 정상적으로 처리합니다.
+
+
+
+### 개인정보 보호 및 외부 동작
+
+- **네트워크**: 실행 중 네트워크 요청이 없습니다. PNG/JPEG 코덱은 `main.js`에 포함되며 이미지는 업로드되지 않습니다.
+- **원격 측정 및 광고**: 분석, 원격 측정, 충돌 보고, 추적, 동적 광고 또는 자동 업데이트 기능이 없습니다.
+- **계정 및 결제**: 계정, 구독, 라이선스 키 또는 결제가 필요 없습니다. manifest의 선택적 후원 링크에 플러그인이 접근하지 않습니다.
+- **보관함 파일**: 명령, 자동화 또는 허용 루트로 선택한 지원 이미지를 읽습니다. 설정된 보관함 상대 폴더에 출력하고, 백업을 만든 뒤 문서화된 이동 또는 자동 이동 절차로만 원본을 교체합니다.
+- **로컬 상태**: 캐시 데이터는 플러그인 폴더에 저장됩니다. 캐시 및 이동 백업은 `Vault/.local-image-compress/backups/` 아래에 저장됩니다.
+- **외부 파일**: 플러그인이 관리하는 데이터는 현재 보관함 안에 남습니다. “폴더 열기”는 운영 체제에 문서화된 백업 폴더 표시만 요청하며 데이터를 전송하지 않습니다.
+- **다른 플러그인**: 위 설명처럼 `obsidian-paste-image-rename`을 일시적으로 비활성화한 뒤 변경 주체를 확인하여 복원할 수 있습니다.
+
+### 팁
+- 적절한 품질 범위: PNG `65-80`, JPEG `75-90`.
+- `files/` 또는 `images/` 같은 특정 폴더만 압축하려면 “허용 루트”를 설정하세요.
+- 보관함에 미압축 이미지가 많다면 백그라운드 압축을 사용하세요.
+
+### 자주 묻는 질문
+**WebAssembly 모듈 초기화에 실패했다고 표시됩니다.**
+플러그인을 다시 로드하세요. 오류가 반복되면 Obsidian 버전, 플랫폼 및 콘솔 오류를 버그 보고서에 포함하세요.
+
+**압축 파일은 어디에 저장되나요?**
+기본적으로 `Compressed`에 저장됩니다. 원본을 교체하려면 “압축 파일 이동”을 사용하세요.
+
+**절약량은 어떻게 계산하나요?**
+캐시에 원본과 출력 크기가 있으면 정확합니다. 압축되지 않은 PNG/JPEG에는 상한이 있는 보수적 비율을 사용하며, 필요할 때 현재 압축 파일 크기를 디스크에서 읽습니다.
+
+### 라이선스
+GPL-3.0-or-later. 타사 라이선스 및 고지: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.nl.md b/assets/README.nl.md
new file mode 100644
index 0000000..daf95e2
--- /dev/null
+++ b/assets/README.nl.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Comprimeer PNG- en JPEG-bestanden rechtstreeks in je Obsidian-kluis op je computer, zonder cloudservices of API's. Verminder de schijfruimte voor afbeeldingen met 30–70% zonder kwaliteitsverlies.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Inhoud
+- [Functies](#functies)
+- [Ondersteunde indelingen](#ondersteunde-indelingen)
+- [Instellingen](#instellingen)
+- [Werking](#werking)
+- [Gegevensopslag en back-ups](#gegevensopslag-en-back-ups)
+- [Automatisering](#automatisering)
+- [Interactie met Paste Image Rename](#interactie-met-paste-image-rename)
+- [Privacy en extern gedrag](#privacy-en-extern-gedrag)
+- [Tips](#tips)
+- [Veelgestelde vragen](#veelgestelde-vragen)
+- [Licentie](#licentie)
+
+### Functies
+- **Lokale compressie**: PNG- en JPEG-afbeeldingen worden lokaal gecomprimeerd.
+- **Opdrachten**:
+ - **Alle afbeeldingen in notitie comprimeren**: verwerkt afbeeldingen waarnaar de actieve notitie verwijst of die daarin worden gebruikt.
+ - **Alle afbeeldingen in map comprimeren**: laat je een map kiezen en comprimeert alle ondersteunde afbeeldingen erin, behalve de uitvoermap.
+ - **Alle afbeeldingen in kluis comprimeren**: scant de hele kluis, behalve de uitvoermap.
+ - **Gecomprimeerde bestanden verplaatsen**: verplaatst resultaten naar de oorspronkelijke locaties. Vooraf worden zowel de originele als de gecomprimeerde versie geback-upt.
+- **Automatisering**:
+ - Nieuwe bestanden automatisch comprimeren zodra ze worden toegevoegd
+ - Achtergrondcompressie na inactiviteit wanneer het aantal ongecomprimeerde afbeeldingen de drempel bereikt
+- **Interface en gemak**:
+ - Contextmenu voor bestanden en mappen
+ - Indicator voor bespaarde ruimte met gedetailleerde tooltip
+ - Voortgangsindicator in de statusbalk
+- **Veiligheid en betrouwbaarheid**:
+ - Cache van verwerkte bestanden met cacheback-ups
+ - Back-ups vóór het verplaatsen van gecomprimeerde bestanden, met automatische verwijdering
+
+### Ondersteunde indelingen
+- PNG (`imagequant`-WASM-pijplijn)
+- JPEG/JPG (`mozjpeg`-WASM-pijplijn)
+
+WebP, GIF, BMP, HEIC/HEIF en AVIF worden in deze release bewust overgeslagen, omdat de plugin voor deze indelingen geen encoders bevat.
+
+### Instellingen
+
+| Instelling | Beschrijving | Type/bereik | Standaard |
+|---|---|---|---|
+| PNG-kwaliteit (min-max) | Kwaliteitsbereik voor PNG-kwantisatie met verlies | 1-100 (bijv. `65-80`) | `65-80` |
+| JPEG-kwaliteit | JPEG-compressiekwaliteit | 1-95 | `85` |
+| Toegestane hoofdmappen | Relatieve paden waar compressie is toegestaan. Leeg = hele kluis | lijst met tekenreeksen | leeg |
+| Uitvoermap | Map waarin gecomprimeerde bestanden worden opgeslagen | tekenreeks | `Compressed` |
+| Nieuwe bestanden automatisch comprimeren | Nieuwe afbeeldingen comprimeren zodra ze worden toegevoegd | booleaans | `false` |
+| Achtergrondcompressie | Comprimeren op de achtergrond tijdens inactiviteit | booleaans | `true` |
+| Achtergronddrempel | Aantal ongecomprimeerde afbeeldingen dat automatische compressie start | 10-1000 | `50` |
+| Inactiviteitsdrempel | Minuten zonder activiteit voordat achtergrondcompressie start | 1-60 minuten | `2` |
+| Automatische back-upbewaring | Oude back-ups van vóór het verplaatsen automatisch verwijderen | booleaans | `false` |
+| Back-ups bewaren, dagen | Verplaatsingsback-ups ouder dan N dagen verwijderen wanneer bewaring actief is | 1-365 | `30` |
+| Gecomprimeerde bestanden automatisch verplaatsen | Bestanden bij starten terugzetten op de oorspronkelijke locatie en originelen vervangen | booleaans | `false` |
+| Drempel voor automatisch verplaatsen | Aantal gereedstaande bestanden dat automatisch verplaatsen start | 1-1000 | `50` |
+
+
+### Werking
+1. Gecomprimeerde bestanden worden in `Compressed` opgeslagen met behoud van de oorspronkelijke padstructuur.
+2. De cache registreert verwerkte bestanden en oorspronkelijke groottes om herhaalde compressie te voorkomen en de besparing juist te berekenen.
+3. 'Gecomprimeerde bestanden verplaatsen' zet bestanden vanuit `Compressed` terug als het origineel binnen een toegestane hoofdmap staat. Vooraf wordt een back-up gemaakt.
+
+Zeer kleine bestanden worden meestal overgeslagen (`<5KB` voor PNG en `<10KB` voor JPEG).
+
+De interne veiligheidslimieten staan vast: bestanden groter dan `100 MB` worden vóór het lezen overgeslagen en afbeeldingen boven `100 miljoen` pixels na validatie van de header.
+
+### Gegevensopslag en back-ups
+- **Primaire cache:** opgeslagen in de pluginmap.
+- **Cacheback-ups:** opgeslagen in `Vault/.local-image-compress/backups/cache/`; maximaal 50 bestanden blijven bewaard.
+- **Afbeeldingsback-ups:** opgeslagen in `Vault/.local-image-compress/backups/originals/`; gemaakt voordat originelen worden vervangen.
+
+### Automatisering
+- Bij 'Achtergrondcompressie' worden twee schuifregelaars beschikbaar:
+ - Drempel voor achtergrondcompressie: 10–1000 afbeeldingen, standaard 50.
+ - Inactiviteitsdrempel: 1–60 minuten, standaard 2.
+- 'Back-ups bewaren, dagen' toont de schuifregelaar voor de bewaartermijn.
+- 'Gecomprimeerde bestanden automatisch verplaatsen' toont de bestandsdrempel. Bij het starten begint het verplaatsen wanneer het aantal bestanden in `Compressed` de drempel bereikt of overschrijdt.
+
+### Interactie met Paste Image Rename
+
+Deze plugin schakelt `obsidian-paste-image-rename` tijdelijk uit tijdens compressie of verplaatsing. Deze bescherming kan niet worden uitgezet, omdat de koppeling tussen gecomprimeerde uitvoer en origineel vereist dat een andere plugin nieuwe bestanden niet hernoemt.
+
+
+Waarom deze bescherming nodig is
+
+Waarom dit nodig is:
+
+- Paste Image Rename registreert een `vault.on("create")`-handler die voor elke aan de kluis toegevoegde afbeelding ongeveer één seconde na aanmaak wordt uitgevoerd. Bestandsnamen die beginnen met `Pasted image ` worden altijd verwerkt, en alle andere afbeeldingen als 'Handle all attachments' aanstaat.
+- Nieuwe gecomprimeerde kopieën in de uitvoermap activeren die handler. Met een actieve Markdown-weergave wordt de uitvoer hernoemd en raakt de koppeling voor verplaatsing verbroken, of verschijnt voor elk bestand een hernoemvenster. Zonder actieve weergave verschijnt voor elk bestand `Error: No active file found`, waardoor de interface tijdens batchverwerking volloopt met fouten.
+- Obsidian heeft geen openbare API waarmee één plugin een andere kan pauzeren. Alleen deze plugin tijdelijk uitschakelen is daarom de enige betrouwbare oplossing.
+
+Veilige afhandeling:
+
+- Alleen de bekende ID `obsidian-paste-image-rename` wordt beïnvloed, uitsluitend tijdens compressie of verplaatsing.
+- De plugin wordt daarna zo nodig met nieuwe pogingen hersteld, tenzij de status extern verandert. De beveiliging onthoudt of zij de plugin uitschakelde en probeert na zo'n wijziging geen herstel.
+- In- en uitschakelen gebruikt de interne Obsidian-API `app.plugins`, omdat er geen openbaar equivalent is. Beschikbaarheid wordt gecontroleerd en fouten worden netjes afgehandeld.
+
+
+
+### Privacy en extern gedrag
+
+- **Netwerk**: geen netwerkverzoeken tijdens runtime. PNG/JPEG-codecs zitten in `main.js`; afbeeldingen worden niet geüpload.
+- **Telemetrie en advertenties**: geen analyses, telemetrie, crashrapportage, tracking, dynamische advertenties of zelfupdates.
+- **Accounts en betalingen**: geen account, abonnement, licentiesleutel of betaling nodig. De optionele financieringslink in het manifest wordt nooit door de plugin geopend.
+- **Kluisbestanden**: de plugin leest ondersteunde afbeeldingen die via opdrachten, automatisering of toegestane hoofdmappen zijn gekozen. Uitvoer gaat naar de ingestelde relatieve map; originelen worden alleen na back-up via de beschreven handmatige of automatische verplaatsing vervangen.
+- **Lokale status**: cachegegevens staan in de pluginmap. Cache- en verplaatsingsback-ups staan onder `Vault/.local-image-compress/backups/`.
+- **Externe bestanden**: beheerde gegevens blijven in de huidige kluis. 'Map openen' vraagt het besturingssysteem alleen gedocumenteerde back-upmappen te tonen en verzendt niets.
+- **Andere plugins**: `obsidian-paste-image-rename` kan zoals hierboven beschreven tijdelijk worden uitgeschakeld en met een controle op de veroorzaker van de statuswijziging worden hersteld.
+
+### Tips
+- Redelijke kwaliteitsbereiken: PNG `65-80`, JPEG `75-90`.
+- Stel 'Toegestane hoofdmappen' in om alleen specifieke mappen zoals `files/` of `images/` te comprimeren.
+- Gebruik achtergrondcompressie als de kluis veel ongecomprimeerde afbeeldingen bevat.
+
+### Veelgestelde vragen
+**De WebAssembly-modules konden niet worden geïnitialiseerd.**
+Herlaad de plugin. Vermeld bij herhaling je Obsidian-versie, platform en consolefout in het bugrapport.
+
+**Waar worden gecomprimeerde bestanden opgeslagen?**
+Standaard in `Compressed`. Gebruik 'Gecomprimeerde bestanden verplaatsen' om de originelen te vervangen.
+
+**Hoe wordt de besparing berekend?**
+De berekening is exact wanneer de cache de oorspronkelijke en uitvoergrootte bevat. Voor ongecomprimeerde PNG/JPEG-bestanden gebruikt de plugin voorzichtige schattingen met begrensde verhoudingen; actuele groottes worden zo nodig van schijf gelezen.
+
+### Licentie
+GPL-3.0-or-later. Licenties en kennisgevingen van derden: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.pl.md b/assets/README.pl.md
new file mode 100644
index 0000000..37c92c8
--- /dev/null
+++ b/assets/README.pl.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Kompresuj pliki PNG i JPEG bezpośrednio w magazynie Obsidian na swoim komputerze, bez usług chmurowych i API. Zmniejsz miejsce zajmowane przez obrazy o 30–70% bez utraty jakości.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Spis treści
+- [Funkcje](#funkcje)
+- [Obsługiwane formaty](#obsługiwane-formaty)
+- [Ustawienia](#ustawienia)
+- [Jak to działa](#jak-to-działa)
+- [Przechowywanie danych i kopie zapasowe](#przechowywanie-danych-i-kopie-zapasowe)
+- [Automatyzacja](#automatyzacja)
+- [Współpraca z Paste Image Rename](#współpraca-z-paste-image-rename)
+- [Prywatność i zachowanie zewnętrzne](#prywatność-i-zachowanie-zewnętrzne)
+- [Wskazówki](#wskazówki)
+- [Częste pytania](#częste-pytania)
+- [Licencja](#licencja)
+
+### Funkcje
+- **Kompresja lokalna**: obrazy PNG i JPEG są kompresowane lokalnie.
+- **Polecenia**:
+ - **Kompresuj wszystkie obrazy w notatce**: przetwarza obrazy używane lub przywołane w aktywnej notatce.
+ - **Kompresuj wszystkie obrazy w folderze**: pozwala wybrać folder i kompresuje wszystkie obsługiwane obrazy poza folderem wyjściowym.
+ - **Kompresuj wszystkie obrazy w magazynie**: skanuje cały magazyn poza folderem wyjściowym.
+ - **Przenieś skompresowane pliki**: przenosi wyniki do lokalizacji oryginałów. Wcześniej tworzy kopię wersji oryginalnej i skompresowanej.
+- **Automatyzacja**:
+ - Automatycznie kompresuj nowe pliki po dodaniu
+ - Kompresuj w tle po bezczynności, gdy liczba nieskompresowanych obrazów osiągnie próg
+- **Interfejs i wygoda**:
+ - Menu kontekstowe plików i folderów
+ - Wskaźnik oszczędzonego miejsca ze szczegółową podpowiedzią
+ - Wskaźnik postępu na pasku stanu
+- **Bezpieczeństwo i niezawodność**:
+ - Pamięć podręczna przetworzonych plików z kopiami
+ - Kopie zapasowe przed przeniesieniem skompresowanych plików, z automatycznym usuwaniem
+
+### Obsługiwane formaty
+- PNG (potok WASM `imagequant`)
+- JPEG/JPG (potok WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF i AVIF są celowo pomijane w tej wersji, ponieważ wtyczka nie zawiera ich koderów.
+
+### Ustawienia
+
+| Ustawienie | Opis | Typ/zakres | Domyślnie |
+|---|---|---|---|
+| Jakość PNG (min-maks) | Zakres jakości stratnej kwantyzacji PNG | 1-100 (np. `65-80`) | `65-80` |
+| Jakość JPEG | Jakość kompresji JPEG | 1-95 | `85` |
+| Dozwolone katalogi główne | Ścieżki względne, w których można kompresować. Puste = cały magazyn | lista ciągów | puste |
+| Folder wyjściowy | Folder zapisu skompresowanych plików | ciąg | `Compressed` |
+| Automatycznie kompresuj nowe pliki | Kompresuj nowe obrazy po dodaniu | wartość logiczna | `false` |
+| Kompresja w tle | Kompresuj w tle podczas bezczynności | wartość logiczna | `true` |
+| Próg kompresji w tle | Liczba nieskompresowanych obrazów wymagana do automatycznego startu | 10-1000 | `50` |
+| Próg bezczynności | Minuty bez aktywności przed rozpoczęciem kompresji w tle | 1-60 minut | `2` |
+| Automatyczna retencja kopii | Automatycznie usuń stare kopie sprzed przeniesienia | wartość logiczna | `false` |
+| Przechowuj kopie, dni | Usuń kopie przenoszenia starsze niż N dni, gdy retencja jest włączona | 1-365 | `30` |
+| Automatycznie przenoś skompresowane pliki | Przy starcie przenieś pliki do lokalizacji oryginałów i zastąp je | wartość logiczna | `false` |
+| Próg automatycznego przenoszenia | Liczba gotowych plików uruchamiająca automatyczne przenoszenie | 1-1000 | `50` |
+
+
+### Jak to działa
+1. Skompresowane pliki są zapisywane w `Compressed` z zachowaniem pierwotnej struktury ścieżek.
+2. Pamięć podręczna zapisuje przetworzone pliki i ich pierwotne rozmiary, aby zapobiec ponownej kompresji i poprawnie obliczać oszczędność.
+3. „Przenieś skompresowane pliki” przenosi pliki z `Compressed` do pierwotnych lokalizacji, jeśli oryginał znajduje się w dozwolonym katalogu. Wcześniej tworzona jest kopia.
+
+Bardzo małe pliki są zwykle pomijane (`<5KB` dla PNG i `<10KB` dla JPEG).
+
+Wewnętrzne limity bezpieczeństwa są stałe: pliki większe niż `100 MB` są pomijane przed odczytem, a obrazy powyżej `100 milionów` pikseli po sprawdzeniu nagłówka.
+
+### Przechowywanie danych i kopie zapasowe
+- **Główna pamięć podręczna:** przechowywana w folderze wtyczki.
+- **Kopie pamięci podręcznej:** w `Vault/.local-image-compress/backups/cache/`; przechowywanych jest maksymalnie 50 plików.
+- **Kopie obrazów:** w `Vault/.local-image-compress/backups/originals/`; tworzone przed zastąpieniem oryginałów.
+
+### Automatyzacja
+- Włączenie „Kompresji w tle” udostępnia dwa suwaki:
+ - Próg kompresji w tle: 10–1000 obrazów, domyślnie 50.
+ - Próg bezczynności: 1–60 minut, domyślnie 2.
+- Włączenie „Przechowuj kopie, dni” pokazuje suwak okresu retencji.
+- Włączenie „Automatycznie przenoś skompresowane pliki” pokazuje próg liczby plików. Przy starcie przenoszenie rozpoczyna się, gdy liczba plików w `Compressed` osiągnie lub przekroczy próg.
+
+### Współpraca z Paste Image Rename
+
+Podczas kompresji lub przenoszenia ta wtyczka tymczasowo wyłącza `obsidian-paste-image-rename`. Ochrony nie można wyłączyć, ponieważ powiązanie skompresowanego wyniku z oryginałem wymaga, by inna wtyczka nie zmieniła nazwy nowego pliku.
+
+
+Dlaczego ta ochrona jest potrzebna
+
+Dlaczego jest potrzebna:
+
+- Paste Image Rename rejestruje obsługę `vault.on("create")`, która uruchamia się dla każdego obrazu dodanego do magazynu około sekundy po jego utworzeniu. Zawsze obsługuje nazwy zaczynające się od `Pasted image `, a wszystkie inne obrazy, gdy włączono „Handle all attachments”.
+- Kopie zapisywane w folderze wyjściowym uruchamiają tę obsługę. Przy aktywnym widoku Markdown zmienia ona nazwę wyniku i zrywa powiązanie potrzebne do przenoszenia albo pokazuje okno zmiany nazwy dla każdego pliku. Bez aktywnego widoku pokazuje `Error: No active file found` dla każdego pliku i zalewa interfejs błędami podczas przetwarzania grupowego.
+- Obsidian nie udostępnia publicznego API, którym jedna wtyczka może wstrzymać inną. Tymczasowe wyłączenie tylko tej wtyczki jest jedynym niezawodnym rozwiązaniem.
+
+Bezpieczna obsługa:
+
+- Dotyczy tylko znanego identyfikatora `obsidian-paste-image-rename` i tylko podczas kompresji lub przenoszenia.
+- Wtyczka jest potem przywracana, w razie potrzeby z ponowieniami, chyba że jej stan zmieni się zewnętrznie. Mechanizm zapisuje, czy sam ją wyłączył, i po takiej zmianie nie próbuje jej przywracać.
+- Włączanie i wyłączanie używa wewnętrznego API Obsidian `app.plugins`, ponieważ brak publicznego odpowiednika. Dostępność funkcji jest sprawdzana, a błędy łagodnie obsługiwane.
+
+
+
+### Prywatność i zachowanie zewnętrzne
+
+- **Sieć**: brak żądań sieciowych w czasie działania. Kodeki PNG/JPEG są wbudowane w `main.js`; obrazy nie są wysyłane.
+- **Telemetria i reklamy**: brak analityki, telemetrii, raportowania awarii, śledzenia, reklam dynamicznych i samoczynnych aktualizacji.
+- **Konta i płatności**: konto, subskrypcja, klucz licencyjny ani płatność nie są potrzebne. Wtyczka nigdy nie otwiera opcjonalnego odnośnika finansowania z manifestu.
+- **Pliki magazynu**: wtyczka czyta obsługiwane obrazy wybrane przez polecenia, automatyzację lub dozwolone katalogi. Wyniki zapisuje w skonfigurowanym folderze względnym, a oryginały zastępuje tylko przez opisane przenoszenie ręczne lub automatyczne po utworzeniu kopii.
+- **Stan lokalny**: dane pamięci podręcznej są w folderze wtyczki. Kopie pamięci i przenoszenia są w `Vault/.local-image-compress/backups/`.
+- **Pliki zewnętrzne**: dane zarządzane przez wtyczkę pozostają w bieżącym magazynie. „Otwórz folder” tylko prosi system o pokazanie opisanych folderów kopii i niczego nie przesyła.
+- **Inne wtyczki**: `obsidian-paste-image-rename` może zostać tymczasowo wyłączony jak opisano wyżej, a następnie przywrócony po sprawdzeniu właściciela zmiany stanu.
+
+### Wskazówki
+- Rozsądne zakresy jakości: PNG `65-80`, JPEG `75-90`.
+- Ustaw „Dozwolone katalogi główne”, aby kompresować tylko foldery takie jak `files/` lub `images/`.
+- Używaj kompresji w tle, gdy magazyn zawiera wiele nieskompresowanych obrazów.
+
+### Częste pytania
+**Wtyczka zgłasza błąd inicjalizacji modułów WebAssembly.**
+Przeładuj wtyczkę. Jeśli błąd się powtórzy, dołącz do zgłoszenia wersję Obsidian, platformę i błąd konsoli.
+
+**Gdzie są zapisywane skompresowane pliki?**
+Domyślnie w `Compressed`. Aby zastąpić oryginały, użyj „Przenieś skompresowane pliki”.
+
+**Jak obliczana jest oszczędność?**
+Wynik jest dokładny, gdy pamięć zawiera rozmiar oryginalny i wynikowy. Dla nieskompresowanych PNG/JPEG używane są ostrożne szacunki z ograniczonymi współczynnikami; bieżące rozmiary są w razie potrzeby odczytywane z dysku.
+
+### Licencja
+GPL-3.0-or-later. Licencje i informacje stron trzecich: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.pt-br.md b/assets/README.pt-br.md
new file mode 100644
index 0000000..1431a4b
--- /dev/null
+++ b/assets/README.pt-br.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Comprima arquivos PNG e JPEG diretamente no seu cofre do Obsidian no computador, sem serviços de nuvem nem APIs. Reduza em 30–70% o espaço usado pelas imagens sem sacrificar a qualidade.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Índice
+- [Recursos](#recursos)
+- [Formatos compatíveis](#formatos-compatíveis)
+- [Configurações](#configurações)
+- [Como funciona](#como-funciona)
+- [Armazenamento de dados e backups](#armazenamento-de-dados-e-backups)
+- [Automação](#automação)
+- [Interação com o Paste Image Rename](#interação-com-o-paste-image-rename)
+- [Privacidade e comportamento externo](#privacidade-e-comportamento-externo)
+- [Dicas](#dicas)
+- [Perguntas frequentes](#perguntas-frequentes)
+- [Licença](#licença)
+
+### Recursos
+- **Compressão local**: imagens PNG e JPEG são comprimidas localmente.
+- **Comandos**:
+ - **Comprimir todas as imagens da nota**: processa as imagens referenciadas ou usadas na nota ativa.
+ - **Comprimir todas as imagens da pasta**: permite escolher uma pasta e comprime todas as imagens compatíveis, exceto a pasta de saída.
+ - **Comprimir todas as imagens do cofre**: verifica todo o cofre, exceto a pasta de saída.
+ - **Mover arquivos comprimidos**: move os resultados para os locais dos arquivos originais. Antes, cria backups das versões original e comprimida.
+- **Automação**:
+ - Comprimir automaticamente novos arquivos quando são adicionados
+ - Compressão em segundo plano após inatividade quando as imagens não comprimidas atingem o limite
+- **Interface e conveniência**:
+ - Menu de contexto para arquivos e pastas
+ - Indicador de espaço economizado com dica detalhada
+ - Indicador de progresso na barra de status
+- **Segurança e confiabilidade**:
+ - Cache dos arquivos processados com backups do cache
+ - Backups antes de mover arquivos comprimidos, com exclusão automática
+
+### Formatos compatíveis
+- PNG (pipeline WASM `imagequant`)
+- JPEG/JPG (pipeline WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF e AVIF são ignorados intencionalmente nesta versão porque o plugin não inclui codificadores para esses formatos.
+
+### Configurações
+
+| Configuração | Descrição | Tipo/faixa | Padrão |
+|---|---|---|---|
+| Qualidade PNG (mín-máx) | Faixa de qualidade para quantização PNG com perdas | 1-100 (por ex. `65-80`) | `65-80` |
+| Qualidade JPEG | Qualidade da compressão JPEG | 1-95 | `85` |
+| Raízes permitidas | Caminhos relativos onde a compressão é permitida. Vazio = cofre inteiro | lista de strings | vazio |
+| Pasta de saída | Pasta onde os arquivos comprimidos são salvos | string | `Compressed` |
+| Comprimir novos arquivos automaticamente | Comprime novas imagens quando são adicionadas | booleano | `false` |
+| Compressão em segundo plano | Comprime em segundo plano durante a inatividade | booleano | `true` |
+| Limite de segundo plano | Número de imagens não comprimidas necessário para iniciar automaticamente | 10-1000 | `50` |
+| Limite de inatividade | Minutos sem atividade antes do início da compressão | 1-60 minutos | `2` |
+| Retenção automática de backups | Exclui automaticamente backups antigos anteriores à movimentação | booleano | `false` |
+| Manter backups, dias | Exclui backups de movimentação com mais de N dias quando a retenção está ativa | 1-365 | `30` |
+| Mover arquivos comprimidos automaticamente | Ao iniciar, move arquivos para os locais originais e substitui os originais | booleano | `false` |
+| Limite de movimentação automática | Número de arquivos prontos que inicia a movimentação automática | 1-1000 | `50` |
+
+
+### Como funciona
+1. Os arquivos comprimidos são salvos em `Compressed`, mantendo a estrutura dos caminhos originais.
+2. O cache registra os arquivos processados e seus tamanhos originais para evitar compressões repetidas e calcular corretamente a economia.
+3. “Mover arquivos comprimidos” devolve os arquivos de `Compressed` aos locais originais se o original estiver em uma raiz permitida. Um backup é criado antes.
+
+Arquivos muito pequenos geralmente são ignorados (`<5KB` para PNG e `<10KB` para JPEG).
+
+Os limites internos de segurança são fixos: arquivos maiores que `100 MB` são ignorados antes da leitura, e imagens acima de `100 milhões` de pixels após a validação do cabeçalho.
+
+### Armazenamento de dados e backups
+- **Cache principal:** armazenado na pasta do plugin.
+- **Backups do cache:** armazenados em `Vault/.local-image-compress/backups/cache/`; até 50 arquivos são mantidos.
+- **Backups das imagens:** armazenados em `Vault/.local-image-compress/backups/originals/`; criados antes da substituição dos originais.
+
+### Automação
+- Ativar “Compressão em segundo plano” disponibiliza dois controles deslizantes:
+ - Limite da compressão em segundo plano: 10–1000 imagens, padrão 50.
+ - Limite de inatividade: 1–60 minutos, padrão 2.
+- Ativar “Manter backups, dias” mostra o controle do período de retenção.
+- Ativar “Mover arquivos comprimidos automaticamente” mostra o limite de arquivos. Ao iniciar, a movimentação começa quando os arquivos em `Compressed` atingem ou ultrapassam o limite.
+
+### Interação com o Paste Image Rename
+
+Este plugin desativa temporariamente `obsidian-paste-image-rename` durante a compressão ou movimentação. Essa proteção não pode ser desativada, pois associar o resultado comprimido ao original exige que outro plugin não renomeie os novos arquivos.
+
+
+Por que essa proteção é necessária
+
+Por que ela é necessária:
+
+- O Paste Image Rename registra um manipulador `vault.on("create")` executado para cada imagem adicionada ao cofre cerca de um segundo após sua criação. Ele sempre atua em nomes iniciados por `Pasted image ` e em todas as outras imagens quando “Handle all attachments” está ativado.
+- As cópias gravadas na pasta de saída ativam esse manipulador. Com uma visualização Markdown ativa, ele renomeia o resultado e quebra a associação necessária à movimentação, ou mostra uma caixa de diálogo para cada arquivo. Sem visualização ativa, mostra `Error: No active file found` para cada arquivo e enche a interface de erros durante o processamento em lote.
+- O Obsidian não oferece uma API pública para um plugin pausar outro. Desativar temporariamente apenas esse plugin é a única solução confiável.
+
+Tratamento seguro:
+
+- Apenas o ID conhecido `obsidian-paste-image-rename` é afetado, somente durante a compressão ou movimentação.
+- O plugin é restaurado depois, com novas tentativas se necessário, a menos que seu estado mude externamente. A proteção registra se foi ela que o desativou e não tenta restaurá-lo após essa mudança.
+- A ativação e desativação usam a API interna `app.plugins` do Obsidian porque não há equivalente público. A disponibilidade dos recursos é verificada e os erros são tratados corretamente.
+
+
+
+### Privacidade e comportamento externo
+
+- **Rede**: não há solicitações de rede durante a execução. Os codecs PNG/JPEG estão em `main.js`; as imagens não são enviadas.
+- **Telemetria e anúncios**: não há análise, telemetria, relatórios de falhas, rastreamento, anúncios dinâmicos nem atualização automática.
+- **Contas e pagamentos**: não são necessários conta, assinatura, chave de licença ou pagamento. O plugin nunca acessa o link opcional de financiamento do manifesto.
+- **Arquivos do cofre**: o plugin lê imagens escolhidas por comandos, automação ou raízes permitidas. Grava na pasta relativa configurada e só substitui originais pelo processo documentado de movimentação manual ou automática, após criar backups.
+- **Estado local**: o cache fica na pasta do plugin. Os backups do cache e das movimentações ficam em `Vault/.local-image-compress/backups/`.
+- **Arquivos externos**: os dados gerenciados permanecem no cofre atual. “Abrir pasta” apenas pede ao sistema operacional para mostrar as pastas documentadas e não transmite dados.
+- **Outros plugins**: `obsidian-paste-image-rename` pode ser desativado temporariamente como descrito acima e depois restaurado com verificação de quem alterou o estado.
+
+### Dicas
+- Faixas de qualidade razoáveis: PNG `65-80`, JPEG `75-90`.
+- Configure “Raízes permitidas” para comprimir apenas pastas como `files/` ou `images/`.
+- Use a compressão em segundo plano quando o cofre tiver muitas imagens não comprimidas.
+
+### Perguntas frequentes
+**O plugin informa que os módulos WebAssembly não foram inicializados.**
+Recarregue o plugin. Se o erro se repetir, inclua no relatório a versão do Obsidian, a plataforma e o erro do console.
+
+**Onde os arquivos comprimidos são salvos?**
+Em `Compressed` por padrão. Para substituir os originais, use “Mover arquivos comprimidos”.
+
+**Como a economia é calculada?**
+O cálculo é exato quando o cache contém os tamanhos original e final. Para PNG/JPEG não comprimidos, são usadas estimativas conservadoras com proporções limitadas; os tamanhos atuais são lidos do disco quando necessário.
+
+### Licença
+GPL-3.0-or-later. Licenças e avisos de terceiros: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.pt.md b/assets/README.pt.md
new file mode 100644
index 0000000..ef73720
--- /dev/null
+++ b/assets/README.pt.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Comprima ficheiros PNG e JPEG diretamente no seu cofre Obsidian no computador, sem serviços na nuvem nem API. Reduza em 30–70% o espaço ocupado pelas imagens sem sacrificar a qualidade.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Índice
+- [Funcionalidades](#funcionalidades)
+- [Formatos suportados](#formatos-suportados)
+- [Definições](#definições)
+- [Como funciona](#como-funciona)
+- [Armazenamento de dados e cópias de segurança](#armazenamento-de-dados-e-cópias-de-segurança)
+- [Automatização](#automatização)
+- [Interação com o Paste Image Rename](#interação-com-o-paste-image-rename)
+- [Privacidade e comportamento externo](#privacidade-e-comportamento-externo)
+- [Sugestões](#sugestões)
+- [Perguntas frequentes](#perguntas-frequentes)
+- [Licença](#licença)
+
+### Funcionalidades
+- **Compressão local**: as imagens PNG e JPEG são comprimidas localmente.
+- **Comandos**:
+ - **Comprimir todas as imagens da nota**: processa as imagens referenciadas ou utilizadas na nota ativa.
+ - **Comprimir todas as imagens da pasta**: permite escolher uma pasta e comprime todas as imagens suportadas, exceto a pasta de saída.
+ - **Comprimir todas as imagens do cofre**: analisa o cofre inteiro, exceto a pasta de saída.
+ - **Mover ficheiros comprimidos**: move os resultados para as localizações dos originais. Antes, cria cópias das versões original e comprimida.
+- **Automatização**:
+ - Comprimir automaticamente novos ficheiros quando são adicionados
+ - Compressão em segundo plano após inatividade quando as imagens não comprimidas atingem o limite
+- **Interface e conveniência**:
+ - Menu de contexto para ficheiros e pastas
+ - Indicador de espaço poupado com descrição detalhada
+ - Indicador de progresso na barra de estado
+- **Segurança e fiabilidade**:
+ - Cache de ficheiros processados com cópias da cache
+ - Cópias de segurança antes de mover ficheiros comprimidos, com eliminação automática
+
+### Formatos suportados
+- PNG (pipeline WASM `imagequant`)
+- JPEG/JPG (pipeline WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF e AVIF são intencionalmente ignorados nesta versão porque o plugin não inclui codificadores para esses formatos.
+
+### Definições
+
+| Definição | Descrição | Tipo/intervalo | Predefinição |
+|---|---|---|---|
+| Qualidade PNG (mín-máx) | Intervalo de qualidade da quantização PNG com perdas | 1-100 (por ex. `65-80`) | `65-80` |
+| Qualidade JPEG | Qualidade da compressão JPEG | 1-95 | `85` |
+| Raízes permitidas | Caminhos relativos onde a compressão é permitida. Vazio = cofre inteiro | lista de cadeias | vazio |
+| Pasta de saída | Pasta onde os ficheiros comprimidos são guardados | cadeia | `Compressed` |
+| Comprimir novos ficheiros automaticamente | Comprime novas imagens quando são adicionadas | booleano | `false` |
+| Compressão em segundo plano | Comprime em segundo plano durante a inatividade | booleano | `true` |
+| Limite de segundo plano | Número de imagens não comprimidas necessário para iniciar automaticamente | 10-1000 | `50` |
+| Limite de inatividade | Minutos sem atividade antes de iniciar a compressão | 1-60 minutos | `2` |
+| Retenção automática de cópias | Elimina automaticamente cópias antigas anteriores à movimentação | booleano | `false` |
+| Manter cópias, dias | Elimina cópias de movimentação com mais de N dias quando a retenção está ativa | 1-365 | `30` |
+| Mover ficheiros comprimidos automaticamente | Repõe ao iniciar os ficheiros nas localizações originais, substituindo-os | booleano | `false` |
+| Limite de movimentação automática | Número de ficheiros prontos que inicia a movimentação automática | 1-1000 | `50` |
+
+
+### Como funciona
+1. Os ficheiros comprimidos são guardados em `Compressed`, mantendo a estrutura dos caminhos originais.
+2. A cache regista os ficheiros processados e tamanhos originais para evitar compressões repetidas e calcular corretamente a poupança.
+3. «Mover ficheiros comprimidos» repõe os ficheiros de `Compressed` nas localizações originais se o original estiver numa raiz permitida. É criada uma cópia antes da movimentação.
+
+Ficheiros muito pequenos são normalmente ignorados (`<5KB` para PNG e `<10KB` para JPEG).
+
+Os limites internos de segurança são fixos: ficheiros maiores que `100 MB` são ignorados antes da leitura e imagens acima de `100 milhões` de píxeis após a validação do cabeçalho.
+
+### Armazenamento de dados e cópias de segurança
+- **Cache principal:** guardada na pasta do plugin.
+- **Cópias da cache:** em `Vault/.local-image-compress/backups/cache/`; são mantidos até 50 ficheiros.
+- **Cópias das imagens:** em `Vault/.local-image-compress/backups/originals/`; criadas antes de substituir os originais.
+
+### Automatização
+- Ativar «Compressão em segundo plano» disponibiliza dois controlos deslizantes:
+ - Limite de compressão: 10–1000 imagens, predefinição 50.
+ - Limite de inatividade: 1–60 minutos, predefinição 2.
+- Ativar «Manter cópias, dias» mostra o controlo do período de retenção.
+- Ativar «Mover ficheiros comprimidos automaticamente» mostra o limite de ficheiros. Ao iniciar, a movimentação começa quando os ficheiros em `Compressed` atingem ou ultrapassam o limite.
+
+### Interação com o Paste Image Rename
+
+Este plugin desativa temporariamente `obsidian-paste-image-rename` durante a compressão ou movimentação. Esta proteção não pode ser desativada, pois associar o resultado comprimido ao original exige que outro plugin não mude o nome dos novos ficheiros.
+
+
+Porque esta proteção é necessária
+
+Por que é necessária:
+
+- O Paste Image Rename regista um processador `vault.on("create")` executado para cada imagem adicionada ao cofre cerca de um segundo após a criação. Atua sempre em nomes iniciados por `Pasted image ` e em todas as outras imagens se «Handle all attachments» estiver ativo.
+- As cópias escritas na pasta de saída ativam esse processador. Com uma vista Markdown ativa, muda o nome do resultado e quebra a associação necessária à movimentação, ou mostra um diálogo por ficheiro. Sem vista ativa, mostra `Error: No active file found` para cada ficheiro e enche a interface de erros durante o processamento em lote.
+- O Obsidian não tem uma API pública que permita a um plugin pausar outro. Desativar temporariamente apenas este plugin é a única solução fiável.
+
+Tratamento seguro:
+
+- Apenas o ID conhecido `obsidian-paste-image-rename` é afetado, apenas durante compressão ou movimentação.
+- O plugin é restaurado depois, com novas tentativas se necessário, salvo se o estado mudar externamente. A proteção regista se foi ela que o desativou e não tenta restaurá-lo após essa mudança.
+- A ativação e desativação usam a API interna `app.plugins` do Obsidian, pois não há equivalente público. A disponibilidade das funções é verificada e os erros são tratados corretamente.
+
+
+
+### Privacidade e comportamento externo
+
+- **Rede**: não há pedidos de rede em execução. Os codecs PNG/JPEG estão incluídos em `main.js`; as imagens não são carregadas.
+- **Telemetria e publicidade**: não há análise, telemetria, relatórios de falhas, rastreio, anúncios dinâmicos nem atualização automática.
+- **Contas e pagamentos**: não são necessários conta, subscrição, chave de licença ou pagamento. O plugin nunca acede à ligação opcional de financiamento do manifesto.
+- **Ficheiros do cofre**: o plugin lê imagens escolhidas por comandos, automatização ou raízes permitidas. Escreve na pasta relativa configurada e só substitui originais pelo processo documentado de movimentação manual ou automática, após criar cópias.
+- **Estado local**: a cache fica na pasta do plugin. As cópias da cache e das movimentações ficam em `Vault/.local-image-compress/backups/`.
+- **Ficheiros externos**: os dados geridos permanecem no cofre atual. «Abrir pasta» apenas pede ao sistema operativo que mostre as pastas documentadas e não transmite dados.
+- **Outros plugins**: `obsidian-paste-image-rename` pode ser temporariamente desativado como descrito acima e depois restaurado com verificação de quem alterou o estado.
+
+### Sugestões
+- Intervalos de qualidade razoáveis: PNG `65-80`, JPEG `75-90`.
+- Configure «Raízes permitidas» para comprimir apenas pastas como `files/` ou `images/`.
+- Use a compressão em segundo plano quando o cofre tiver muitas imagens não comprimidas.
+
+### Perguntas frequentes
+**O plugin indica que os módulos WebAssembly não foram inicializados.**
+Recarregue o plugin. Se o erro se repetir, inclua no relatório a versão do Obsidian, plataforma e erro da consola.
+
+**Onde são guardados os ficheiros comprimidos?**
+Em `Compressed` por predefinição. Para substituir os originais, use «Mover ficheiros comprimidos».
+
+**Como é calculada a poupança?**
+O cálculo é exato quando a cache contém os tamanhos original e final. Para PNG/JPEG não comprimidos usam-se estimativas conservadoras com rácios limitados; os tamanhos atuais são lidos do disco quando necessário.
+
+### Licença
+GPL-3.0-or-later. Licenças e avisos de terceiros: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.ru.md b/assets/README.ru.md
new file mode 100644
index 0000000..ef1ba0e
--- /dev/null
+++ b/assets/README.ru.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Сжимает PNG- и JPEG-файлы в хранилище Obsidian на компьютере, локально без облаков и API. Позволяет экономить 30–70% дискового пространства, занятого изображениями, без потери качества.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Оглавление
+- [Возможности](#возможности)
+- [Поддерживаемые форматы](#поддерживаемые-форматы)
+- [Настройки](#настройки)
+- [Как это работает](#как-это-работает)
+- [Хранение данных и резервные копии](#хранение-данных-и-резервные-копии)
+- [Автоматизация](#автоматизация)
+- [Взаимодействие с Paste Image Rename](#взаимодействие-с-paste-image-rename)
+- [Приватность и внешнее поведение](#приватность-и-внешнее-поведение)
+- [Советы](#советы)
+- [Частые вопросы](#частые-вопросы)
+- [Лицензия](#лицензия)
+
+### Возможности
+- **Локальное сжатие**: PNG- и JPEG-изображения сжимаются локально.
+- **Команды**:
+ - **Сжать все изображения в заметке**: Обрабатывает изображения, упомянутые или используемые в активной заметке.
+ - **Сжать все изображения в папке**: Даёт выбрать папку и сжимает все поддерживаемые изображения внутри, кроме папки вывода.
+ - **Сжать все изображения в хранилище**: Полный проход по хранилищу, исключая папку вывода.
+ - **Переместить сжатые файлы**: Переносит результаты сжатия туда, где лежали оригиналы. Перед перемещением создаётся резервная копия оригиналов и сжатых версий.
+- **Автоматизация**:
+ - Автосжатие новых файлов при добавлении
+ - Фоновое сжатие при неактивности пользователя и превышении порога несжатых изображений в хранилище.
+- **Интерфейс и удобство**:
+ - Контекстное меню для файлов и папок
+ - Индикатор экономии места со всплывающей подсказкой
+ - Индикатор процесса в строке состояния
+- **Безопасность и надёжность**:
+ - Кэш обработанных файлов с резервными копиями
+ - Резервные копии перед перемещением сжатых файлов с автоудалением.
+
+### Поддерживаемые форматы
+- PNG (конвейер WASM `imagequant`)
+- JPEG/JPG (конвейер WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF и AVIF в этом релизе намеренно пропускаются: необходимые кодировщики не встроены в плагин.
+
+### Настройки
+
+| Параметр | Описание | Тип/диапазон | По умолчанию |
+|---|---|---|---|
+| Качество PNG (мин-макс) | Диапазон качества квантования PNG с потерями | 1-100 (например `65-80`) | `65-80` |
+| Качество JPEG | Качество сжатия JPEG | 1-95 | `85` |
+| Разрешённые корни | Относительные пути, где разрешено сжатие. Пустое значение = всё хранилище | список строк | пусто |
+| Выходная папка | Папка для сохранения сжатых файлов | строка | `Compressed` |
+| Автосжатие новых файлов | Сжимать новые изображения при добавлении | логический | `false` |
+| Автоматическое фоновое сжатие | Сжимать в фоне при неактивности | логический | `true` |
+| Порог фонового сжатия | Количество несжатых изображений для автозапуска сжатия в фоне. | 10-1000 | `50` |
+| Порог неактивности | Минуты без действий пользователя перед запуском фонового сжатия | 1-60 минут | `2` |
+| Автохранение резервных копий | Автоматически удалять старые резервные копии переноса | логический | `false` |
+| Хранить резервные копии, дней | Удалять резервные копии переноса старше N дней, если автохранение включено | 1-365 | `30` |
+| Автоперемещение сжатых файлов | При запуске переносить сжатые файлы на места оригинальных изображений с заменой | логический | `false` |
+| Порог автоперемещения | Количество готовых к переносу сжатых файлов для автозапуска | 1-1000 | `50` |
+
+
+### Как это работает
+1. Сжатые файлы сохраняются в папку `Compressed` с повторением структуры путей.
+2. Кэш фиксирует факт обработки файлов и исходный размер, чтобы не сжимать повторно и корректно считать экономию.
+3. Команда «Переместить сжатые файлы» переносит файлы из `Compressed` на места оригиналов, если оригинал найден в разрешённых корнях. Перед перемещением создаётся резервная копия.
+
+Минимальные размеры для сжатия: очень маленькие файлы обычно пропускаются (`<5KB` для PNG и `<10KB` для JPEG).
+
+Внутренние лимиты безопасности фиксированы: файлы больше `100 МБ` пропускаются до чтения, изображения выше `100 млн` пикселей пропускаются после проверки заголовка.
+
+### Хранение данных и резервные копии
+- **Основной кэш:** хранится в папке плагина.
+- **Резервные копии кэша:** `Vault/.local-image-compress/backups/cache/`; хранится не более 50 файлов.
+- **Резервные копии изображений:** `Vault/.local-image-compress/backups/originals/`; создаются перед заменой оригиналов.
+
+### Автоматизация
+- При включении «Автоматического фонового сжатия» становятся доступны два ползунка:
+ - Порог для фонового сжатия: 10–1000 изображений, по умолчанию 50.
+ - Порог бездействия: 1–60 минут, по умолчанию 2.
+- «Хранить резервные копии, дней» при включении показывает ползунок срока хранения.
+- «Автоперемещение сжатых файлов» при включении показывает порог по количеству файлов. При запуске плагина, если в `Compressed` файлов не меньше порога, запускается перемещение.
+
+### Взаимодействие с Paste Image Rename
+
+Плагин временно отключает сторонний плагин `obsidian-paste-image-rename` на время сжатия или перемещения файлов. Настройки для отключения этой защиты нет, потому что сопоставление сжатого вывода с оригиналом зависит от того, что свежие файлы не будут переименованы другим плагином.
+
+
+Зачем нужна эта защита
+
+Зачем это нужно:
+
+- Paste Image Rename регистрирует обработчик `vault.on("create")`, который срабатывает для каждого изображения, добавленного в хранилище примерно в течение секунды после создания. Он всегда обрабатывает файлы с именем, начинающимся на `Pasted image `, и все остальные изображения, если включена настройка «Handle all attachments».
+- Когда наш плагин записывает сжатые копии в папку вывода, новые файлы запускают этот обработчик. При активной Markdown-вкладке Paste Image Rename переименовывает только что записанный результат, нарушая сопоставление «сжатый файл → оригинал», или показывает диалог переименования для каждого файла. Без активной Markdown-вкладки он показывает уведомление `Error: No active file found` для каждого созданного файла и заполняет интерфейс ошибками во время пакетной обработки.
+- В Obsidian нет публичного API, чтобы один плагин попросил другой приостановиться, поэтому временное отключение этого одного плагина — единственный надёжный способ.
+
+Как это сделано безопасно:
+
+- Затрагивается только известный ID `obsidian-paste-image-rename` и только во время сжатия или перемещения.
+- После операции плагин восстанавливается с повторными попытками. Защита запоминает, отключала ли она плагин, и не пытается восстановить его, если состояние изменилось извне.
+- Для включения и отключения используется внутренний API Obsidian `app.plugins`, поскольку публичного аналога нет. Наличие API проверяется перед вызовом, а ошибки обрабатываются без прерывания операции.
+
+
+
+### Приватность и внешнее поведение
+
+- **Сеть**: плагин не выполняет сетевых запросов во время работы. Кодеки PNG/JPEG встроены в `main.js`; изображения никуда не загружаются.
+- **Телеметрия и реклама**: нет аналитики, телеметрии, отчётов о сбоях, отслеживания, динамической рекламы или механизма самообновления.
+- **Учётные записи и платежи**: учётная запись, подписка, лицензионный ключ и оплата не нужны. Необязательная ссылка поддержки в манифесте не открывается самим плагином.
+- **Файлы хранилища**: плагин читает поддерживаемые изображения, выбранные командами, автоматизацией или разрешёнными корнями. Сжатые результаты записываются в настроенную папку относительно хранилища; оригиналы заменяются только через документированные ручное или автоматическое перемещение после создания резервных копий.
+- **Локальное состояние**: кэш хранится в папке плагина. Резервные копии кэша и перемещения хранятся в `Vault/.local-image-compress/backups/`.
+- **Внешние файлы**: управляемые данные остаются внутри текущего хранилища. Команды «Открыть папку» только просят ОС показать описанные папки резервных копий и ничего не передают.
+- **Другие плагины**: `obsidian-paste-image-rename` может временно отключаться во время сжатия или перемещения, как описано выше, а затем восстанавливается с проверкой того, кем было изменено его состояние.
+
+### Советы
+- Разумный диапазон качества: PNG `65-80`, JPEG `75-90`.
+- Настройте «Разрешённые корни», если хотите сжимать только определённые папки, например `files/` или `images/`.
+- Используйте фоновое сжатие, если в хранилище много несжатых изображений.
+
+### Частые вопросы
+**Плагин пишет, что WebAssembly-модули не инициализировались.**
+Перезагрузите плагин. Если ошибка повторяется, приложите к сообщению об ошибке версию Obsidian, платформу и текст ошибки из консоли.
+
+**Где оказываются сжатые файлы?**
+По умолчанию в `Compressed`. Для замены оригиналов используйте команду «Переместить сжатые файлы».
+
+**Как считается экономия?**
+Экономия точная, когда в кэше есть исходный и выходной размеры. Для несжатых PNG/JPEG плагин использует консервативную оценку с ограниченными коэффициентами; текущие размеры сжатых файлов при необходимости читаются с диска.
+
+### Лицензия
+GPL-3.0-or-later. Сторонние лицензии и уведомления: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.th.md b/assets/README.th.md
new file mode 100644
index 0000000..b9463ec
--- /dev/null
+++ b/assets/README.th.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+บีบอัดไฟล์ PNG และ JPEG โดยตรงใน vault ของ Obsidian บนคอมพิวเตอร์ โดยไม่ใช้บริการคลาวด์หรือ API ลดพื้นที่ดิสก์ที่ภาพใช้ลง 30–70% โดยไม่ลดทอนคุณภาพ
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### สารบัญ
+- [คุณสมบัติ](#คุณสมบัติ)
+- [รูปแบบที่รองรับ](#รูปแบบที่รองรับ)
+- [การตั้งค่า](#การตั้งค่า)
+- [วิธีการทำงาน](#วิธีการทำงาน)
+- [การจัดเก็บข้อมูลและข้อมูลสำรอง](#การจัดเก็บข้อมูลและข้อมูลสำรอง)
+- [ระบบอัตโนมัติ](#ระบบอัตโนมัติ)
+- [การทำงานร่วมกับ Paste Image Rename](#การทำงานร่วมกับ-paste-image-rename)
+- [ความเป็นส่วนตัวและการทำงานภายนอก](#ความเป็นส่วนตัวและการทำงานภายนอก)
+- [เคล็ดลับ](#เคล็ดลับ)
+- [คำถามที่พบบ่อย](#คำถามที่พบบ่อย)
+- [สัญญาอนุญาต](#สัญญาอนุญาต)
+
+### คุณสมบัติ
+- **การบีบอัดภายในเครื่อง**: บีบอัดภาพ PNG และ JPEG ภายในเครื่อง
+- **คำสั่ง**:
+ - **บีบอัดภาพทั้งหมดในโน้ต**: ประมวลผลภาพที่อ้างอิงหรือใช้ในโน้ตที่เปิดอยู่
+ - **บีบอัดภาพทั้งหมดในโฟลเดอร์**: ให้เลือกโฟลเดอร์และบีบอัดภาพที่รองรับทั้งหมด ยกเว้นโฟลเดอร์ผลลัพธ์
+ - **บีบอัดภาพทั้งหมดใน vault**: สแกนทั้ง vault ยกเว้นโฟลเดอร์ผลลัพธ์
+ - **ย้ายไฟล์ที่บีบอัด**: ย้ายผลลัพธ์ไปยังตำแหน่งต้นฉบับ โดยสำรองทั้งฉบับต้นฉบับและฉบับบีบอัดก่อน
+- **ระบบอัตโนมัติ**:
+ - บีบอัดไฟล์ใหม่โดยอัตโนมัติเมื่อเพิ่มไฟล์
+ - บีบอัดเบื้องหลังเมื่อผู้ใช้ไม่มีการใช้งานและจำนวนภาพที่ยังไม่บีบอัดถึงเกณฑ์
+- **ส่วนติดต่อและความสะดวก**:
+ - เมนูบริบทสำหรับไฟล์และโฟลเดอร์
+ - ตัวแสดงพื้นที่ที่ประหยัดได้พร้อมคำอธิบายโดยละเอียด
+ - ตัวแสดงความคืบหน้าในแถบสถานะ
+- **ความปลอดภัยและความน่าเชื่อถือ**:
+ - แคชของไฟล์ที่ประมวลผลพร้อมข้อมูลสำรอง
+ - สำรองข้อมูลก่อนย้ายไฟล์ที่บีบอัด พร้อมการลบอัตโนมัติ
+
+### รูปแบบที่รองรับ
+- PNG (กระบวนการ WASM `imagequant`)
+- JPEG/JPG (กระบวนการ WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF และ AVIF ถูกข้ามโดยตั้งใจในรุ่นนี้ เพราะปลั๊กอินไม่มีตัวเข้ารหัสสำหรับรูปแบบเหล่านี้
+
+### การตั้งค่า
+
+| การตั้งค่า | คำอธิบาย | ชนิด/ช่วง | ค่าเริ่มต้น |
+|---|---|---|---|
+| คุณภาพ PNG (ต่ำสุด-สูงสุด) | ช่วงคุณภาพของการลดสี PNG แบบสูญเสียข้อมูล | 1-100 (เช่น `65-80`) | `65-80` |
+| คุณภาพ JPEG | คุณภาพการบีบอัด JPEG | 1-95 | `85` |
+| รากที่อนุญาต | เส้นทางสัมพัทธ์ที่อนุญาตให้บีบอัด ว่าง = ทั้ง vault | รายการข้อความ | ว่าง |
+| โฟลเดอร์ผลลัพธ์ | โฟลเดอร์สำหรับบันทึกไฟล์ที่บีบอัด | ข้อความ | `Compressed` |
+| บีบอัดไฟล์ใหม่อัตโนมัติ | บีบอัดภาพใหม่เมื่อเพิ่มเข้ามา | บูลีน | `false` |
+| การบีบอัดเบื้องหลัง | บีบอัดเบื้องหลังเมื่อไม่มีการใช้งาน | บูลีน | `true` |
+| เกณฑ์เบื้องหลัง | จำนวนภาพที่ยังไม่บีบอัดซึ่งต้องมีเพื่อเริ่มโดยอัตโนมัติ | 10-1000 | `50` |
+| เกณฑ์ไม่มีการใช้งาน | จำนวนนาทีที่ไม่มีการใช้งานก่อนเริ่มบีบอัดเบื้องหลัง | 1-60 นาที | `2` |
+| การเก็บข้อมูลสำรองอัตโนมัติ | ลบข้อมูลสำรองเก่าก่อนการย้ายโดยอัตโนมัติ | บูลีน | `false` |
+| เก็บข้อมูลสำรอง, วัน | ลบข้อมูลสำรองการย้ายที่เก่ากว่า N วันเมื่อเปิดการเก็บอัตโนมัติ | 1-365 | `30` |
+| ย้ายไฟล์ที่บีบอัดอัตโนมัติ | เมื่อเริ่ม ให้ย้ายไฟล์กลับตำแหน่งภาพต้นฉบับและแทนที่ต้นฉบับ | บูลีน | `false` |
+| เกณฑ์การย้ายอัตโนมัติ | จำนวนไฟล์ที่พร้อมย้ายซึ่งเรียกการย้ายอัตโนมัติ | 1-1000 | `50` |
+
+
+### วิธีการทำงาน
+1. ไฟล์ที่บีบอัดถูกบันทึกใน `Compressed` โดยคงโครงสร้างเส้นทางเดิม
+2. แคชบันทึกไฟล์ที่ประมวลผลและขนาดต้นฉบับ เพื่อป้องกันการบีบอัดซ้ำและคำนวณพื้นที่ประหยัดได้อย่างถูกต้อง
+3. “ย้ายไฟล์ที่บีบอัด” ย้ายไฟล์จาก `Compressed` กลับตำแหน่งเดิมเมื่อต้นฉบับอยู่ในรากที่อนุญาต โดยสร้างข้อมูลสำรองก่อน
+
+ไฟล์ขนาดเล็กมากมักถูกข้าม (PNG `<5KB` และ JPEG `<10KB`)
+
+ขีดจำกัดความปลอดภัยภายในเป็นค่าคงที่: ไฟล์ใหญ่กว่า `100 MB` ถูกข้ามก่อนอ่าน และภาพเกิน `100 ล้าน` พิกเซลถูกข้ามหลังตรวจสอบส่วนหัว
+
+### การจัดเก็บข้อมูลและข้อมูลสำรอง
+- **แคชหลัก:** เก็บในโฟลเดอร์ปลั๊กอิน
+- **ข้อมูลสำรองแคช:** เก็บใน `Vault/.local-image-compress/backups/cache/` และเก็บสูงสุด 50 ไฟล์
+- **ข้อมูลสำรองภาพ:** เก็บใน `Vault/.local-image-compress/backups/originals/` และสร้างก่อนแทนที่ต้นฉบับ
+
+### ระบบอัตโนมัติ
+- การเปิด “การบีบอัดเบื้องหลัง” จะแสดงแถบเลื่อนสองรายการ:
+ - เกณฑ์การบีบอัดเบื้องหลัง: 10–1000 ภาพ ค่าเริ่มต้น 50
+ - เกณฑ์ไม่มีการใช้งาน: 1–60 นาที ค่าเริ่มต้น 2
+- การเปิด “เก็บข้อมูลสำรอง, วัน” จะแสดงแถบเลื่อนระยะเวลาเก็บ
+- การเปิด “ย้ายไฟล์ที่บีบอัดอัตโนมัติ” จะแสดงเกณฑ์จำนวนไฟล์ เมื่อเริ่ม การย้ายจะทำงานหากจำนวนไฟล์ใน `Compressed` ถึงหรือเกินเกณฑ์
+
+### การทำงานร่วมกับ Paste Image Rename
+
+ปลั๊กอินนี้ปิดใช้ปลั๊กอินภายนอก `obsidian-paste-image-rename` ชั่วคราวระหว่างบีบอัดหรือย้าย ไม่สามารถปิดการป้องกันนี้ได้ เพราะการจับคู่ผลลัพธ์กับต้นฉบับต้องอาศัยการที่ปลั๊กอินอื่นไม่เปลี่ยนชื่อไฟล์ใหม่
+
+
+เหตุใดจึงต้องใช้การป้องกันนี้
+
+เหตุผลที่จำเป็น:
+
+- Paste Image Rename ลงทะเบียนตัวจัดการ `vault.on("create")` ซึ่งทำงานกับทุกภาพที่เพิ่มใน vault ประมาณหนึ่งวินาทีหลังสร้าง โดยทำงานกับชื่อที่ขึ้นต้นด้วย `Pasted image ` เสมอ และกับภาพอื่นทั้งหมดเมื่อเปิด “Handle all attachments”
+- สำเนาที่เขียนลงโฟลเดอร์ผลลัพธ์จะเรียกตัวจัดการนี้ หากมีมุมมอง Markdown ที่ใช้งานอยู่ ตัวจัดการจะเปลี่ยนชื่อผลลัพธ์จนการจับคู่สำหรับการย้ายเสียหาย หรือแสดงกล่องเปลี่ยนชื่อสำหรับทุกไฟล์ หากไม่มีมุมมองที่ใช้งาน จะขึ้น `Error: No active file found` สำหรับทุกไฟล์และทำให้ส่วนติดต่อเต็มไปด้วยข้อผิดพลาดระหว่างประมวลผลแบบกลุ่ม
+- Obsidian ไม่มี API สาธารณะให้ปลั๊กอินหนึ่งหยุดอีกปลั๊กอินชั่วคราว ดังนั้นการปิดใช้เฉพาะปลั๊กอินนี้ชั่วคราวจึงเป็นวิธีเดียวที่เชื่อถือได้
+
+การจัดการอย่างปลอดภัย:
+
+- มีผลเฉพาะ ID `obsidian-paste-image-rename` และเฉพาะระหว่างบีบอัดหรือย้าย
+- ปลั๊กอินจะถูกเปิดคืนหลังจากนั้น พร้อมลองใหม่หากจำเป็น เว้นแต่สถานะถูกเปลี่ยนจากภายนอก ตัวป้องกันบันทึกว่าตนเป็นผู้ปิดหรือไม่ และจะไม่พยายามคืนค่าหลังการเปลี่ยนดังกล่าว
+- การเปิดและปิดใช้ API ภายใน `app.plugins` ของ Obsidian เพราะไม่มี API สาธารณะที่เทียบเท่า โดยตรวจสอบว่าฟังก์ชันมีอยู่และจัดการข้อผิดพลาดอย่างเหมาะสม
+
+
+
+### ความเป็นส่วนตัวและการทำงานภายนอก
+
+- **เครือข่าย**: ไม่มีคำขอเครือข่ายขณะทำงาน ตัวแปลง PNG/JPEG รวมอยู่ใน `main.js` และไม่มีการอัปโหลดภาพ
+- **การวัดผลและโฆษณา**: ไม่มีการวิเคราะห์ การวัดผลทางไกล รายงานข้อขัดข้อง การติดตาม โฆษณาแบบไดนามิก หรือการอัปเดตตัวเอง
+- **บัญชีและการชำระเงิน**: ไม่ต้องใช้บัญชี การสมัครสมาชิก คีย์สัญญาอนุญาต หรือการชำระเงิน ปลั๊กอินไม่เข้าถึงลิงก์สนับสนุนที่เลือกได้ใน manifest
+- **ไฟล์ใน vault**: ปลั๊กอินอ่านภาพที่เลือกผ่านคำสั่ง ระบบอัตโนมัติ หรือรากที่อนุญาต เขียนผลลัพธ์ในโฟลเดอร์สัมพัทธ์ที่ตั้งค่า และแทนที่ต้นฉบับผ่านขั้นตอนย้ายหรือย้ายอัตโนมัติที่อธิบายไว้หลังสำรองข้อมูลเท่านั้น
+- **สถานะในเครื่อง**: แคชอยู่ในโฟลเดอร์ปลั๊กอิน ข้อมูลสำรองแคชและการย้ายอยู่ใต้ `Vault/.local-image-compress/backups/`
+- **ไฟล์ภายนอก**: ข้อมูลที่ปลั๊กอินจัดการอยู่ใน vault ปัจจุบัน “เปิดโฟลเดอร์” เพียงขอให้ระบบปฏิบัติการแสดงโฟลเดอร์ข้อมูลสำรองและไม่ส่งข้อมูล
+- **ปลั๊กอินอื่น**: `obsidian-paste-image-rename` อาจถูกปิดชั่วคราวดังที่อธิบายข้างต้น แล้วเปิดคืนหลังตรวจสอบว่าใครเปลี่ยนสถานะ
+
+### เคล็ดลับ
+- ช่วงคุณภาพที่เหมาะสม: PNG `65-80`, JPEG `75-90`
+- ตั้งค่า “รากที่อนุญาต” หากต้องการบีบอัดเฉพาะโฟลเดอร์ เช่น `files/` หรือ `images/`
+- ใช้การบีบอัดเบื้องหลังเมื่อ vault มีภาพที่ยังไม่บีบอัดจำนวนมาก
+
+### คำถามที่พบบ่อย
+**ปลั๊กอินแจ้งว่าเริ่มต้นโมดูล WebAssembly ไม่สำเร็จ**
+โหลดปลั๊กอินใหม่ หากเกิดซ้ำ ให้ระบุเวอร์ชัน Obsidian แพลตฟอร์ม และข้อผิดพลาดคอนโซลในรายงานข้อบกพร่อง
+
+**ไฟล์ที่บีบอัดเก็บไว้ที่ไหน?**
+ค่าเริ่มต้นคือ `Compressed` หากต้องการแทนที่ต้นฉบับ ให้ใช้ “ย้ายไฟล์ที่บีบอัด”
+
+**คำนวณพื้นที่ที่ประหยัดได้อย่างไร?**
+ผลลัพธ์จะแม่นยำเมื่อแคชมีขนาดต้นฉบับและผลลัพธ์ สำหรับ PNG/JPEG ที่ยังไม่บีบอัด ปลั๊กอินใช้การประมาณแบบระมัดระวังด้วยอัตราส่วนที่มีเพดาน และอ่านขนาดไฟล์ปัจจุบันจากดิสก์เมื่อจำเป็น
+
+### สัญญาอนุญาต
+GPL-3.0-or-later. สัญญาอนุญาตและประกาศของบุคคลที่สาม: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.tr.md b/assets/README.tr.md
new file mode 100644
index 0000000..630f4b1
--- /dev/null
+++ b/assets/README.tr.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+PNG ve JPEG dosyalarını bulut hizmetleri veya API kullanmadan doğrudan bilgisayarınızdaki Obsidian kasasında sıkıştırın. Görsellerin kullandığı disk alanını kaliteyi düşürmeden %30–70 azaltın.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### İçindekiler
+- [Özellikler](#özellikler)
+- [Desteklenen biçimler](#desteklenen-biçimler)
+- [Ayarlar](#ayarlar)
+- [Çalışma şekli](#çalışma-şekli)
+- [Veri depolama ve yedekler](#veri-depolama-ve-yedekler)
+- [Otomasyon](#otomasyon)
+- [Paste Image Rename ile etkileşim](#paste-image-rename-ile-etkileşim)
+- [Gizlilik ve dış davranış](#gizlilik-ve-dış-davranış)
+- [İpuçları](#i̇puçları)
+- [Sık sorulan sorular](#sık-sorulan-sorular)
+- [Lisans](#lisans)
+
+### Özellikler
+- **Yerel sıkıştırma**: PNG ve JPEG görselleri yerel olarak sıkıştırılır.
+- **Komutlar**:
+ - **Nottaki tüm görselleri sıkıştır**: etkin notta başvurulan veya kullanılan görselleri işler.
+ - **Klasördeki tüm görselleri sıkıştır**: klasör seçtirir ve çıktı klasörü dışındaki desteklenen görselleri sıkıştırır.
+ - **Kasadaki tüm görselleri sıkıştır**: çıktı klasörü dışında tüm kasayı tarar.
+ - **Sıkıştırılmış dosyaları taşı**: sonuçları özgün konumlara taşır; önce özgün ve sıkıştırılmış sürümleri yedekler.
+- **Otomasyon**:
+ - Yeni dosyaları eklendiklerinde otomatik sıkıştır
+ - Sıkıştırılmamış görsel sayısı eşiğe ulaştığında kullanıcı hareketsizliğinden sonra arka planda sıkıştır
+- **Arayüz ve kolaylık**:
+ - Dosya ve klasörler için bağlam menüsü
+ - Ayrıntılı araç ipucuyla alan tasarrufu göstergesi
+ - Durum çubuğu ilerleme göstergesi
+- **Güvenlik ve güvenilirlik**:
+ - İşlenen dosyaların önbelleği ve önbellek yedekleri
+ - Sıkıştırılmış dosyaları taşımadan önce yedekleme ve otomatik silme
+
+### Desteklenen biçimler
+- PNG (`imagequant` WASM işlem hattı)
+- JPEG/JPG (`mozjpeg` WASM işlem hattı)
+
+WebP, GIF, BMP, HEIC/HEIF ve AVIF, eklenti bu biçimlerin kodlayıcılarını içermediği için bu sürümde bilerek atlanır.
+
+### Ayarlar
+
+| Ayar | Açıklama | Tür/aralık | Varsayılan |
+|---|---|---|---|
+| PNG kalitesi (min-maks) | Kayıplı PNG niceleme kalite aralığı | 1-100 (örn. `65-80`) | `65-80` |
+| JPEG kalitesi | JPEG sıkıştırma kalitesi | 1-95 | `85` |
+| İzin verilen kökler | Sıkıştırmaya izin verilen göreli yollar. Boş = tüm kasa | dize listesi | boş |
+| Çıktı klasörü | Sıkıştırılmış dosyaların kaydedildiği klasör | dize | `Compressed` |
+| Yeni dosyaları otomatik sıkıştır | Yeni görselleri eklendiklerinde sıkıştır | mantıksal | `false` |
+| Arka plan sıkıştırması | Hareketsizlikte arka planda sıkıştır | mantıksal | `true` |
+| Arka plan eşiği | Otomatik başlatma için gereken sıkıştırılmamış görsel sayısı | 10-1000 | `50` |
+| Hareketsizlik eşiği | Arka plan sıkıştırmasından önce etkinliksiz dakika | 1-60 dakika | `2` |
+| Otomatik yedek saklama | Taşıma öncesi eski yedekleri otomatik sil | mantıksal | `false` |
+| Yedekleri tut, gün | Saklama etkinse N günden eski taşıma yedeklerini sil | 1-365 | `30` |
+| Sıkıştırılmış dosyaları otomatik taşı | Başlangıçta dosyaları özgün konumlara taşıyıp orijinalleri değiştir | mantıksal | `false` |
+| Otomatik taşıma eşiği | Otomatik taşımayı başlatan hazır dosya sayısı | 1-1000 | `50` |
+
+
+### Çalışma şekli
+1. Sıkıştırılmış dosyalar özgün yol yapısı korunarak `Compressed` klasörüne kaydedilir.
+2. Önbellek, yinelenen sıkıştırmayı önlemek ve tasarrufu doğru hesaplamak için işlenen dosyaları ve özgün boyutları kaydeder.
+3. “Sıkıştırılmış dosyaları taşı”, özgün dosya izin verilen bir kökteyse dosyaları `Compressed` konumundan geri taşır. Önce yedek oluşturulur.
+
+Çok küçük dosyalar genellikle atlanır (PNG için `<5KB`, JPEG için `<10KB`).
+
+İç güvenlik sınırları sabittir: `100 MB` üzerindeki dosyalar okunmadan, `100 milyon` piksel üzerindeki görseller başlık doğrulamasından sonra atlanır.
+
+### Veri depolama ve yedekler
+- **Ana önbellek:** eklenti klasöründe saklanır.
+- **Önbellek yedekleri:** `Vault/.local-image-compress/backups/cache/` içinde saklanır; en fazla 50 dosya tutulur.
+- **Görsel yedekleri:** `Vault/.local-image-compress/backups/originals/` içinde saklanır; orijinaller değiştirilmeden önce oluşturulur.
+
+### Otomasyon
+- “Arka plan sıkıştırması” iki kaydırıcıyı kullanılabilir yapar:
+ - Arka plan sıkıştırma eşiği: 10–1000 görsel, varsayılan 50.
+ - Hareketsizlik eşiği: 1–60 dakika, varsayılan 2.
+- “Yedekleri tut, gün” saklama süresi kaydırıcısını gösterir.
+- “Sıkıştırılmış dosyaları otomatik taşı” dosya sayısı eşiğini gösterir. Başlangıçta `Compressed` içindeki sayı eşiğe ulaştığında veya aştığında taşıma başlar.
+
+### Paste Image Rename ile etkileşim
+
+Bu eklenti sıkıştırma veya taşıma sırasında `obsidian-paste-image-rename` eklentisini geçici olarak devre dışı bırakır. Sıkıştırılmış çıktının özgün dosyayla eşleşmesi, yeni dosyaların başka bir eklenti tarafından yeniden adlandırılmamasına bağlı olduğundan bu koruma kapatılamaz.
+
+
+Bu koruma neden gerekli
+
+Neden gereklidir:
+
+- Paste Image Rename, kasaya eklenen her görsel için oluşturulmasından yaklaşık bir saniye sonra çalışan bir `vault.on("create")` işleyicisi kaydeder. `Pasted image ` ile başlayan adları her zaman, “Handle all attachments” açıksa diğer tüm görselleri işler.
+- Çıktı klasörüne yazılan kopyalar bu işleyiciyi tetikler. Etkin Markdown görünümünde çıktıyı yeniden adlandırıp taşıma eşleşmesini bozar veya her dosya için yeniden adlandırma iletişim kutusu gösterir. Etkin görünüm yoksa her dosyada `Error: No active file found` göstererek toplu işlem sırasında arayüzü hatalarla doldurur.
+- Obsidian, bir eklentinin diğerini duraklatmasını sağlayan genel API sunmaz. Yalnızca bu eklentiyi geçici olarak kapatmak tek güvenilir çözümdür.
+
+Güvenli işleme:
+
+- Yalnızca bilinen `obsidian-paste-image-rename` kimliği ve yalnızca sıkıştırma veya taşıma sırasında etkilenir.
+- Eklenti daha sonra gerekirse yeniden denenerek geri yüklenir; durumu dışarıdan değişirse geri yüklenmez. Koruma, eklentiyi kendisinin kapatıp kapatmadığını kaydeder ve böyle bir değişiklikten sonra geri yüklemeyi denemez.
+- Genel eşdeğeri olmadığından etkinleştirme ve devre dışı bırakma Obsidian'ın dahili `app.plugins` API'sini kullanır. Özelliklerin varlığı denetlenir ve hatalar düzgün işlenir.
+
+
+
+### Gizlilik ve dış davranış
+
+- **Ağ**: çalışma zamanında ağ isteği yoktur. PNG/JPEG codec'leri `main.js` içindedir; görseller yüklenmez.
+- **Telemetri ve reklamlar**: analiz, telemetri, çökme raporu, izleme, dinamik reklam veya otomatik güncelleme yoktur.
+- **Hesaplar ve ödemeler**: hesap, abonelik, lisans anahtarı veya ödeme gerekmez. Eklenti manifestteki isteğe bağlı destek bağlantısına erişmez.
+- **Kasa dosyaları**: komutlar, otomasyon veya izin verilen köklerle seçilen görseller okunur. Çıktı yapılandırılan göreli klasöre yazılır; orijinaller yalnızca yedek sonrası belgelenen manuel veya otomatik taşıma ile değiştirilir.
+- **Yerel durum**: önbellek eklenti klasöründedir. Önbellek ve taşıma yedekleri `Vault/.local-image-compress/backups/` altındadır.
+- **Harici dosyalar**: yönetilen veriler geçerli kasada kalır. “Klasörü aç” yalnızca işletim sisteminden belgelenen klasörleri göstermesini ister, veri aktarmaz.
+- **Diğer eklentiler**: `obsidian-paste-image-rename` yukarıda açıklandığı gibi geçici kapatılabilir ve durum değişikliğinin sahibi denetlenerek geri yüklenir.
+
+### İpuçları
+- Uygun kalite aralıkları: PNG `65-80`, JPEG `75-90`.
+- Yalnızca `files/` veya `images/` gibi klasörleri sıkıştırmak için “İzin verilen kökler”i ayarlayın.
+- Kasada çok sayıda sıkıştırılmamış görsel varsa arka plan sıkıştırmasını kullanın.
+
+### Sık sorulan sorular
+**Eklenti WebAssembly modüllerinin başlatılamadığını bildiriyor.**
+Eklentiyi yeniden yükleyin. Hata tekrarlanırsa rapora Obsidian sürümünü, platformu ve konsol hatasını ekleyin.
+
+**Sıkıştırılmış dosyalar nerede saklanır?**
+Varsayılan olarak `Compressed` içinde. Orijinalleri değiştirmek için “Sıkıştırılmış dosyaları taşı”yı kullanın.
+
+**Tasarruf nasıl hesaplanır?**
+Önbellek özgün ve çıktı boyutlarını içeriyorsa hesap kesindir. Sıkıştırılmamış PNG/JPEG için sınırlı oranlı ihtiyatlı tahminler kullanılır; güncel boyutlar gerektiğinde diskten okunur.
+
+### Lisans
+GPL-3.0-or-later. Üçüncü taraf lisansları ve bildirimleri: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.uk.md b/assets/README.uk.md
new file mode 100644
index 0000000..3064b80
--- /dev/null
+++ b/assets/README.uk.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Стискайте файли PNG і JPEG безпосередньо у сховищі Obsidian на своєму комп’ютері, без хмарних сервісів чи API. Зменшуйте зайняте зображеннями місце на 30–70% без втрати якості.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Зміст
+- [Можливості](#можливості)
+- [Підтримувані формати](#підтримувані-формати)
+- [Налаштування](#налаштування)
+- [Як це працює](#як-це-працює)
+- [Зберігання даних і резервні копії](#зберігання-даних-і-резервні-копії)
+- [Автоматизація](#автоматизація)
+- [Взаємодія з Paste Image Rename](#взаємодія-з-paste-image-rename)
+- [Конфіденційність і зовнішня поведінка](#конфіденційність-і-зовнішня-поведінка)
+- [Поради](#поради)
+- [Поширені запитання](#поширені-запитання)
+- [Ліцензія](#ліцензія)
+
+### Можливості
+- **Локальне стиснення**: зображення PNG і JPEG стискаються локально.
+- **Команди**:
+ - **Стиснути всі зображення в нотатці**: обробляє зображення, на які посилається активна нотатка або які вона використовує.
+ - **Стиснути всі зображення в папці**: дає вибрати папку та стискає всі підтримувані зображення, крім папки виводу.
+ - **Стиснути всі зображення у сховищі**: сканує все сховище, крім папки виводу.
+ - **Перемістити стиснені файли**: переносить результати на місця оригіналів, спершу резервуючи оригінальну й стиснену версії.
+- **Автоматизація**:
+ - Автоматично стискати нові файли після додавання
+ - Стискати у фоні після бездіяльності, коли кількість нестиснених зображень досягне порога
+- **Інтерфейс і зручність**:
+ - Контекстне меню для файлів і папок
+ - Індикатор заощадженого місця з докладною підказкою
+ - Індикатор поступу в рядку стану
+- **Безпека й надійність**:
+ - Кеш оброблених файлів із резервними копіями
+ - Резервні копії перед переміщенням стиснених файлів з автоматичним видаленням
+
+### Підтримувані формати
+- PNG (конвеєр WASM `imagequant`)
+- JPEG/JPG (конвеєр WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF та AVIF навмисно пропускаються в цьому випуску, бо плагін не містить кодерів для цих форматів.
+
+### Налаштування
+
+| Налаштування | Опис | Тип/діапазон | Типове значення |
+|---|---|---|---|
+| Якість PNG (мін-макс) | Діапазон якості квантування PNG із втратами | 1-100 (напр. `65-80`) | `65-80` |
+| Якість JPEG | Якість стиснення JPEG | 1-95 | `85` |
+| Дозволені корені | Відносні шляхи, де дозволене стиснення. Порожньо = усе сховище | список рядків | порожньо |
+| Папка виводу | Папка для стиснених файлів | рядок | `Compressed` |
+| Автоматично стискати нові файли | Стискати нові зображення після додавання | логічне | `false` |
+| Фонове стиснення | Стискати у фоні під час бездіяльності | логічне | `true` |
+| Поріг фонового стиснення | Кількість нестиснених зображень для автоматичного запуску | 10-1000 | `50` |
+| Поріг бездіяльності | Хвилини без активності до запуску фонового стиснення | 1-60 хвилин | `2` |
+| Автоматичне зберігання резервних копій | Автоматично видаляти старі копії перед переміщенням | логічне | `false` |
+| Зберігати копії, днів | Видаляти копії переміщення, старші за N днів, коли зберігання ввімкнено | 1-365 | `30` |
+| Автоматично переміщати стиснені файли | Під час запуску повертати файли на місця оригіналів і замінювати їх | логічне | `false` |
+| Поріг автоматичного переміщення | Кількість готових файлів, що запускає автоматичне переміщення | 1-1000 | `50` |
+
+
+### Як це працює
+1. Стиснені файли зберігаються в `Compressed` зі збереженням початкової структури шляхів.
+2. Кеш записує оброблені файли та початкові розміри, щоб запобігти повторному стисненню й правильно обчислювати економію.
+3. «Перемістити стиснені файли» повертає файли з `Compressed` на початкові місця, якщо оригінал перебуває в дозволеному корені. Спершу створюється резервна копія.
+
+Дуже малі файли зазвичай пропускаються (`<5KB` для PNG і `<10KB` для JPEG).
+
+Внутрішні межі безпеки фіксовані: файли понад `100 MB` пропускаються до читання, а зображення понад `100 мільйонів` пікселів — після перевірки заголовка.
+
+### Зберігання даних і резервні копії
+- **Основний кеш:** зберігається в папці плагіна.
+- **Резервні копії кешу:** у `Vault/.local-image-compress/backups/cache/`; зберігається до 50 файлів.
+- **Резервні копії зображень:** у `Vault/.local-image-compress/backups/originals/`; створюються перед заміною оригіналів.
+
+### Автоматизація
+- Увімкнення «Фонового стиснення» показує два повзунки:
+ - Поріг фонового стиснення: 10–1000 зображень, типово 50.
+ - Поріг бездіяльності: 1–60 хвилин, типово 2.
+- Увімкнення «Зберігати копії, днів» показує повзунок строку зберігання.
+- Увімкнення «Автоматично переміщати стиснені файли» показує поріг кількості файлів. Під час запуску переміщення починається, коли кількість файлів у `Compressed` досягає або перевищує поріг.
+
+### Взаємодія з Paste Image Rename
+
+Під час стиснення або переміщення цей плагін тимчасово вимикає `obsidian-paste-image-rename`. Цей захист не можна вимкнути: зіставлення стисненого результату з оригіналом потребує, щоб інший плагін не перейменовував нові файли.
+
+
+Навіщо потрібен цей захист
+
+Навіщо це потрібно:
+
+- Paste Image Rename реєструє обробник `vault.on("create")`, який запускається для кожного доданого зображення приблизно через секунду після створення. Він завжди обробляє назви, що починаються з `Pasted image `, та всі інші зображення, якщо ввімкнено «Handle all attachments».
+- Копії в папці виводу запускають цей обробник. За активного подання Markdown він перейменовує результат і руйнує зіставлення для переміщення або показує діалог для кожного файла. Без активного подання він показує `Error: No active file found` для кожного файла й заповнює інтерфейс помилками під час пакетної обробки.
+- Obsidian не має публічного API, за допомогою якого один плагін може призупинити інший. Тимчасове вимкнення лише цього плагіна — єдине надійне рішення.
+
+Безпечна обробка:
+
+- Вплив поширюється лише на відомий ID `obsidian-paste-image-rename` і лише під час стиснення або переміщення.
+- Потім плагін відновлюється, за потреби з повторними спробами, якщо його стан не змінився ззовні. Захист записує, чи саме він вимкнув плагін, і після такої зміни не намагається його відновити.
+- Увімкнення та вимкнення використовують внутрішній API Obsidian `app.plugins`, бо публічного відповідника немає. Наявність функцій перевіряється, а помилки коректно обробляються.
+
+
+
+### Конфіденційність і зовнішня поведінка
+
+- **Мережа**: немає мережевих запитів під час роботи. Кодеки PNG/JPEG вбудовані в `main.js`; зображення не вивантажуються.
+- **Телеметрія й реклама**: немає аналітики, телеметрії, звітів про збої, відстеження, динамічної реклами чи самооновлення.
+- **Облікові записи й платежі**: не потрібні обліковий запис, підписка, ліцензійний ключ або оплата. Плагін не відкриває необов’язкове посилання фінансування з маніфесту.
+- **Файли сховища**: плагін читає зображення, вибрані командами, автоматизацією або дозволеними коренями. Вивід записує у налаштовану відносну папку, а оригінали замінює лише задокументованим ручним чи автоматичним переміщенням після створення копій.
+- **Локальний стан**: кеш міститься в папці плагіна. Копії кешу й переміщення — у `Vault/.local-image-compress/backups/`.
+- **Зовнішні файли**: керовані дані залишаються в поточному сховищі. «Відкрити папку» лише просить ОС показати задокументовані папки й не передає даних.
+- **Інші плагіни**: `obsidian-paste-image-rename` може бути тимчасово вимкнений, як описано вище, а потім відновлений із перевіркою того, хто змінив стан.
+
+### Поради
+- Доцільні діапазони якості: PNG `65-80`, JPEG `75-90`.
+- Налаштуйте «Дозволені корені», щоб стискати лише папки на кшталт `files/` або `images/`.
+- Використовуйте фонове стиснення, якщо сховище містить багато нестиснених зображень.
+
+### Поширені запитання
+**Плагін повідомляє, що модулі WebAssembly не вдалося ініціалізувати.**
+Перезавантажте плагін. Якщо помилка повториться, додайте до звіту версію Obsidian, платформу й помилку консолі.
+
+**Де зберігаються стиснені файли?**
+Типово в `Compressed`. Щоб замінити оригінали, скористайтеся «Перемістити стиснені файли».
+
+**Як обчислюється економія?**
+Результат точний, якщо кеш містить початковий і вихідний розміри. Для нестиснених PNG/JPEG використовуються консервативні оцінки з обмеженими коефіцієнтами; поточні розміри за потреби читаються з диска.
+
+### Ліцензія
+GPL-3.0-or-later. Ліцензії та повідомлення третіх сторін: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.vi.md b/assets/README.vi.md
new file mode 100644
index 0000000..c818fe3
--- /dev/null
+++ b/assets/README.vi.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+Nén tệp PNG và JPEG trực tiếp trong kho Obsidian trên máy tính, không dùng dịch vụ đám mây hay API. Giảm 30–70% dung lượng đĩa do hình ảnh chiếm dụng mà không làm giảm chất lượng.
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### Mục lục
+- [Tính năng](#tính-năng)
+- [Định dạng được hỗ trợ](#định-dạng-được-hỗ-trợ)
+- [Cài đặt](#cài-đặt)
+- [Cách hoạt động](#cách-hoạt-động)
+- [Lưu trữ dữ liệu và sao lưu](#lưu-trữ-dữ-liệu-và-sao-lưu)
+- [Tự động hóa](#tự-động-hóa)
+- [Tương tác với Paste Image Rename](#tương-tác-với-paste-image-rename)
+- [Quyền riêng tư và hoạt động bên ngoài](#quyền-riêng-tư-và-hoạt-động-bên-ngoài)
+- [Mẹo](#mẹo)
+- [Câu hỏi thường gặp](#câu-hỏi-thường-gặp)
+- [Giấy phép](#giấy-phép)
+
+### Tính năng
+- **Nén cục bộ**: nén hình ảnh PNG và JPEG ngay trên máy.
+- **Lệnh**:
+ - **Nén mọi hình ảnh trong ghi chú**: xử lý hình ảnh được tham chiếu hoặc sử dụng trong ghi chú đang hoạt động.
+ - **Nén mọi hình ảnh trong thư mục**: cho phép chọn thư mục và nén mọi ảnh được hỗ trợ, trừ thư mục đầu ra.
+ - **Nén mọi hình ảnh trong kho**: quét toàn bộ kho, trừ thư mục đầu ra.
+ - **Di chuyển tệp đã nén**: chuyển kết quả về vị trí tệp gốc; trước đó sao lưu cả phiên bản gốc và phiên bản đã nén.
+- **Tự động hóa**:
+ - Tự động nén tệp mới khi được thêm
+ - Nén nền sau khi người dùng không hoạt động và số ảnh chưa nén đạt ngưỡng
+- **Giao diện và tiện ích**:
+ - Menu ngữ cảnh cho tệp và thư mục
+ - Chỉ báo dung lượng tiết kiệm kèm chú giải chi tiết
+ - Chỉ báo tiến trình trên thanh trạng thái
+- **An toàn và tin cậy**:
+ - Bộ nhớ đệm của tệp đã xử lý cùng bản sao lưu
+ - Sao lưu trước khi di chuyển tệp đã nén, có thể tự động xóa
+
+### Định dạng được hỗ trợ
+- PNG (quy trình WASM `imagequant`)
+- JPEG/JPG (quy trình WASM `mozjpeg`)
+
+WebP, GIF, BMP, HEIC/HEIF và AVIF được chủ ý bỏ qua trong bản phát hành này vì plugin không kèm bộ mã hóa cho các định dạng đó.
+
+### Cài đặt
+
+| Cài đặt | Mô tả | Kiểu/phạm vi | Mặc định |
+|---|---|---|---|
+| Chất lượng PNG (tối thiểu-tối đa) | Phạm vi chất lượng lượng tử hóa PNG có mất dữ liệu | 1-100 (ví dụ `65-80`) | `65-80` |
+| Chất lượng JPEG | Chất lượng nén JPEG | 1-95 | `85` |
+| Gốc được phép | Đường dẫn tương đối được phép nén. Trống = toàn bộ kho | danh sách chuỗi | trống |
+| Thư mục đầu ra | Nơi lưu tệp đã nén | chuỗi | `Compressed` |
+| Tự động nén tệp mới | Nén hình ảnh mới khi được thêm | boolean | `false` |
+| Nén nền | Nén trong nền khi không hoạt động | boolean | `true` |
+| Ngưỡng nén nền | Số ảnh chưa nén cần có để tự động bắt đầu | 10-1000 | `50` |
+| Ngưỡng không hoạt động | Số phút không có hoạt động trước khi nén nền | 1-60 phút | `2` |
+| Tự động giữ bản sao lưu | Tự động xóa bản sao lưu cũ trước khi di chuyển | boolean | `false` |
+| Giữ bản sao lưu, ngày | Xóa bản sao di chuyển cũ hơn N ngày khi tính năng giữ tự động bật | 1-365 | `30` |
+| Tự động di chuyển tệp đã nén | Khi khởi động, chuyển tệp về vị trí ảnh gốc và thay thế tệp gốc | boolean | `false` |
+| Ngưỡng tự động di chuyển | Số tệp sẵn sàng cần có để kích hoạt tự động di chuyển | 1-1000 | `50` |
+
+
+### Cách hoạt động
+1. Tệp đã nén được lưu trong `Compressed` và giữ nguyên cấu trúc đường dẫn ban đầu.
+2. Bộ nhớ đệm ghi lại tệp đã xử lý và kích thước gốc để tránh nén lặp và tính chính xác dung lượng tiết kiệm.
+3. “Di chuyển tệp đã nén” đưa tệp từ `Compressed` về vị trí ban đầu nếu tệp gốc nằm trong một gốc được phép. Bản sao lưu được tạo trước.
+
+Tệp rất nhỏ thường được bỏ qua (PNG `<5KB`, JPEG `<10KB`).
+
+Giới hạn an toàn nội bộ được cố định: tệp lớn hơn `100 MB` bị bỏ qua trước khi đọc, và ảnh trên `100 triệu` pixel bị bỏ qua sau khi xác thực phần đầu tệp.
+
+### Lưu trữ dữ liệu và sao lưu
+- **Bộ nhớ đệm chính:** lưu trong thư mục plugin.
+- **Bản sao bộ nhớ đệm:** lưu tại `Vault/.local-image-compress/backups/cache/`; giữ tối đa 50 tệp.
+- **Bản sao hình ảnh:** lưu tại `Vault/.local-image-compress/backups/originals/`; được tạo trước khi thay thế tệp gốc.
+
+### Tự động hóa
+- Bật “Nén nền” sẽ hiện hai thanh trượt:
+ - Ngưỡng nén nền: 10–1000 hình ảnh, mặc định 50.
+ - Ngưỡng không hoạt động: 1–60 phút, mặc định 2.
+- Bật “Giữ bản sao lưu, ngày” sẽ hiện thanh trượt thời gian lưu.
+- Bật “Tự động di chuyển tệp đã nén” sẽ hiện ngưỡng số tệp. Khi khởi động, quá trình di chuyển bắt đầu nếu số tệp trong `Compressed` đạt hoặc vượt ngưỡng.
+
+### Tương tác với Paste Image Rename
+
+Plugin này tạm thời tắt plugin bên thứ ba `obsidian-paste-image-rename` trong khi nén hoặc di chuyển. Không thể tắt cơ chế bảo vệ này vì việc ánh xạ kết quả nén với tệp gốc yêu cầu tệp mới không bị plugin khác đổi tên.
+
+
+Vì sao cần biện pháp bảo vệ này
+
+Lý do cần thiết:
+
+- Paste Image Rename đăng ký trình xử lý `vault.on("create")`, chạy cho mỗi ảnh được thêm vào kho khoảng một giây sau khi tạo. Trình xử lý luôn tác động lên tên bắt đầu bằng `Pasted image ` và lên mọi ảnh khác nếu “Handle all attachments” được bật.
+- Bản sao nén ghi vào thư mục đầu ra sẽ kích hoạt trình xử lý. Khi có chế độ xem Markdown đang hoạt động, nó đổi tên kết quả và phá vỡ ánh xạ dùng để di chuyển, hoặc hiện hộp thoại đổi tên cho từng tệp. Khi không có chế độ xem hoạt động, nó hiện `Error: No active file found` cho từng tệp, làm giao diện đầy lỗi trong khi xử lý hàng loạt.
+- Obsidian không có API công khai để một plugin tạm dừng plugin khác. Vì vậy, tạm thời tắt duy nhất plugin này là giải pháp đáng tin cậy duy nhất.
+
+Xử lý an toàn:
+
+- Chỉ ID đã biết `obsidian-paste-image-rename` bị ảnh hưởng, và chỉ trong khi nén hoặc di chuyển.
+- Plugin được khôi phục sau đó, thử lại nếu cần, trừ khi trạng thái bị thay đổi từ bên ngoài. Cơ chế bảo vệ ghi lại việc chính nó đã tắt plugin và không cố khôi phục sau thay đổi như vậy.
+- Việc bật và tắt dùng API nội bộ `app.plugins` của Obsidian vì không có API công khai tương đương. Tính năng được kiểm tra trước và lỗi được xử lý an toàn.
+
+
+
+### Quyền riêng tư và hoạt động bên ngoài
+
+- **Mạng**: plugin không gửi yêu cầu mạng khi chạy. Bộ mã hóa/giải mã PNG/JPEG nằm trong `main.js`; hình ảnh không được tải lên.
+- **Đo từ xa và quảng cáo**: không có phân tích, đo từ xa, báo cáo sự cố, theo dõi, quảng cáo động hay tự cập nhật.
+- **Tài khoản và thanh toán**: không cần tài khoản, đăng ký, khóa giấy phép hay thanh toán. Plugin không bao giờ truy cập liên kết tài trợ tùy chọn trong manifest.
+- **Tệp trong kho**: plugin đọc ảnh được chọn bởi lệnh, tự động hóa hoặc gốc được phép. Kết quả được ghi vào thư mục tương đối đã cấu hình; tệp gốc chỉ được thay thế qua luồng di chuyển thủ công hoặc tự động đã mô tả sau khi sao lưu.
+- **Trạng thái cục bộ**: bộ nhớ đệm nằm trong thư mục plugin. Bản sao bộ nhớ đệm và di chuyển nằm dưới `Vault/.local-image-compress/backups/`.
+- **Tệp bên ngoài**: dữ liệu do plugin quản lý vẫn nằm trong kho hiện tại. “Mở thư mục” chỉ yêu cầu hệ điều hành hiển thị thư mục sao lưu đã mô tả và không truyền dữ liệu.
+- **Plugin khác**: `obsidian-paste-image-rename` có thể bị tắt tạm thời như trên, rồi được khôi phục sau khi kiểm tra bên nào đã thay đổi trạng thái.
+
+### Mẹo
+- Phạm vi chất lượng hợp lý: PNG `65-80`, JPEG `75-90`.
+- Đặt “Gốc được phép” nếu chỉ muốn nén các thư mục như `files/` hoặc `images/`.
+- Dùng nén nền khi kho có nhiều ảnh chưa nén.
+
+### Câu hỏi thường gặp
+**Plugin báo không thể khởi tạo mô-đun WebAssembly.**
+Tải lại plugin. Nếu lỗi lặp lại, hãy đưa phiên bản Obsidian, nền tảng và lỗi bảng điều khiển vào báo cáo lỗi.
+
+**Tệp đã nén được lưu ở đâu?**
+Mặc định trong `Compressed`. Để thay thế tệp gốc, dùng “Di chuyển tệp đã nén”.
+
+**Dung lượng tiết kiệm được tính như thế nào?**
+Kết quả chính xác khi bộ nhớ đệm có kích thước gốc và đầu ra. Với PNG/JPEG chưa nén, plugin dùng ước tính thận trọng với tỷ lệ có giới hạn; kích thước tệp nén hiện tại được đọc từ đĩa khi cần.
+
+### Giấy phép
+GPL-3.0-or-later. Giấy phép và thông báo của bên thứ ba: [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md).
diff --git a/assets/README.zh-cn.md b/assets/README.zh-cn.md
new file mode 100644
index 0000000..dee4577
--- /dev/null
+++ b/assets/README.zh-cn.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+无需云服务或 API,直接在电脑上的 Obsidian 仓库中压缩 PNG 和 JPEG 文件。在不牺牲画质的前提下,将图片占用的磁盘空间减少 30–70%。
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### 目录
+- [功能](#功能)
+- [支持的格式](#支持的格式)
+- [设置](#设置)
+- [工作原理](#工作原理)
+- [数据存储与备份](#数据存储与备份)
+- [自动化](#自动化)
+- [与 Paste Image Rename 的交互](#与-paste-image-rename-的交互)
+- [隐私与外部行为](#隐私与外部行为)
+- [使用提示](#使用提示)
+- [常见问题](#常见问题)
+- [许可证](#许可证)
+
+### 功能
+- **本地压缩**:在本地压缩 PNG 和 JPEG 图片。
+- **命令**:
+ - **压缩笔记中的所有图片**:处理活动笔记引用或使用的图片。
+ - **压缩文件夹中的所有图片**:选择文件夹并压缩其中所有支持的图片,不包括输出文件夹。
+ - **压缩仓库中的所有图片**:扫描整个仓库,不包括输出文件夹。
+ - **移动压缩文件**:将结果移到原文件位置;移动前备份原始版本和压缩版本。
+- **自动化**:
+ - 添加新文件时自动压缩
+ - 未压缩图片数量达到阈值且用户处于空闲状态后,在后台压缩
+- **界面与便利功能**:
+ - 文件和文件夹右键菜单
+ - 带详细提示的节省空间指示器
+ - 状态栏进度指示器
+- **安全与可靠性**:
+ - 已处理文件缓存及缓存备份
+ - 移动压缩文件前创建备份,并可自动删除
+
+### 支持的格式
+- PNG(`imagequant` WASM 流程)
+- JPEG/JPG(`mozjpeg` WASM 流程)
+
+本版本未包含 WebP、GIF、BMP、HEIC/HEIF 和 AVIF 编码器,因此会有意跳过这些格式。
+
+### 设置
+
+| 设置 | 说明 | 类型/范围 | 默认值 |
+|---|---|---|---|
+| PNG 质量(最小-最大) | 有损 PNG 量化质量范围 | 1-100(例如 `65-80`) | `65-80` |
+| JPEG 质量 | JPEG 压缩质量 | 1-95 | `85` |
+| 允许的根目录 | 允许压缩的相对路径。留空 = 整个仓库 | 字符串列表 | 留空 |
+| 输出文件夹 | 保存压缩文件的文件夹 | 字符串 | `Compressed` |
+| 自动压缩新文件 | 添加新图片时压缩 | 布尔值 | `false` |
+| 后台压缩 | 空闲时在后台压缩 | 布尔值 | `true` |
+| 后台压缩阈值 | 自动启动所需的未压缩图片数量 | 10-1000 | `50` |
+| 空闲阈值 | 启动后台压缩前无用户操作的分钟数 | 1-60 分钟 | `2` |
+| 自动备份保留 | 自动删除移动前创建的旧备份 | 布尔值 | `false` |
+| 备份保留天数 | 启用自动保留时删除早于 N 天的移动备份 | 1-365 | `30` |
+| 自动移动压缩文件 | 启动时将文件移回原图位置并替换原图 | 布尔值 | `false` |
+| 自动移动阈值 | 触发自动移动的待移动文件数量 | 1-1000 | `50` |
+
+
+### 工作原理
+1. 压缩文件保存在 `Compressed` 中,并保留原始路径结构。
+2. 缓存记录已处理文件及原始大小,以避免重复压缩并准确计算节省量。
+3. 如果原文件位于允许的根目录中,“移动压缩文件”会将文件从 `Compressed` 移回原位置。移动前会创建备份。
+
+通常会跳过很小的文件(PNG `<5KB`,JPEG `<10KB`)。
+
+内部安全限制固定:超过 `100 MB` 的文件在读取前跳过,超过 `1 亿`像素的图片在验证文件头后跳过。
+
+### 数据存储与备份
+- **主缓存:**存储在插件文件夹中。
+- **缓存备份:**存储在 `Vault/.local-image-compress/backups/cache/`;最多保留 50 个文件。
+- **图片备份:**存储在 `Vault/.local-image-compress/backups/originals/`;替换原图前创建。
+
+### 自动化
+- 启用“后台压缩”后会显示两个滑块:
+ - 后台压缩阈值:10–1000 张图片,默认 50。
+ - 空闲阈值:1–60 分钟,默认 2。
+- 启用“备份保留天数”后会显示保留期滑块。
+- 启用“自动移动压缩文件”后会显示文件数阈值。启动时,`Compressed` 中的文件数达到或超过阈值即开始移动。
+
+### 与 Paste Image Rename 的交互
+
+压缩或移动文件时,本插件会暂时禁用第三方插件 `obsidian-paste-image-rename`。此保护无法关闭,因为压缩结果与原图的映射依赖新文件不被其他插件重命名。
+
+
+为什么需要此保护
+
+为何需要此保护:
+
+- Paste Image Rename 注册了 `vault.on("create")` 处理器,在图片添加到仓库约一秒后运行。它始终处理以 `Pasted image ` 开头的文件;启用“Handle all attachments”时还会处理所有其他图片。
+- 本插件向输出文件夹写入压缩副本时会触发该处理器。存在活动 Markdown 视图时,它会重命名输出并破坏移动所需的映射,或为每个文件显示重命名对话框。没有活动视图时,它会为每个文件显示 `Error: No active file found`,使批处理期间界面充满错误。
+- Obsidian 没有供一个插件暂停另一个插件的公开 API,因此暂时只禁用该插件是唯一可靠的解决方案。
+
+安全处理方式:
+
+- 仅在压缩或移动期间影响已知 ID `obsidian-paste-image-rename`。
+- 操作后会在必要时重试恢复,除非其状态被外部更改。保护机制记录是否由自己禁用插件,发生这种更改后不会尝试恢复。
+- 启用和禁用使用 Obsidian 内部 `app.plugins` API,因为没有公开替代方案。调用前检测功能并妥善处理错误。
+
+
+
+### 隐私与外部行为
+
+- **网络**:运行时不发送网络请求。PNG/JPEG 编解码器内置于 `main.js`;图片不会上传。
+- **遥测与广告**:不包含分析、遥测、崩溃报告、跟踪、动态广告或自动更新。
+- **账户与付款**:无需账户、订阅、许可证密钥或付款。插件不会访问 manifest 中的可选赞助链接。
+- **仓库文件**:插件读取由命令、自动化或允许根目录选择的支持图片。输出写入已配置的仓库相对文件夹;仅在备份后通过文档所述的手动或自动移动替换原图。
+- **本地状态**:缓存位于插件文件夹;缓存与移动备份位于 `Vault/.local-image-compress/backups/`。
+- **外部文件**:插件管理的数据留在当前仓库中。“打开文件夹”只让操作系统显示已说明的备份文件夹,不传输数据。
+- **其他插件**:如上所述,`obsidian-paste-image-rename` 可能会暂时禁用,随后在确认状态变更归属后恢复。
+
+### 使用提示
+- 建议质量范围:PNG `65-80`,JPEG `75-90`。
+- 若只想压缩 `files/` 或 `images/` 等特定文件夹,请设置“允许的根目录”。
+- 仓库中未压缩图片较多时,请使用后台压缩。
+
+### 常见问题
+**插件提示 WebAssembly 模块初始化失败。**
+请重新加载插件。如果错误再次出现,请在错误报告中提供 Obsidian 版本、平台和控制台错误。
+
+**压缩文件保存在哪里?**
+默认保存在 `Compressed`。若要替换原图,请使用“移动压缩文件”。
+
+**如何计算节省量?**
+缓存包含原始和输出大小时,计算结果准确。对于未压缩的 PNG/JPEG,插件采用有上限比例的保守估算;需要时从磁盘读取当前压缩文件大小。
+
+### 许可证
+GPL-3.0-or-later. 第三方许可证与声明:[THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
diff --git a/assets/README.zh-tw.md b/assets/README.zh-tw.md
new file mode 100644
index 0000000..aa73271
--- /dev/null
+++ b/assets/README.zh-tw.md
@@ -0,0 +1,132 @@
+# Local Image Compress
+
+不需雲端服務或 API,直接在電腦上的 Obsidian 儲存庫中壓縮 PNG 與 JPEG 檔案。在不犧牲畫質的前提下,將圖片占用的磁碟空間減少 30–70%。
+
+Read in your language: [English](../README.md) • [العربية](README.ar.md) • [Deutsch](README.de.md) • [Español](README.es.md) • [فارسی](README.fa.md) • [Français](README.fr.md) • [Bahasa Indonesia](README.id.md) • [Italiano](README.it.md) • [Nederlands](README.nl.md) • [Polski](README.pl.md) • [Português](README.pt.md) • [Português (Brasil)](README.pt-br.md) • [Русский](README.ru.md) • [ไทย](README.th.md) • [Türkçe](README.tr.md) • [Українська](README.uk.md) • [Tiếng Việt](README.vi.md) • [日本語](README.ja.md) • [한국어](README.ko.md) • [中文简体](README.zh-cn.md) • [中文繁體](README.zh-tw.md)
+
+
+
+### 目錄
+- [功能](#功能)
+- [支援的格式](#支援的格式)
+- [設定](#設定)
+- [運作方式](#運作方式)
+- [資料儲存與備份](#資料儲存與備份)
+- [自動化](#自動化)
+- [與 Paste Image Rename 的互動](#與-paste-image-rename-的互動)
+- [隱私與外部行為](#隱私與外部行為)
+- [使用提示](#使用提示)
+- [常見問題](#常見問題)
+- [授權](#授權)
+
+### 功能
+- **本機壓縮**:在本機壓縮 PNG 與 JPEG 圖片。
+- **命令**:
+ - **壓縮筆記中的所有圖片**:處理目前使用中的筆記引用或使用的圖片。
+ - **壓縮資料夾中的所有圖片**:選擇資料夾並壓縮其中所有支援的圖片,不包含輸出資料夾。
+ - **壓縮儲存庫中的所有圖片**:掃描整個儲存庫,不包含輸出資料夾。
+ - **移動壓縮檔案**:將結果移到原始檔案位置;移動前備份原始版本與壓縮版本。
+- **自動化**:
+ - 新增檔案時自動壓縮
+ - 未壓縮圖片數量達到門檻且使用者處於閒置狀態後,在背景壓縮
+- **介面與便利功能**:
+ - 檔案與資料夾右鍵選單
+ - 附詳細說明的節省空間指示器
+ - 狀態列進度指示器
+- **安全性與可靠性**:
+ - 已處理檔案快取及快取備份
+ - 移動壓縮檔案前建立備份,並可自動刪除
+
+### 支援的格式
+- PNG(`imagequant` WASM 流程)
+- JPEG/JPG(`mozjpeg` WASM 流程)
+
+本版本未包含 WebP、GIF、BMP、HEIC/HEIF 與 AVIF 編碼器,因此會刻意略過這些格式。
+
+### 設定
+
+| 設定 | 說明 | 類型/範圍 | 預設值 |
+|---|---|---|---|
+| PNG 品質(最小-最大) | 有損 PNG 量化品質範圍 | 1-100(例如 `65-80`) | `65-80` |
+| JPEG 品質 | JPEG 壓縮品質 | 1-95 | `85` |
+| 允許的根目錄 | 允許壓縮的相對路徑。留空 = 整個儲存庫 | 字串清單 | 留空 |
+| 輸出資料夾 | 儲存壓縮檔案的資料夾 | 字串 | `Compressed` |
+| 自動壓縮新檔案 | 新圖片加入時壓縮 | 布林值 | `false` |
+| 背景壓縮 | 閒置時在背景壓縮 | 布林值 | `true` |
+| 背景壓縮門檻 | 自動啟動所需的未壓縮圖片數量 | 10-1000 | `50` |
+| 閒置門檻 | 啟動背景壓縮前無使用者操作的分鐘數 | 1-60 分鐘 | `2` |
+| 自動備份保留 | 自動刪除移動前建立的舊備份 | 布林值 | `false` |
+| 備份保留天數 | 啟用自動保留時刪除早於 N 天的移動備份 | 1-365 | `30` |
+| 自動移動壓縮檔案 | 啟動時將檔案移回原圖位置並取代原圖 | 布林值 | `false` |
+| 自動移動門檻 | 觸發自動移動的待移動檔案數量 | 1-1000 | `50` |
+
+
+### 運作方式
+1. 壓縮檔案儲存在 `Compressed` 中,並保留原始路徑結構。
+2. 快取記錄已處理檔案及原始大小,以避免重複壓縮並正確計算節省量。
+3. 若原始檔案位於允許的根目錄中,“移動壓縮檔案”會將檔案從 `Compressed` 移回原始位置。移動前會建立備份。
+
+通常會略過很小的檔案(PNG `<5KB`,JPEG `<10KB`)。
+
+內部安全限制固定:超過 `100 MB` 的檔案在讀取前略過,超過 `1 億` 像素的圖片在驗證檔頭後略過。
+
+### 資料儲存與備份
+- **主快取:**儲存在外掛資料夾中。
+- **快取備份:**儲存在 `Vault/.local-image-compress/backups/cache/`;最多保留 50 個檔案。
+- **圖片備份:**儲存在 `Vault/.local-image-compress/backups/originals/`;取代原圖前建立。
+
+### 自動化
+- 啟用“背景壓縮”後會顯示兩個滑桿:
+ - 背景壓縮門檻:10–1000 張圖片,預設 50。
+ - 閒置門檻:1–60 分鐘,預設 2。
+- 啟用“備份保留天數”後會顯示保留期限滑桿。
+- 啟用“自動移動壓縮檔案”後會顯示檔案數門檻。啟動時,`Compressed` 中的檔案數達到或超過門檻便開始移動。
+
+### 與 Paste Image Rename 的互動
+
+壓縮或移動檔案時,本外掛會暫時停用第三方外掛 `obsidian-paste-image-rename`。此保護無法關閉,因為壓縮結果與原圖的對應關係取決於新檔案不被其他外掛重新命名。
+
+
+為什麼需要此保護
+
+為何需要此保護:
+
+- Paste Image Rename 註冊了 `vault.on("create")` 處理常式,在圖片新增至儲存庫約一秒後執行。它一律處理以 `Pasted image ` 開頭的檔案;啟用“Handle all attachments”時還會處理所有其他圖片。
+- 本外掛向輸出資料夾寫入壓縮複本時會觸發該處理常式。有作用中的 Markdown 檢視時,它會重新命名輸出並破壞移動所需的對應關係,或為每個檔案顯示重新命名對話方塊。沒有作用中的檢視時,它會為每個檔案顯示 `Error: No active file found`,使批次處理期間介面充斥錯誤。
+- Obsidian 沒有供一個外掛暫停另一個外掛的公開 API,因此暫時只停用該外掛是唯一可靠的解決方案。
+
+安全處理方式:
+
+- 僅在壓縮或移動期間影響已知 ID `obsidian-paste-image-rename`。
+- 操作後會在必要時重試還原,除非其狀態被外部變更。保護機制記錄是否由自身停用外掛,發生這種變更後不會嘗試還原。
+- 啟用與停用使用 Obsidian 內部 `app.plugins` API,因為沒有公開替代方案。呼叫前偵測功能並妥善處理錯誤。
+
+
+
+### 隱私與外部行為
+
+- **網路**:執行時不發出網路要求。PNG/JPEG 編解碼器內建於 `main.js`;圖片不會上傳。
+- **遙測與廣告**:不包含分析、遙測、當機報告、追蹤、動態廣告或自動更新。
+- **帳戶與付款**:無需帳戶、訂閱、授權金鑰或付款。外掛不會存取 manifest 中的選用贊助連結。
+- **儲存庫檔案**:外掛讀取由命令、自動化或允許根目錄選擇的支援圖片。輸出寫入已設定的儲存庫相對資料夾;僅在備份後透過文件所述的手動或自動移動取代原圖。
+- **本機狀態**:快取位於外掛資料夾;快取與移動備份位於 `Vault/.local-image-compress/backups/`。
+- **外部檔案**:外掛管理的資料留在目前儲存庫中。“開啟資料夾”只讓作業系統顯示已說明的備份資料夾,不傳輸資料。
+- **其他外掛**:如上所述,`obsidian-paste-image-rename` 可能會暫時停用,隨後在確認狀態變更的來源後還原。
+
+### 使用提示
+- 建議品質範圍:PNG `65-80`,JPEG `75-90`。
+- 若只想壓縮 `files/` 或 `images/` 等特定資料夾,請設定“允許的根目錄”。
+- 儲存庫中未壓縮圖片較多時,請使用背景壓縮。
+
+### 常見問題
+**外掛顯示 WebAssembly 模組初始化失敗。**
+請重新載入外掛。若錯誤再次發生,請在錯誤報告中提供 Obsidian 版本、平台與主控台錯誤。
+
+**壓縮檔案儲存在哪裡?**
+預設儲存在 `Compressed`。若要取代原圖,請使用“移動壓縮檔案”。
+
+**如何計算節省量?**
+快取包含原始與輸出大小時,計算結果準確。對於未壓縮的 PNG/JPEG,外掛採用比例設有上限的保守估算;需要時從磁碟讀取目前壓縮檔案大小。
+
+### 授權
+GPL-3.0-or-later. 第三方授權與聲明:[THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
diff --git a/eslint.obsidian.config.mjs b/eslint.obsidian.config.mjs
index ea75fbe..85133a2 100644
--- a/eslint.obsidian.config.mjs
+++ b/eslint.obsidian.config.mjs
@@ -1,6 +1,7 @@
// Blocking mirror of the current Obsidian community-plugin submission scanner.
import fs from "node:fs";
import path from "node:path";
+import json from "@eslint/json";
import tsParser from "@typescript-eslint/parser";
import tseslint from "typescript-eslint";
import obsidianmd from "eslint-plugin-obsidianmd";
@@ -10,6 +11,9 @@ const isDevLayout = path.basename(import.meta.dirname) === "source-recovery"
&& fs.existsSync(path.join(import.meta.dirname, "..", "manifest.json"));
const sourcePrefix = isDevLayout ? "source-recovery/" : "";
const sourceFiles = [`${sourcePrefix}src-ts/**/*.ts`];
+const disabledObsidianJsonRules = Object.fromEntries(
+ Object.keys(obsidianmd.rules).map((ruleName) => [`obsidianmd/${ruleName}`, "off"])
+);
export default [
{
@@ -41,9 +45,13 @@ export default [
}
},
{
- files: [`${sourcePrefix}src-ts/i18n.ts`],
+ files: [`${sourcePrefix}src-ts/locales/en.json`],
+ plugins: { json },
+ language: "json/json",
rules: {
- "obsidianmd/ui/sentence-case-locale-module": "error"
+ ...disabledObsidianJsonRules,
+ "no-irregular-whitespace": "off",
+ "obsidianmd/ui/sentence-case-json": "error"
}
}
];
diff --git a/package-lock.json b/package-lock.json
index 172e646..c460eb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "obsidian-local-image-compress",
"version": "1.0.1",
"devDependencies": {
+ "@eslint/json": "0.14.0",
"@jsquash/jpeg": "1.6.0",
"@jsquash/png": "3.1.1",
"@types/node": "25.7.0",
diff --git a/package.json b/package.json
index 537414c..5c82612 100644
--- a/package.json
+++ b/package.json
@@ -22,15 +22,18 @@
"typecheck": "tsc --noEmit",
"validate:manifest": "node scripts/validate-manifest.js",
"validate:license": "node scripts/validate-license.js",
+ "validate:readmes": "node scripts/validate-readme-locales.js",
+ "qa:i18n": "node scripts/validate-i18n.js",
"validate:wasm": "node scripts/validate-wasm.js",
"qa:runtime": "node scripts/run-runtime-qa.js",
"test:ts": "npm run build:ts && npm run smoke:ts",
"verify:root-ts": "node scripts/verify-root-ts.js",
"verify:release": "node scripts/verify-release.js",
"prepare:release": "node scripts/prepare-release.js",
+ "prepare:release-notes": "node scripts/prepare-release-notes.js",
"build:root": "node scripts/build-root.js",
"test:release": "npm test && npm run build:root && npm run audit:policy:bundle && npm run verify:release && npm run verify:root-ts",
- "test": "npm run lint && npm run audit:policy && npm run lint:eslint && npm run lint:obsidian && npm run test:ts && npm run typecheck && npm run validate:manifest && npm run validate:license && npm run validate:wasm",
+ "test": "npm run lint && npm run audit:policy && npm run lint:eslint && npm run lint:obsidian && npm run qa:i18n && npm run test:ts && npm run typecheck && npm run validate:manifest && npm run validate:license && npm run validate:readmes && npm run validate:wasm",
"release": "node -e \"process.stdout.write('Release tag must be exact numeric SemVer: ' + require('./package.json').version + '\\n')\""
},
"keywords": [
@@ -52,6 +55,7 @@
},
"homepage": "https://github.com/haperone/local-image-compress#readme",
"devDependencies": {
+ "@eslint/json": "0.14.0",
"@jsquash/jpeg": "1.6.0",
"@jsquash/png": "3.1.1",
"@types/node": "25.7.0",
diff --git a/scripts/audit-policy.js b/scripts/audit-policy.js
index d34b183..b9f93f0 100644
--- a/scripts/audit-policy.js
+++ b/scripts/audit-policy.js
@@ -51,7 +51,7 @@ const combinedSource = files.map((file) => file.source).join("\n");
const packageJson = JSON.parse(fs.readFileSync(path.join(sourceRoot, "package.json"), "utf8"));
const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, "manifest.json"), "utf8"));
const readme = fs.readFileSync(path.join(repoRoot, "README.md"), "utf8");
-const readmeRu = fs.readFileSync(path.join(repoRoot, "README.ru.md"), "utf8");
+const readmeRu = fs.readFileSync(path.join(repoRoot, "assets", "README.ru.md"), "utf8");
const releaseAuditPath = path.join(repoRoot, "OBSIDIAN_RELEASE_AUDIT.md");
assert(!isDevLayout || fs.existsSync(releaseAuditPath), "DEV policy audit requires OBSIDIAN_RELEASE_AUDIT.md");
const releaseAudit = fs.existsSync(releaseAuditPath) ? fs.readFileSync(releaseAuditPath, "utf8") : null;
@@ -114,13 +114,13 @@ for (const token of [
for (const token of [
"Сеть",
"Телеметрия и реклама",
- "Аккаунты и платежи",
- "Файлы vault",
+ "Учётные записи и платежи",
+ "Файлы хранилища",
"Локальное состояние",
"Внешние файлы",
"Другие плагины"
]) {
- assert(readmeRu.includes(token), `README.ru.md is missing policy disclosure: ${token}`);
+ assert(readmeRu.includes(token), `assets/README.ru.md is missing policy disclosure: ${token}`);
}
const expectedFsBoundaryFiles = [
diff --git a/scripts/prepare-release-notes.js b/scripts/prepare-release-notes.js
new file mode 100644
index 0000000..baef5aa
--- /dev/null
+++ b/scripts/prepare-release-notes.js
@@ -0,0 +1,26 @@
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const { resolveRepositoryLayout } = require("./repository-layout");
+
+const { repositoryRoot } = resolveRepositoryLayout(__dirname);
+const changelogPath = path.join(repositoryRoot, "CHANGELOG.md");
+const outputPath = path.join(repositoryRoot, "release-notes.md");
+
+if (!fs.existsSync(changelogPath)) {
+ throw new Error("CHANGELOG.md is missing; run DEV to PROD promotion before creating a release");
+}
+
+const changelog = fs.readFileSync(changelogPath, "utf8").replace(/\r\n/g, "\n");
+const heading = "## Unreleased";
+const headingIndex = changelog.indexOf(heading);
+const contentStart = headingIndex < 0 ? -1 : changelog.indexOf("\n", headingIndex) + 1;
+const nextSection = contentStart <= 0 ? -1 : changelog.indexOf("\n## ", contentStart);
+const notes = contentStart <= 0 ? "" : changelog.slice(contentStart, nextSection < 0 ? changelog.length : nextSection).trim();
+if (!notes || notes.includes("_No changes._")) {
+ throw new Error("CHANGELOG.md has no unreleased changes");
+}
+
+fs.writeFileSync(outputPath, `${notes}\n`);
+process.stdout.write(`Prepared GitHub release notes from CHANGELOG.md: ${outputPath}\n`);
diff --git a/scripts/run-runtime-qa.js b/scripts/run-runtime-qa.js
index 49d2a12..af2985f 100644
--- a/scripts/run-runtime-qa.js
+++ b/scripts/run-runtime-qa.js
@@ -10,12 +10,15 @@ const { repositoryRoot: repoRoot, sourceRoot } = resolveRepositoryLayout();
const runnerPath = path.join(__dirname, "runtime-qa.js");
const reportDir = path.join(repoRoot, "qa-backups");
const pluginInstallDir = resolvePluginInstallDirectory();
+const vaultRoot = path.resolve(pluginInstallDir, "..", "..", "..");
const dataJsonPath = path.join(pluginInstallDir, "data.json");
const preQaSettingsBackupPath = path.join(reportDir, "pre-qa-data-backup.json");
const QA_STATE_MARKER = "QA-LIC-Runtime-";
+const QA_ARTIFACT_PARENTS = ["", "Compressed", "files", "files/Compressed"];
const cliPath = process.env.OBSIDIAN_CLI || (
process.platform === "win32" ? "C:\\Program Files\\Obsidian\\Obsidian.com" : "obsidian"
);
+const DEFAULT_OBSIDIAN_CLI_TIMEOUT_MS = 10 * 60 * 1000;
const args = new Set(process.argv.slice(2));
const skipReload = args.has("--skip-reload");
@@ -48,13 +51,18 @@ function timestampForFile() {
}
function runObsidianCli(cliArgs, description, options = {}) {
+ const timeoutMs = getObsidianCliTimeoutMs();
const result = spawnSync(cliPath, cliArgs, {
cwd: pluginInstallDir,
encoding: "utf8",
windowsHide: true,
- maxBuffer: 64 * 1024 * 1024
+ maxBuffer: 64 * 1024 * 1024,
+ timeout: timeoutMs
});
if (result.error) {
+ if (result.error.code === "ETIMEDOUT") {
+ throw new Error(`Obsidian CLI timed out during ${description} after ${timeoutMs}ms.\nCLI: ${cliPath}`);
+ }
throw new Error(`Failed to run Obsidian CLI for ${description}: ${result.error.message}\nCLI: ${cliPath}`);
}
if (result.status !== 0 && !options.allowFailure) {
@@ -67,6 +75,11 @@ function runObsidianCli(cliArgs, description, options = {}) {
return result;
}
+function getObsidianCliTimeoutMs() {
+ const timeoutMs = Number(process.env.OBSIDIAN_CLI_TIMEOUT_MS);
+ return Number.isFinite(timeoutMs) && timeoutMs > 0 ? Math.trunc(timeoutMs) : DEFAULT_OBSIDIAN_CLI_TIMEOUT_MS;
+}
+
function extractEvalPayload(stdout) {
const markerIndex = stdout.lastIndexOf("=>");
if (markerIndex === -1) {
@@ -171,6 +184,41 @@ function restoreSettingsIfPolluted() {
}
}
+function removePathInsideVault(targetPath) {
+ const resolvedBase = path.resolve(vaultRoot);
+ const resolvedTarget = path.resolve(targetPath);
+ const relative = path.relative(resolvedBase, resolvedTarget);
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
+ throw new Error(`Refusing to remove outside vault: ${targetPath}`);
+ }
+ fs.rmSync(resolvedTarget, { recursive: true, force: true });
+}
+
+function cleanupRuntimeQaVaultArtifacts() {
+ for (const parentRel of QA_ARTIFACT_PARENTS) {
+ const parentPath = parentRel ? path.join(vaultRoot, ...parentRel.split("/")) : vaultRoot;
+ let entries = [];
+ try {
+ entries = fs.readdirSync(parentPath, { withFileTypes: true });
+ } catch (error) {
+ if (error.code !== "ENOENT") {
+ console.warn(`Could not read runtime QA artifact parent ${parentPath}: ${error.message}`);
+ }
+ continue;
+ }
+ for (const entry of entries) {
+ if (!entry.isDirectory() || !entry.name.startsWith(QA_STATE_MARKER)) {
+ continue;
+ }
+ try {
+ removePathInsideVault(path.join(parentPath, entry.name));
+ } catch (error) {
+ console.warn(`Could not remove runtime QA artifact ${entry.name}: ${error.message}`);
+ }
+ }
+ }
+}
+
async function main() {
if (args.has("--help") || args.has("-h")) {
printHelp();
@@ -242,4 +290,5 @@ main().catch((error) => {
process.exitCode = 1;
}).finally(() => {
restoreSettingsIfPolluted();
+ cleanupRuntimeQaVaultArtifacts();
});
diff --git a/scripts/runtime-qa.js b/scripts/runtime-qa.js
index fb62a34..1ee85f3 100644
--- a/scripts/runtime-qa.js
+++ b/scripts/runtime-qa.js
@@ -6,7 +6,9 @@
const pluginId = "local-image-compress";
const startedAt = new Date().toISOString();
const runStamp = startedAt.replace(/[:.]/g, "-").replace(/Z$/, "");
- const qaRoot = `QA-LIC-Runtime-${runStamp}`;
+ const qaStateMarker = "QA-LIC-Runtime-";
+ const qaRoot = `${qaStateMarker}${runStamp}`;
+ const staleQaArtifactParents = ["", "Compressed", "files", "files/Compressed"];
const report = {
startedAt,
pluginId,
@@ -103,13 +105,62 @@
assert(relative && !relative.startsWith("..") && !path.isAbsolute(relative), "Refusing to remove outside vault", { targetAbs, vaultBase });
await fs.promises.rm(resolvedTarget, { recursive: true, force: true });
};
+ const removeQaRunRootsUnder = async (parentRel) => {
+ const parentAbs = parentRel ? absolute(parentRel) : vaultBase;
+ let entries = [];
+ try {
+ entries = await fs.promises.readdir(parentAbs, { withFileTypes: true });
+ } catch (error) {
+ if (error?.code !== "ENOENT") {
+ recordWarning("cleanup.read-qa-artifact-parent", { parentRel, error: serializeError(error) });
+ }
+ return;
+ }
+ await Promise.all(entries
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(qaStateMarker))
+ .map((entry) => safeRmAbs(path.join(parentAbs, entry.name))));
+ };
+ const removeRuntimeQaVaultArtifacts = async () => {
+ await safeRmAbs(absolute(qaRoot));
+ await Promise.all(staleQaArtifactParents.map((parentRel) => removeQaRunRootsUnder(parentRel)));
+ };
+ const isQaOwnedVaultPath = (vaultRel) => {
+ const normalized = normalizeVaultPath(vaultRel);
+ return normalized === qaRoot || normalized.startsWith(`${qaRoot}/`);
+ };
+ const getEscapedQaFiles = (files) => Array.from(files || [])
+ .filter((file) => file?.path && !isQaOwnedVaultPath(file.path))
+ .map((file) => file.path);
+ const assertQaOwnedFiles = (files, action) => {
+ const escaped = getEscapedQaFiles(files);
+ assert(escaped.length === 0, `Runtime QA attempted ${action} outside ${qaRoot}`, {
+ escaped: escaped.slice(0, 25),
+ escapedCount: escaped.length,
+ allowedRoots: p.settings?.allowedRoots,
+ outputFolder: p.settings?.outputFolder
+ });
+ };
+ const assertQaRuntimeScope = async (label) => {
+ const allowedRoots = Array.isArray(p.settings?.allowedRoots) ? p.settings.allowedRoots.map(normalizeVaultPath) : [];
+ assert(allowedRoots.length === 1 && normalizeVaultPathRootForQa(allowedRoots[0]) === qaRoot, `${label}: runtime QA allowedRoots escaped QA root`, {
+ allowedRoots: p.settings?.allowedRoots
+ });
+ assert(normalizeVaultPath(p.getOutputFolder()) === `${qaRoot}/Compressed`, `${label}: runtime QA output folder escaped QA root`, {
+ outputFolder: p.getOutputFolder()
+ });
+ if (p.imageIndex?.isReady?.()) {
+ assertQaOwnedFiles(p.getAllImageFiles(), `${label} image-index scope`);
+ }
+ };
+ function normalizeVaultPathRootForQa(vaultRel) {
+ return normalizeVaultPath(vaultRel).replace(/^\/+|\/+$/g, "");
+ }
// --- QA settings safety net (must survive a hard-killed run) ---
// This harness overwrites the user's real data.json. The in-renderer restore in the finally
// below only runs if execution reaches it; a hard process kill skips it and leaves QA values
// behind. So: auto-heal data.json if a previous crashed run left QA state in it, then snapshot
// the clean settings to disk *before* any mutation so recovery is always possible.
- const qaStateMarker = "QA-LIC-Runtime-";
const preQaSettingsBackupPath = path.join(p.getPluginDirectory(), "qa-backups", "pre-qa-data-backup.json");
const settingsLookLikeQaState = (settings) => {
const outputFolder = String(settings?.outputFolder || "");
@@ -139,6 +190,42 @@
const originalCacheData = clone(p.cache.cacheData);
const originalOpenPath = electron.shell.openPath;
const originalNewFileDelay = p.newFileQueue?.AUTO_COMPRESS_DELAY;
+ const originalCompressFile = p.compressFile;
+ const originalRunCompressionBatch = p.runCompressionBatch;
+ if (typeof originalCompressFile === "function") {
+ p.compressFile = async (file, ...args) => {
+ assertQaOwnedFiles([file], "compressFile");
+ return await originalCompressFile.call(p, file, ...args);
+ };
+ restoreStack.push(async () => {
+ p.compressFile = originalCompressFile;
+ });
+ }
+ if (typeof originalRunCompressionBatch === "function") {
+ p.runCompressionBatch = async (files, ...args) => {
+ assertQaOwnedFiles(files, "runCompressionBatch");
+ return await originalRunCompressionBatch.call(p, files, ...args);
+ };
+ restoreStack.push(async () => {
+ p.runCompressionBatch = originalRunCompressionBatch;
+ });
+ }
+ const originalGetBackupStoragePaths = p.getBackupStoragePaths;
+ if (typeof originalGetBackupStoragePaths === "function") {
+ const qaBackupStorageRoot = absolute(joinVault(qaRoot, "IsolatedBackupStorage"));
+ p.getBackupStoragePaths = () => {
+ const originalPaths = originalGetBackupStoragePaths.call(p);
+ return {
+ ...originalPaths,
+ root: qaBackupStorageRoot,
+ backupsRoot: path.join(qaBackupStorageRoot, "backups"),
+ originalFilesBackups: path.join(qaBackupStorageRoot, "backups", "originals")
+ };
+ };
+ restoreStack.push(async () => {
+ p.getBackupStoragePaths = originalGetBackupStoragePaths;
+ });
+ }
restoreStack.push(async () => {
try {
p.statusBarController?.closeMenu?.();
@@ -158,7 +245,7 @@
await p.statusBarController?.update?.();
});
cleanupStack.push(async () => {
- await safeRmAbs(absolute(qaRoot));
+ await removeRuntimeQaVaultArtifacts();
});
function patchMethod(target, name, replacement) {
@@ -428,6 +515,7 @@
}
async function runCommand(commandId, timeoutMs = 90000) {
+ await assertQaRuntimeScope(`before command ${commandId}`);
const result = app.commands.executeCommandById(`${pluginId}:${commandId}`);
if (result && typeof result.then === "function") {
await result;
@@ -436,7 +524,7 @@
}
async function setupIsolatedState() {
- await safeRmAbs(absolute(qaRoot));
+ await removeRuntimeQaVaultArtifacts();
await ensureFolder(qaRoot);
p.statusBarController?.closeMenu?.();
p.closeManagedModals?.();
@@ -450,8 +538,6 @@
autoBackgroundCompression: false,
autoBackgroundThreshold: 10,
inactivityThresholdMinutes: 1,
- cacheRetentionMonths: 2,
- autoCleanupGhostsOnStart: false,
autoBackupsRetentionEnabled: false,
autoBackupsRetentionDays: 7,
autoMoveCompressedEnabled: false,
@@ -460,6 +546,7 @@
await p.saveSettings();
await p.cache.clearCache();
await p.rebuildImageIndex("runtime-qa-start");
+ await assertQaRuntimeScope("setupIsolatedState");
await p.statusBarController.update();
if (p.newFileQueue) {
p.newFileQueue.AUTO_COMPRESS_DELAY = 25;
@@ -477,14 +564,13 @@
autoBackgroundCompression: false,
autoBackgroundThreshold: 10,
inactivityThresholdMinutes: 1,
- cacheRetentionMonths: 2,
- autoCleanupGhostsOnStart: false,
autoBackupsRetentionEnabled: false,
autoBackupsRetentionDays: 7,
autoMoveCompressedEnabled: false,
autoMoveCompressedThreshold: 1
};
await p.saveSettings();
+ await assertQaRuntimeScope("restoreQaDefaults");
}
try {
@@ -535,10 +621,10 @@
const dropdowns = Array.from(root.querySelectorAll("select"));
report.metrics.settingsLabels = labels;
assert(labels.length >= 24, "Settings labels count is too low", { count: labels.length, labels });
- assert(buttons.length >= 9, "Settings buttons count is too low", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
+ assert(buttons.length >= 8, "Settings buttons count is too low", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
assert(textInputs.length >= 2, "Expected PNG and output-folder text inputs", { count: textInputs.length });
- assert(rangeInputs.length >= 6, "Expected all remaining settings sliders", { count: rangeInputs.length });
- assert(toggles.length >= 5, "Expected all settings toggles", { count: toggles.length });
+ assert(rangeInputs.length >= 5, "Expected all remaining settings sliders", { count: rangeInputs.length });
+ assert(toggles.length >= 4, "Expected all settings toggles", { count: toggles.length });
assert(dropdowns.length >= 0, "Dropdown query failed");
const savingsTarget = root.querySelector(".tiny-local-savings-tooltip-target");
if (savingsTarget) {
@@ -564,6 +650,22 @@
};
});
+ await check("localization: selected language reaches commands and settings", async () => {
+ const expectedLocale = p.constructor.currentLang;
+ const expected = {
+ en: { command: "Compress all images in note", setting: "PNG quality (min-max)" },
+ ru: { command: "Сжать все изображения в заметке", setting: "Качество PNG (мин-макс)" },
+ uk: { command: "Стиснути всі зображення в нотатці", setting: "Якість PNG (мін-макс)" }
+ }[expectedLocale];
+ assert(expected, "Plugin selected an unsupported built-in language", { expectedLocale });
+ const command = app.commands.commands[`${pluginId}:compress-images-in-note`];
+ const root = await openSettings();
+ const labels = Array.from(root.querySelectorAll(".setting-item-name")).map((el) => el.textContent.trim());
+ assert(command?.name?.endsWith(expected.command), "Registered command is not localized", { command: command?.name, expected });
+ assert(labels.includes(expected.setting), "Settings DOM is not localized", { expected: expected.setting, labels });
+ return { locale: expectedLocale, command: command.name, setting: expected.setting };
+ });
+
await check("accessibility: theme variables, motion overrides, and popout ownership", async () => {
const originalThemeClasses = {
light: document.body.classList.contains("theme-light"),
@@ -646,86 +748,92 @@
});
await check("settings: text inputs and sliders update runtime settings", async () => {
- const root = await openSettings();
- const textInputs = Array.from(root.querySelectorAll("input[type='text']"));
- const rangeInputs = Array.from(root.querySelectorAll("input[type='range']"));
- assert(textInputs.length >= 2 && rangeInputs.length >= 6, "Settings controls missing");
+ try {
+ const root = await openSettings();
+ const textInputs = Array.from(root.querySelectorAll("input[type='text']"));
+ const rangeInputs = Array.from(root.querySelectorAll("input[type='range']"));
+ assert(textInputs.length >= 2 && rangeInputs.length >= 5, "Settings controls missing");
- dispatchInput(textInputs[0], "42-58");
- assert(p.settings.pngQuality.min === 42 && p.settings.pngQuality.max === 58, "PNG quality text input did not update settings", p.settings.pngQuality);
- const oldOutput = p.settings.outputFolder;
- dispatchInput(textInputs[1], "../bad-output");
- await sleep(100);
- assert(p.settings.outputFolder === oldOutput, "Invalid output folder was accepted", { oldOutput, current: p.settings.outputFolder });
- dispatchInput(textInputs[1], `${qaRoot}/Compressed`);
- assert(p.settings.outputFolder === `${qaRoot}/Compressed`, "Output folder text input did not update settings", p.settings.outputFolder);
+ dispatchInput(textInputs[0], "42-58");
+ assert(p.settings.pngQuality.min === 42 && p.settings.pngQuality.max === 58, "PNG quality text input did not update settings", p.settings.pngQuality);
+ const oldOutput = p.settings.outputFolder;
+ dispatchInput(textInputs[1], "../bad-output");
+ await sleep(100);
+ assert(p.settings.outputFolder === oldOutput, "Invalid output folder was accepted", { oldOutput, current: p.settings.outputFolder });
+ dispatchInput(textInputs[1], `${qaRoot}/Compressed`);
+ assert(p.settings.outputFolder === `${qaRoot}/Compressed`, "Output folder text input did not update settings", p.settings.outputFolder);
- const sliderAssertions = [
- [0, 55, () => p.settings.jpegQuality === 55, "jpegQuality"],
- [1, 20, () => p.settings.autoBackgroundThreshold === 20 && p.backgroundCompressionService.AUTO_BACKGROUND_THRESHOLD === 20, "autoBackgroundThreshold"],
- [2, 3, () => p.settings.inactivityThresholdMinutes === 3 && p.backgroundCompressionService.USER_INACTIVITY_THRESHOLD === 180000, "inactivityThresholdMinutes"],
- [3, 9, () => p.settings.autoBackupsRetentionDays === 9, "autoBackupsRetentionDays"],
- [4, 2, () => p.settings.autoMoveCompressedThreshold === 2, "autoMoveCompressedThreshold"],
- [5, 4, () => p.settings.cacheRetentionMonths === 4, "cacheRetentionMonths"]
- ];
- for (const [index, value, predicate, key] of sliderAssertions) {
- dispatchInput(rangeInputs[index], value);
- await sleep(80);
- assert(predicate(), `Slider did not update ${key}`, { key, value, current: p.settings[key] });
+ const sliderAssertions = [
+ [0, 55, () => p.settings.jpegQuality === 55, "jpegQuality"],
+ [1, 20, () => p.settings.autoBackgroundThreshold === 20 && p.backgroundCompressionService.AUTO_BACKGROUND_THRESHOLD === 20, "autoBackgroundThreshold"],
+ [2, 3, () => p.settings.inactivityThresholdMinutes === 3 && p.backgroundCompressionService.USER_INACTIVITY_THRESHOLD === 180000, "inactivityThresholdMinutes"],
+ [3, 9, () => p.settings.autoBackupsRetentionDays === 9, "autoBackupsRetentionDays"],
+ [4, 2, () => p.settings.autoMoveCompressedThreshold === 2, "autoMoveCompressedThreshold"]
+ ];
+ for (const [index, value, predicate, key] of sliderAssertions) {
+ dispatchInput(rangeInputs[index], value);
+ await sleep(80);
+ assert(predicate(), `Slider did not update ${key}`, { key, value, current: p.settings[key] });
+ }
+
+ await sleep(800);
+ return {
+ pngQuality: clone(p.settings.pngQuality),
+ outputFolder: p.settings.outputFolder,
+ slidersTested: sliderAssertions.length
+ };
+ } finally {
+ await restoreQaDefaults();
}
-
- await sleep(800);
- await restoreQaDefaults();
- return {
- pngQuality: p.settings.pngQuality,
- outputFolder: p.settings.outputFolder,
- slidersTested: sliderAssertions.length
- };
});
await check("settings: toggles update runtime settings", async () => {
await restoreQaDefaults();
- const root = await openSettings();
- const freshToggles = Array.from(root.querySelectorAll(".checkbox-container"));
- assert(freshToggles.length >= 5, "Settings toggles missing", { count: freshToggles.length });
- await setToggle(freshToggles[0], true);
- assert(p.settings.autoCompressNewFiles === true, "autoCompressNewFiles toggle did not update");
- await setToggle(freshToggles[1], true);
- assert(p.settings.autoBackgroundCompression === true, "background toggle did not update");
- await setToggle(freshToggles[2], true);
- assert(p.settings.autoBackupsRetentionEnabled === true, "retention toggle did not update");
- await setToggle(freshToggles[3], true);
- assert(p.settings.autoMoveCompressedEnabled === true, "auto-move toggle did not update");
- await setToggle(freshToggles[4], true);
- assert(p.settings.autoCleanupGhostsOnStart === true, "ghost cleanup toggle did not update");
- await sleep(650);
- await restoreQaDefaults();
- return {
- togglesTested: 5
- };
+ try {
+ const root = await openSettings();
+ const freshToggles = Array.from(root.querySelectorAll(".checkbox-container"));
+ assert(freshToggles.length >= 4, "Settings toggles missing", { count: freshToggles.length });
+ await setToggle(freshToggles[0], true);
+ assert(p.settings.autoCompressNewFiles === true, "autoCompressNewFiles toggle did not update");
+ await setToggle(freshToggles[1], true);
+ assert(p.settings.autoBackgroundCompression === true, "background toggle did not update");
+ await setToggle(freshToggles[2], true);
+ assert(p.settings.autoBackupsRetentionEnabled === true, "retention toggle did not update");
+ await setToggle(freshToggles[3], true);
+ assert(p.settings.autoMoveCompressedEnabled === true, "auto-move toggle did not update");
+ await sleep(650);
+ return {
+ togglesTested: 4
+ };
+ } finally {
+ await restoreQaDefaults();
+ }
});
await check("settings: allowed roots add modal and clear icon work", async () => {
- p.settings.allowedRoots = [`${qaRoot}/`];
- await p.saveSettings();
- let root = await openSettings();
- let buttons = Array.from(root.querySelectorAll("button"));
- const rootPill = root.querySelector(".tiny-local-roots-pill");
- assert(rootPill?.tagName === "BUTTON" && !!rootPill.getAttribute("aria-label"), "Allowed-root removal pill is not an accessible button");
- const addButton = buttons.find((button) => !button.classList.contains("tiny-local-roots-pill"));
- assert(!!addButton, "Allowed-roots Add button missing");
- clickElement(addButton);
- await sleep(300);
- assert(!!document.querySelector(".modal-container .modal"), "Allowed-roots modal did not open");
- await closeTopModal();
- root = await openSettings();
- const clearIcon = root.querySelector(".tiny-local-roots-clear");
- assert(!!clearIcon, "Allowed-roots clear icon missing");
- clickElement(clearIcon);
- await sleep(400);
- assert(Array.isArray(p.settings.allowedRoots) && p.settings.allowedRoots.length === 0, "Allowed roots were not cleared", p.settings.allowedRoots);
- await restoreQaDefaults();
- return { modalOpened: true, clearWorked: true };
+ try {
+ p.settings.allowedRoots = [`${qaRoot}/`];
+ await p.saveSettings();
+ let root = await openSettings();
+ let buttons = Array.from(root.querySelectorAll("button"));
+ const rootPill = root.querySelector(".tiny-local-roots-pill");
+ assert(rootPill?.tagName === "BUTTON" && !!rootPill.getAttribute("aria-label"), "Allowed-root removal pill is not an accessible button");
+ const addButton = buttons.find((button) => !button.classList.contains("tiny-local-roots-pill"));
+ assert(!!addButton, "Allowed-roots Add button missing");
+ clickElement(addButton);
+ await sleep(300);
+ assert(!!document.querySelector(".modal-container .modal"), "Allowed-roots modal did not open");
+ await closeTopModal();
+ root = await openSettings();
+ const clearIcon = root.querySelector(".tiny-local-roots-clear");
+ assert(!!clearIcon, "Allowed-roots clear icon missing");
+ clickElement(clearIcon);
+ await sleep(400);
+ assert(Array.isArray(p.settings.allowedRoots) && p.settings.allowedRoots.length === 0, "Allowed roots were not cleared", p.settings.allowedRoots);
+ return { modalOpened: true, clearWorked: true };
+ } finally {
+ await restoreQaDefaults();
+ }
});
await check("settings: cache restore dropdown is populated and dispatches restore", async () => {
@@ -774,7 +882,6 @@
clearCache: 0,
rebuildIndex: 0,
statusUpdate: 0,
- clearGhosts: 0,
move: 0,
clearBackups: 0,
openPath: [],
@@ -785,7 +892,6 @@
clearCache: p.cache.clearCache,
rebuildImageIndex: p.rebuildImageIndex,
statusUpdate: p.statusBarController.update,
- cleanupGhostEntries: p.cleanupGhostEntries,
moveCompressedToFiles: p.moveService.moveCompressedToFiles,
clearOriginalFilesBackups: p.clearOriginalFilesBackups,
showCacheBackupsList: p.showCacheBackupsList,
@@ -795,7 +901,6 @@
p.cache.clearCache = async () => { calls.clearCache++; };
p.rebuildImageIndex = async () => { calls.rebuildIndex++; };
p.statusBarController.update = async () => { calls.statusUpdate++; };
- p.cleanupGhostEntries = async () => { calls.clearGhosts++; return 1; };
p.moveService.moveCompressedToFiles = async () => { calls.move++; };
p.clearOriginalFilesBackups = async () => { calls.clearBackups++; };
p.showCacheBackupsList = async () => { calls.showBackupsList++; };
@@ -807,16 +912,15 @@
const root = await openSettings();
const buttons = Array.from(root.querySelectorAll("button"))
.filter((button) => !button.classList.contains("tiny-local-roots-pill"));
- assert(buttons.length >= 9, "Expected at least 9 settings buttons", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
+ assert(buttons.length >= 8, "Expected at least 8 settings buttons", { count: buttons.length, texts: buttons.map((button) => button.textContent.trim()) });
const buttonIndexes = {
refreshUncompressed: 1,
clearCache: 2,
refreshCache: 3,
- clearGhosts: 4,
- moveCompressed: 5,
- clearImageBackups: 6,
- openImageBackups: 7,
- openCacheBackups: 8
+ moveCompressed: 4,
+ clearImageBackups: 5,
+ openImageBackups: 6,
+ openCacheBackups: 7
};
for (const index of Object.values(buttonIndexes)) {
assert(buttons[index], `Missing button index ${index}`, { count: buttons.length });
@@ -825,7 +929,6 @@
}
assert(calls.refresh === 2, "Refresh buttons did not dispatch forceRefreshCache twice", calls);
assert(calls.clearCache === 1, "Clear cache button did not dispatch", calls);
- assert(calls.clearGhosts === 1, "Clear ghosts button did not dispatch", calls);
assert(calls.move === 1, "Move button did not dispatch", calls);
assert(calls.clearBackups === 1, "Clear image backups button did not dispatch", calls);
assert(calls.openPath.length === 1, "Open image backups button did not open path", calls);
@@ -835,7 +938,6 @@
p.cache.clearCache = originals.clearCache;
p.rebuildImageIndex = originals.rebuildImageIndex;
p.statusBarController.update = originals.statusUpdate;
- p.cleanupGhostEntries = originals.cleanupGhostEntries;
p.moveService.moveCompressedToFiles = originals.moveCompressedToFiles;
p.clearOriginalFilesBackups = originals.clearOriginalFilesBackups;
p.showCacheBackupsList = originals.showCacheBackupsList;
@@ -848,7 +950,7 @@
return calls;
});
- await check("cache: clear, ghost cleanup, stale prune, backup, and restore work", async () => {
+ await check("cache: clear, compaction, backup, and restore work", async () => {
await p.cache.clearCache();
assert(p.cache.getCacheStats().total === 0, "clearCache did not empty cache", p.cache.getCacheStats());
const tiny = await createSmallJpeg(`${qaRoot}/Cache/tiny-too-small.jpg`);
@@ -856,44 +958,48 @@
await waitForCompressionIdle();
let fresh = await p.cache.getFreshEntryForFile(tiny);
assert(fresh?.entry?.state === "skipped" && fresh.entry.skipReason === "too_small", "Too-small image was not cached as skipped", fresh?.entry);
+ const legacySource = await createSmallJpeg(`${qaRoot}/Cache/legacy-source.jpg`);
- const ghostMtime = Date.now();
- const ghostKey = p.cache.buildCacheKey(`${qaRoot}/Cache/missing.jpg`, "ghost-md5", ghostMtime);
- p.cache.cacheData.entries[ghostKey] = {
+ const missingMtime = Date.now();
+ const missingModernKey = p.cache.buildCacheKey(`${qaRoot}/Cache/missing.jpg`, "missing-modern", missingMtime);
+ const missingLegacyKey = `legacy:${qaRoot}/Cache/missing-legacy.jpg`;
+ const existingLegacyKey = `legacy:${legacySource.path}`;
+ const staleKey = p.cache.buildCacheKey(tiny.path, "stale-modern", Math.max(1, tiny.stat.mtime - 1));
+ p.cache.cacheData.entries[missingModernKey] = {
path: `${qaRoot}/Cache/missing.jpg`,
- md5: "ghost-md5",
- mtime: ghostMtime,
- timestamp: ghostMtime,
- lastAccessMs: ghostMtime,
- state: "processed",
+ md5: "missing-modern",
+ mtime: missingMtime,
+ timestamp: missingMtime,
+ state: "skipped",
originalSize: 100,
- sourceMtime: ghostMtime,
+ sourceMtime: missingMtime,
sourceSize: 100
};
- await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
- const ghostCount = await p.getGhostEntriesCount();
- const removedGhosts = await p.cleanupGhostEntries();
- assert(ghostCount >= 1 && removedGhosts >= 1, "Ghost cleanup did not remove ghost entry", { ghostCount, removedGhosts });
-
- const stale = await createSmallJpeg(`${qaRoot}/Cache/stale-source.jpg`);
- const staleMtime = Date.now() - 90 * 24 * 60 * 60 * 1000;
- const staleKey = p.cache.buildCacheKey(stale.path, "stale-md5", staleMtime);
- p.cache.cacheData.entries[staleKey] = {
- path: stale.path,
- md5: "stale-md5",
- mtime: staleMtime,
- timestamp: staleMtime,
- lastAccessMs: staleMtime,
+ p.cache.cacheData.entries[missingLegacyKey] = {
+ path: `${qaRoot}/Cache/missing-legacy.jpg`,
state: "processed",
- originalSize: stale.stat.size,
- sourceMtime: staleMtime,
- sourceSize: stale.stat.size,
- stateUpdatedAt: staleMtime,
- processedMtime: staleMtime
+ timestamp: 1
+ };
+ p.cache.cacheData.entries[existingLegacyKey] = {
+ path: legacySource.path,
+ state: "processed",
+ originalSize: legacySource.stat.size,
+ timestamp: 1
+ };
+ p.cache.cacheData.entries[staleKey] = {
+ path: tiny.path,
+ md5: "stale-modern",
+ state: "skipped",
+ sourceMtime: Math.max(1, tiny.stat.mtime - 1),
+ sourceSize: tiny.stat.size + 1,
+ timestamp: 1
};
await p.cache.saveCache({ mergeDiskEntries: false, authoritative: true });
- const pruned = await p.cache.pruneStaleCacheEntries(1, Date.now());
- assert(pruned >= 1, "Stale cache entry was not pruned", { pruned });
+ const compacted = await p.cache.compactCache();
+ assert(compacted.missingFilesRemoved === 1 && compacted.supersededRemoved === 1, "Cache compaction returned wrong result", compacted);
+ assert(!p.cache.cacheData.entries[missingModernKey] && !p.cache.cacheData.entries[staleKey], "Cache compaction kept removable modern entries", compacted);
+ assert(!!p.cache.cacheData.entries[missingLegacyKey] && !!p.cache.cacheData.entries[fresh.cacheKey], "Cache compaction removed legacy or current entries", compacted);
+ assert(!!p.cache.cacheData.entries[existingLegacyKey] && await p.cache.getFreshEntryForFile(legacySource) === null, "Existing legacy cache entry was deleted or still treated as processed");
const markerMtime = Date.now();
const markerKey = p.cache.buildCacheKey(`${qaRoot}/Cache/backup-marker.jpg`, "backup-marker", markerMtime);
@@ -919,7 +1025,7 @@
assert(restored === true, "restoreFromBackup returned false", { targetBackup });
assert(!!p.cache.cacheData.entries[markerKey], "Cache backup did not restore marker entry", { targetBackup });
assert(p.cache.isValidBackupFileName("../bad.json") === false, "Invalid backup filename was accepted");
- return { ghostCount, removedGhosts, pruned, backupCount: backups.length, targetBackup };
+ return { compacted, backupCount: backups.length, targetBackup };
});
await check("compression: direct JPG, JPEG, and PNG produce smaller outputs and pending cache entries", async () => {
@@ -1097,15 +1203,17 @@
});
await check("compression: new-file auto compression queue drains and compresses", async () => {
- p.settings.autoCompressNewFiles = true;
- await p.saveSettings();
- const file = await createImage(`${qaRoot}/Auto/auto-new-file.jpg`, "jpg", 11);
- await p.handleNewFile(file);
- await sleep(100);
- await p.drainNewFileCompressionBatch();
- const result = await assertCompressed(file, "auto new file");
- await restoreQaDefaults();
- return result;
+ try {
+ p.settings.autoCompressNewFiles = true;
+ await p.saveSettings();
+ const file = await createImage(`${qaRoot}/Auto/auto-new-file.jpg`, "jpg", 11);
+ await p.handleNewFile(file);
+ await sleep(100);
+ await p.drainNewFileCompressionBatch();
+ return await assertCompressed(file, "auto new file");
+ } finally {
+ await restoreQaDefaults();
+ }
});
await check("validation: unsupported and too-small files are rejected safely", async () => {
@@ -1129,6 +1237,9 @@
const aria = p.statusBarItem?.getAttribute?.("aria-label") || "";
assert(statusText.includes("/"), "Status bar text does not contain counts", { statusText, aria });
assert(aria.includes(statusText.trim()), "Status bar aria-label does not include status text", { statusText, aria });
+ assert(!p.statusBarItem?.getAttribute?.("title"), "Status bar item has a native title tooltip that can overlap Obsidian's tooltip", {
+ title: p.statusBarItem?.getAttribute?.("title")
+ });
assert(p.statusBarItem?.getAttribute?.("role") === "button", "Status bar item is missing role=button");
assert(p.statusBarItem?.getAttribute?.("tabindex") === "0", "Status bar item is missing tabindex=0");
assert(p.statusBarItem?.getAttribute?.("aria-haspopup") === "menu", "Status bar item is missing aria-haspopup=menu");
@@ -1328,22 +1439,25 @@
});
await check("move: auto-move threshold moves fresh compressed output automatically", async () => {
- p.settings.autoMoveCompressedEnabled = true;
- p.settings.autoMoveCompressedThreshold = 1;
- await p.saveSettings();
- const file = await createImage(`${qaRoot}/AutoMove/auto-move.jpg`, "jpg", 13);
- const originalBefore = (await statRel(file.path)).size;
- await p.compressFile(file);
- await waitForCompressionIdle(120000);
- const originalAfter = (await statRel(file.path)).size;
- assert(originalAfter < originalBefore, "Auto-move did not replace original with smaller compressed file", { originalBefore, originalAfter });
- assert(!(await existsRel(outputRelFor(file.path))), "Auto-move left compressed output behind", { output: outputRelFor(file.path) });
- const movedCacheEntry = getStoredCacheEntryWithState(file.path, "moved");
- assert(!!movedCacheEntry, "Auto-move did not mark cache entry moved", {
- entries: p.cache.getEntriesForPath(file.path)
- });
- await restoreQaDefaults();
- return { originalBefore, originalAfter };
+ try {
+ p.settings.autoMoveCompressedEnabled = true;
+ p.settings.autoMoveCompressedThreshold = 1;
+ await p.saveSettings();
+ const file = await createImage(`${qaRoot}/AutoMove/auto-move.jpg`, "jpg", 13);
+ const originalBefore = (await statRel(file.path)).size;
+ await p.compressFile(file);
+ await waitForCompressionIdle(120000);
+ const originalAfter = (await statRel(file.path)).size;
+ assert(originalAfter < originalBefore, "Auto-move did not replace original with smaller compressed file", { originalBefore, originalAfter });
+ assert(!(await existsRel(outputRelFor(file.path))), "Auto-move left compressed output behind", { output: outputRelFor(file.path) });
+ const movedCacheEntry = getStoredCacheEntryWithState(file.path, "moved");
+ assert(!!movedCacheEntry, "Auto-move did not mark cache entry moved", {
+ entries: p.cache.getEntriesForPath(file.path)
+ });
+ return { originalBefore, originalAfter };
+ } finally {
+ await restoreQaDefaults();
+ }
});
await check("backups: clear original-files backups is safe when redirected to isolated storage", async () => {
diff --git a/scripts/smoke-ts.js b/scripts/smoke-ts.js
index 0ab7a7a..c45772b 100644
--- a/scripts/smoke-ts.js
+++ b/scripts/smoke-ts.js
@@ -204,6 +204,8 @@ if (!fs.existsSync(artifact)) {
}
const source = fs.readFileSync(artifact, "utf8");
+const runRuntimeQaWrapperSource = fs.readFileSync(path.join(root, "scripts", "run-runtime-qa.js"), "utf8");
+const devVaultSource = isDevLayout ? fs.readFileSync(path.join(repositoryRoot, "scripts", "dev-vault.mjs"), "utf8") : "";
const requiredArtifactTokens = [
"require(\"obsidian\")",
@@ -241,6 +243,27 @@ assert(
"TypeScript artifact still spawns native compressor binaries"
);
+assert(
+ runRuntimeQaWrapperSource.includes("OBSIDIAN_CLI_TIMEOUT_MS")
+ && /spawnSync\(cliPath, cliArgs,[\s\S]*timeout: timeoutMs/.test(runRuntimeQaWrapperSource),
+ "Runtime QA wrapper does not bound Obsidian CLI calls with a timeout"
+);
+assert(
+ runRuntimeQaWrapperSource.includes("cleanupRuntimeQaVaultArtifacts")
+ && runRuntimeQaWrapperSource.includes("QA_ARTIFACT_PARENTS")
+ && runRuntimeQaWrapperSource.includes("removePathInsideVault")
+ && runRuntimeQaWrapperSource.includes("cleanupRuntimeQaVaultArtifacts();"),
+ "Runtime QA wrapper does not clean marker-owned Vault artifacts after hard timeout"
+);
+
+if (isDevLayout) {
+ assert(
+ devVaultSource.includes("OBSIDIAN_CLI_TIMEOUT_MS")
+ && /spawnSync\(cliPath, cliArgs,[\s\S]*timeout: timeoutMs/.test(devVaultSource),
+ "DEV vault helper does not bound Obsidian CLI calls with a timeout"
+ );
+}
+
assert(
!source.includes(".innerHTML"),
"TypeScript artifact still writes localized content through innerHTML"
@@ -296,6 +319,18 @@ const backgroundCompressionServiceSource = fs.readFileSync(path.join(sourceTsRoo
const statusBarControllerSource = fs.readFileSync(path.join(sourceTsRoot, "status-bar-controller.ts"), "utf8");
const stylesSource = fs.readFileSync(path.join(repositoryRoot, "styles.css"), "utf8");
const runtimeQaSource = fs.readFileSync(path.join(root, "scripts", "runtime-qa.js"), "utf8");
+assert(
+ runtimeQaSource.includes("removeRuntimeQaVaultArtifacts")
+ && runtimeQaSource.includes("staleQaArtifactParents")
+ && runtimeQaSource.includes("entry.name.startsWith(qaStateMarker)")
+ && runtimeQaSource.includes("IsolatedBackupStorage")
+ && runtimeQaSource.includes("originalFilesBackups: path.join(qaBackupStorageRoot")
+ && runtimeQaSource.includes("Runtime QA attempted ${action} outside ${qaRoot}")
+ && runtimeQaSource.includes("originalRunCompressionBatch")
+ && runtimeQaSource.includes("assertQaRuntimeScope(`before command ${commandId}`)")
+ && (runtimeQaSource.match(/finally \{\n await restoreQaDefaults\(\);/g) || []).length >= 5,
+ "Runtime QA no longer cleans QA-owned Vault artifacts, isolates backups, or fails closed outside its QA root"
+);
const pluginGuardSource = fs.readFileSync(path.join(sourceTsRoot, "plugin-guard-service.ts"), "utf8");
const savingsCalculatorSource = fs.readFileSync(path.join(sourceTsRoot, "savings-calculator.ts"), "utf8");
const commandRegistrySource = fs.readFileSync(path.join(sourceTsRoot, "services", "command-registry.ts"), "utf8");
@@ -304,6 +339,13 @@ const migrationRunnerSource = fs.readFileSync(path.join(sourceTsRoot, "services"
const folderSelectorModalSource = fs.readFileSync(path.join(sourceTsRoot, "services", "folder-selector-modal.ts"), "utf8");
const newFileQueueSource = fs.readFileSync(path.join(sourceTsRoot, "services", "new-file-queue.ts"), "utf8");
const cacheBackupsViewSource = fs.readFileSync(path.join(sourceTsRoot, "services", "cache-backups-view.ts"), "utf8");
+const localesIndexSource = fs.readFileSync(path.join(sourceTsRoot, "locales", "index.ts"), "utf8");
+const englishLocale = JSON.parse(fs.readFileSync(path.join(sourceTsRoot, "locales", "en.json"), "utf8"));
+const i18nCatalogSource = fs.readdirSync(path.join(sourceTsRoot, "locales"))
+ .filter((fileName) => fileName.endsWith(".json"))
+ .sort()
+ .map((fileName) => fs.readFileSync(path.join(sourceTsRoot, "locales", fileName), "utf8"))
+ .join("\n");
const serviceSources = [
"background-compression-service.ts",
"image-scanner.ts",
@@ -314,7 +356,7 @@ const serviceSources = [
"status-bar-controller.ts"
].map((fileName) => fs.readFileSync(path.join(sourceTsRoot, fileName), "utf8"));
const readmeSource = fs.readFileSync(path.join(repositoryRoot, "README.md"), "utf8");
-const readmeRuSource = fs.readFileSync(path.join(repositoryRoot, "README.ru.md"), "utf8");
+const readmeRuSource = fs.readFileSync(path.join(repositoryRoot, "assets", "README.ru.md"), "utf8");
const releasePolicySource = fs.readFileSync(path.join(repositoryRoot, "RELEASE_POLICY.md"), "utf8");
const releaseReadinessPath = path.join(repositoryRoot, "RELEASE_READINESS.md");
const obsidianReleaseAuditPath = path.join(repositoryRoot, "OBSIDIAN_RELEASE_AUDIT.md");
@@ -340,6 +382,7 @@ const validateManifestSource = fs.readFileSync(path.join(root, "scripts", "valid
const buildRootSource = fs.readFileSync(path.join(root, "scripts", "build-root.js"), "utf8");
const buildTsSource = fs.readFileSync(path.join(root, "scripts", "build-ts.js"), "utf8");
const prepareReleaseSource = fs.readFileSync(path.join(root, "scripts", "prepare-release.js"), "utf8");
+const prepareReleaseNotesSource = fs.readFileSync(path.join(root, "scripts", "prepare-release-notes.js"), "utf8");
const verifyReleaseSource = fs.readFileSync(path.join(root, "scripts", "verify-release.js"), "utf8");
const classWideGatesSource = fs.readFileSync(path.join(root, "scripts", "class-wide-gates.js"), "utf8");
const auditPolicySource = fs.readFileSync(path.join(root, "scripts", "audit-policy.js"), "utf8");
@@ -363,6 +406,7 @@ const combinedTsSource = [
compressorSource,
concurrencyLimiterSource,
i18nSource,
+ localesIndexSource,
imageScannerSource,
moveServiceSource,
pluginGuardSource,
@@ -516,7 +560,8 @@ assert(settingsSource.includes("function getInternalWorkerPoolSize") && settings
assert(!settingsTabSource.includes("auto.pasteRenameGuard.timeout") && !settingsTabSource.includes("settings.workerPoolSize") && !settingsTabSource.includes("settings.compressionTimeout") && !settingsTabSource.includes("settings.wasmInitTimeout") && !settingsTabSource.includes("settings.maxInputSize") && !settingsTabSource.includes("settings.maxImagePixels"), "Technical settings returned to the settings UI");
assert(!utilsSource.includes("|| /^[a-zA-Z]:/.test(normalizedPath)") && !settingsSource.includes("const outputFolder = typeof source.outputFolder"), "Low-severity utility/settings cleanup regressions are present");
assert(settingsSource.includes("inactivityThresholdMinutes") && settingsTabSource.includes("auto.bg.inactivity"), "Settings are missing configurable inactivity threshold support");
-assert(settingsSource.includes("cacheRetentionMonths") && settingsTabSource.includes("stats.cache.retention"), "Settings are missing configurable cache retention support");
+assert(settingsSource.includes('"cacheRetentionMonths"') && settingsSource.includes('"autoCleanupGhostsOnStart"'), "Settings normalization does not silently drop removed cache-maintenance fields");
+assert(!settingsTabSource.includes("cacheRetentionMonths") && !settingsTabSource.includes("autoCleanupGhostsOnStart"), "Removed cache-maintenance controls remain in settings UI");
assert(compressorSource.includes("applySettings(settings") && pluginSource.includes("this.compressor?.applySettings?.(this.settings)"), "Compressor runtime limits are not applied from normalized settings");
assert(compressorSource.includes("app: App | null") && !compressorSource.includes("app: any | null"), "Compressor app reference is still typed as any");
assert(!settingsSource.includes("integer || 4"), "Worker pool sizing still contains a dead integer fallback");
@@ -570,27 +615,41 @@ assert(settingsTabSource.includes("debouncedSaveSettings"), "Settings tab qualit
assert(!/add(?:Slider|Text)\([\s\S]{0,700}await this\.plugin\.saveSettings\(\)/.test(settingsTabSource), "Settings tab slider/text controls still save settings on every change event");
assert(settingsTabSource.includes("flushPendingSaveSettings") && settingsTabSource.includes("_renderRootsCleanups"), "Settings tab does not flush debounced saves or clean allowed-root pill listeners");
assert(settingsTabSource.includes("class AllowedRootsFolderSuggestModal extends obsidian.FuzzySuggestModal") && !settingsTabSource.includes("new (class extends obsidian.FuzzySuggestModal"), "Allowed-roots picker still uses an anonymous FuzzySuggestModal subclass");
-assert(settingsTabSource.includes("normalizeAllowedRootSelection") && settingsTabSource.includes("paths.allowedRoots.cannotAddRoot") && i18nSource.includes("paths.allowedRoots.cannotAddRoot"), "Allowed-roots picker does not handle root selection explicitly");
+assert(settingsTabSource.includes("normalizeAllowedRootSelection") && settingsTabSource.includes("paths.allowedRoots.cannotAddRoot") && i18nCatalogSource.includes("paths.allowedRoots.cannotAddRoot"), "Allowed-roots picker does not handle root selection explicitly");
assert(settingsTabSource.includes("tiny-local-warning-block") && stylesSource.includes(".tiny-local-warning-block") && settingsTabSource.includes("tiny-local-roots-pill") && stylesSource.includes(".tiny-local-roots-pill") && !settingsTabSource.includes("warn.style.") && !settingsTabSource.includes("pill.style."), "Settings tab static warning/root-pill styles still live inline");
assert(settingsTabSource.includes('list.createEl("button", { text: root, cls: "badge tiny-local-roots-pill"') && settingsTabSource.includes('pill.setAttribute("aria-label"'), "Allowed-root removal pills are not keyboard-accessible buttons");
assert(!settingsTabSource.includes("debouncedWorkerPoolRestartNotice") && !settingsTabSource.includes("settings.workerPoolSize.restartNote"), "Settings tab still contains worker-pool restart UI for removed technical settings");
-assert(settingsTabSource.includes("runButtonTask") && settingsTabSource.includes("common.refreshing") && settingsTabSource.includes("common.clearing") && i18nSource.includes("common.refreshing") && i18nSource.includes("common.clearing"), "Settings async stats buttons are missing loading/disabled state");
-assert(settingsTabSource.includes("stats.ghosts.clearedCount") && i18nSource.includes("stats.ghosts.clearedCount") && !settingsTabSource.includes("stats.ghosts.name\").toLowerCase()"), "Ghost cleanup Notice still concatenates translated fragments");
+assert(settingsTabSource.includes("runButtonTask") && settingsTabSource.includes("common.refreshing") && settingsTabSource.includes("common.clearing") && i18nCatalogSource.includes("common.refreshing") && i18nCatalogSource.includes("common.clearing"), "Settings async stats buttons are missing loading/disabled state");
+assert(
+ settingsTabSource.includes('`${t(this.plugin.app, "stats.uncompressed.ready")}: ${stats.uncompressedImages}`')
+ && settingsTabSource.includes('`${t(this.plugin.app, "move.ready")}: ${stats.compressedFilesCount}`')
+ && !settingsTabSource.includes('`${stats.uncompressedImages} ${t(this.plugin.app, "stats.uncompressed.ready")}`'),
+ "Settings count labels must precede values so translations do not require numeric plural forms"
+);
+assert(
+ savingsCalculatorSource.includes('` (${t(this.plugin.app, "tooltip.savings.estimated")}: ${savings.estimatedFiles})`')
+ && !savingsCalculatorSource.includes('`${savings.estimatedFiles} ${t(this.plugin.app, "tooltip.savings.estimated")}`'),
+ "Estimated-file labels must precede values so translations do not require numeric plural forms"
+);
+assert(!settingsTabSource.includes("stats.ghosts") && !i18nCatalogSource.includes("stats.ghosts") && !i18nCatalogSource.includes("stats.cache.retention"), "Removed ghost/retention strings remain in runtime UI locales");
assert(settingsTabSource.includes("applySubsettingVisibility") && (settingsTabSource.match(/\.settingEl\.toggle\(/g) || []).length === 1, "Settings conditional rows still duplicate raw settingEl.toggle calls");
assert(settingsTabSource.includes("registerDomEvent(container, 'mouseenter'") && !settingsTabSource.includes("container.addEventListener('mouseenter'"), "Savings tooltip listeners are not registered through the plugin lifecycle");
assert(settingsTabSource.includes("tooltipRoot") && !settingsTabSource.includes("activeDocument.body.appendChild") && !settingsTabSource.includes("activeDocument.body.removeChild"), "Savings tooltip DOM operations lack a body guard");
-assert(settingsTabSource.includes("showSettingsOperationError") && settingsTabSource.includes("Move compressed files action failed") && settingsTabSource.includes("Cache restore action failed") && i18nSource.includes("notice.operationFailed"), "Settings async actions are missing shared error feedback");
+assert(settingsTabSource.includes("showSettingsOperationError") && settingsTabSource.includes("Move compressed files action failed") && settingsTabSource.includes("Cache restore action failed") && i18nCatalogSource.includes("notice.operationFailed"), "Settings async actions are missing shared error feedback");
assert(settingsTabSource.includes("getSavingsBarWidths") && settingsTabSource.includes("Number.isFinite(savings.savedSize)") && settingsTabSource.includes("Number.isFinite(savings.originalSize)"), "Savings bar widths are missing finite-number guards");
assert(stylesSource.includes(".tiny-local-savings-tooltip-wrapper") && stylesSource.includes(".tiny-local-savings-tooltip-target") && !settingsTabSource.includes("tooltip.style.position") && !settingsTabSource.includes("tooltip.style.zIndex") && !settingsTabSource.includes("tooltip.style.pointerEvents") && !settingsTabSource.includes("container.style.cursor"), "Savings tooltip static styles still live inline");
// Obsidian plugin guidelines compliance (2026-05-31): GL1 heading wording, GL2 setHeading not raw h3, GL3 tooltip position via CSS custom properties
-assert(!/"section\.paths":\s*"[^"]*[Ss]ettings/.test(i18nSource) && !/"section\.paths":\s*"Настройки/.test(i18nSource), "section.paths heading still contains a redundant 'settings' word (Obsidian guideline #7)");
+assert(!/"section\.paths":\s*"[^"]*[Ss]ettings/.test(i18nCatalogSource) && !/"section\.paths":\s*"Настройки/.test(i18nCatalogSource), "section.paths heading still contains a redundant 'settings' word (Obsidian guideline #7)");
assert(!pluginSource.includes('createEl("h3"') && !pluginSource.includes("createEl('h3'"), "Backups modal still renders a raw h3 heading instead of Setting().setHeading() (Obsidian guideline #8)");
assert(settingsTabSource.includes("tooltip.setCssProps({") && settingsTabSource.includes('"--local-image-compress-savings-tooltip-left"') && settingsTabSource.includes('"--local-image-compress-savings-tooltip-top"') && !settingsTabSource.includes("tooltip.style.left") && !settingsTabSource.includes("tooltip.style.top") && stylesSource.includes("--local-image-compress-savings-tooltip-left") && stylesSource.includes("--local-image-compress-savings-tooltip-top"), "Savings tooltip position is not driven by CSS custom properties (Obsidian guideline #23)");
assert(i18nSource.includes("preloadExternalLanguages") && pluginSource.includes("await preloadExternalLanguages") && !i18nSource.includes("fs.existsSync") && !i18nSource.includes("fs.statSync") && !i18nSource.includes("fs.readFileSync"), "i18n still performs sync filesystem reads in the t() hot path");
+assert(i18nSource.includes("export const I18N = BUILTIN_I18N") && localesIndexSource.includes("export const BUILTIN_I18N") && (localesIndexSource.match(/\.json";/g) || []).length === 21, "README UI locales are not statically bundled into main.js");
+assert(i18nSource.includes('"zh-hans": "zh-cn"') && i18nSource.includes('"zh-hant": "zh-tw"') && i18nSource.includes("replace(/_/g, \"-\")"), "Regional Obsidian language aliases are incomplete");
+assert(i18nSource.includes("getLanguage as getObsidianLanguage") && i18nSource.includes('requireApiVersion("1.8.7")') && !i18nSource.includes("app?.getLanguage"), "i18n must detect the locale through Obsidian's guarded module-level getLanguage API");
assert(!i18nSource.includes("process.cwd()") && i18nSource.includes("if (!pluginDir)") && i18nSource.includes("return {};") && i18nSource.includes("pluginDir ? LOADED_LANGS"), "i18n external-language resolution does not fail closed when the vault plugin directory is unavailable");
assert(i18nSource.includes("TranslationParams") && i18nSource.includes("interpolateTranslation") && !/t\([^\n]+\)\.replace\(/.test(combinedTsSource), "Translated placeholders still rely on caller-side string replacement");
-assert(i18nSource.includes("WARNED_LANG_LOAD_ERRORS") && i18nSource.includes("console.warn") && i18nSource.includes("i18n.externalLoadFailed"), "External language parse/load failures are still silent");
-assert(i18nSource.includes('primary === "be"') && i18nSource.includes('primary === "by"') && i18nSource.includes("[missing translation key]") && i18nSource.includes("`[${key}]`"), "i18n locale/missing-key fallback semantics are incomplete");
+assert(i18nSource.includes("WARNED_LANG_LOAD_ERRORS") && i18nSource.includes("console.warn") && i18nCatalogSource.includes("i18n.externalLoadFailed"), "External language parse/load failures are still silent");
+assert(i18nSource.includes('be: "ru"') && i18nSource.includes('by: "ru"') && i18nSource.includes('ua: "uk"') && i18nSource.includes("[missing translation key]") && i18nSource.includes("`[${key}]`"), "i18n locale/missing-key fallback semantics are incomplete");
assert(compressionWorkerSource.includes("getCachedWasmModule") && !compressionWorkerSource.includes("new WebAssembly.Module(message.wasm.jpeg"), "Compression worker still recompiles JPEG WASM modules for every init");
assert(compressionWorkerSource.includes("getImagequantBindingModule") && !compressionWorkerSource.includes("as any"), "Compression worker still bypasses imagequant binding validation with any casts");
assert(imageIndexSource.includes("pendingRebuildMutations") && imageIndexSource.includes("const nextRecords = new Map") && imageIndexSource.includes("this.records = nextRecords") && !imageIndexSource.includes("this.records.clear()"), "ImageIndex rebuild still mutates the live records map instead of atomically swapping");
@@ -673,7 +732,7 @@ assert(pluginSource.includes("isImageFile(file: unknown): file is obsidian.TFile
assert(pluginSource.includes("intentionally uses || instead of ??") && pluginSource.includes("Returns every supported image file") && pluginSource.includes("Returns only uncompressed image files"), "Plugin output-folder fallback or image-file method naming intent is undocumented");
assert(pluginSource.includes("progress.error\")} (${fileLabel})") && pluginSource.includes('reason === "too_large"') && cacheSource.includes('skipReason === "too_large"'), "Compression errors or too_large skip settings keys are missing class-wide guards");
assert(pluginSource.includes('new Set(["/", ...folders.map') && !pluginSource.includes('folderPaths.unshift("/")'), "Folder selector still filters root and re-adds it with unshift");
-assert(pluginSource.includes("notice.compressionDeferredDueToMove") && i18nSource.includes("notice.compressionDeferredDueToMove"), "Move-in-progress compression deferral Notice is missing specific i18n coverage");
+assert(pluginSource.includes("notice.compressionDeferredDueToMove") && i18nCatalogSource.includes("notice.compressionDeferredDueToMove"), "Move-in-progress compression deferral Notice is missing specific i18n coverage");
assert(pluginSource.includes("Snapshot defensively because UI/event mutations") && pluginSource.includes("const indexUpdatePromise = isOutputPath"), "Batch settings snapshot or modify-event scheduling intent is not guarded");
assert(pluginSource.includes("PLUGIN_BACKUP_DELETE_CONCURRENCY") && pluginSource.includes("backupDeleteLimiter") && pluginSource.includes("Promise.allSettled(backups.map") && !pluginSource.includes("for (const backup of backups)"), "Original-files backup cleanup still deletes backup directories sequentially");
assert(pluginSource.includes("readdir(backupDir, { withFileTypes: true })") && !pluginSource.includes("fs3.promises.lstat(backupPath)") && pluginSource.includes("backup.isFile()") && pluginSource.includes("fs3.promises.unlink(backupPath)"), "Original-files backup cleanup still uses lstat per entry or leaves orphan files in backupDir");
@@ -693,6 +752,7 @@ assert(statusBarControllerSource.includes("this.plugin.isUnloading") && statusBa
assert(pluginSource.includes("registerDomEvent(this.statusBarItem") && !statusBarControllerSource.includes(".onclick ="), "Status bar click handler is still reassigned from update()");
assert(pluginSource.includes('setAttribute?.("role", "button")') && pluginSource.includes('setAttribute?.("tabindex", "0")') && pluginSource.includes('setAttribute?.("aria-haspopup", "menu")') && pluginSource.includes('setAttribute?.("aria-expanded", "false")'), "Status bar item is missing keyboard/ARIA button semantics");
assert(pluginSource.includes('registerDomEvent(this.statusBarItem, "keydown"') && pluginSource.includes('event.key !== "Enter" && event.key !== " "') && pluginSource.includes("keyboard: true"), "Status bar item is missing Enter/Space keyboard activation");
+assert(statusBarControllerSource.includes('setAttribute?.("aria-label", accessibleStatusText)') && statusBarControllerSource.includes('removeAttribute?.("title")') && !statusBarControllerSource.includes('setAttribute?.("title"'), "Status bar item must use one tooltip surface: aria-label without a native title");
assert(statusBarControllerSource.includes('menu.setAttribute("role", "menu")') && statusBarControllerSource.includes('menu.createEl("button"') && statusBarControllerSource.includes('setAttribute("role", "menuitem")') && !statusBarControllerSource.includes('const menuItem = menu.createEl("div"'), "Status menu actions are not button-backed menuitems");
assert(statusBarControllerSource.includes("focusFirstMenuItem(menu)") && statusBarControllerSource.includes("restoreStatusMenuFocus") && statusBarControllerSource.includes("requestWindowAnimationFrame") && statusBarControllerSource.includes("e.stopImmediatePropagation()") && statusBarControllerSource.includes('"ArrowDown"') && statusBarControllerSource.includes('"ArrowUp"') && statusBarControllerSource.includes('"Home"') && statusBarControllerSource.includes('"End"'), "Status menu keyboard focus management is missing");
assert(!statusBarControllerSource.includes("console.debug"), "Status bar controller still logs debug output in production paths");
@@ -750,7 +810,7 @@ assert(moveServiceSource.includes("isCandidateOriginalFile(file: unknown): file
assert(moveServiceSource.includes("normalizeVaultPath(compressedFile.relativePath") && !/compressedFile\.relativePath[\s\S]{0,100}\.replace\(/.test(moveServiceSource), "MoveService still normalizes compressed relative paths with inline string replacement");
assert(moveServiceSource.includes("pathsReferToSameFile") && moveServiceSource.includes("move.skip.selfMove"), "MoveService does not guard compressed/original self-moves");
assert(moveServiceSource.includes("move.skip.noOriginalCandidate") && moveServiceSource.includes("displaySkippedCount"), "MoveService does not account zero-candidate originals or derive skipped totals from reason groups");
-assert(i18nSource.includes("move.backup.createdCount") && i18nSource.includes("backups.imagesFolder.deletedCount"), "Backup notices are missing i18n keys");
+assert(i18nCatalogSource.includes("move.backup.createdCount") && i18nCatalogSource.includes("backups.imagesFolder.deletedCount"), "Backup notices are missing i18n keys");
assert(i18nSource.includes("normalizeVaultPathForComparison(pluginDir)") && i18nSource.includes("LOADED_LANGS[cacheKey]"), "i18n external-language cache is not scoped by plugin directory");
assert(!moveServiceSource.includes("Created backup of ${") && !pluginSource.includes("Backups folder not found") && !pluginSource.includes("No backups to delete"), "Backup notices still contain hardcoded English text");
assert(pluginSource.includes("compressionWorkflowsInFlight") && pluginSource.includes("waitForCompressionIdle") && moveServiceSource.includes("await this.plugin.waitForCompressionIdle()"), "Move flow does not wait for active compression workflows");
@@ -758,9 +818,12 @@ assert(pluginSource.includes("indexRefreshTimers: Map") &&
assert(pluginSource.includes("waitForCompressionIdle(maxWaitMs = 60_000)") && pluginSource.includes("waitForCompressionIdle giving up after") && !pluginSource.includes("queueMicrotask(() => resolve(undefined))"), "Compression idle wait can still spin forever or fall back to a microtask-only tick");
assert(newFileQueueSource.includes("NEW_FILE_PENDING_MAX") && newFileQueueSource.includes("auto.queueFull"), "Plugin does not cap the new-file auto-compress queue");
assert(pluginSource.includes("background.starting") && pluginSource.includes("background.finished"), "Background compression does not notify users about larger batches");
-assert(pluginSource.includes("GHOST_CLEANUP_COMPRESSED_THRESHOLD") && pluginSource.includes("maybeCleanupGhostEntriesAfterCompression"), "Plugin does not automatically clean ghost cache entries after enough successful compressions");
-assert(pluginSource.includes("STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD") && pluginSource.includes("maybePruneStaleCacheEntriesAfterCompression"), "Plugin does not automatically prune stale cache entries after enough successful compressions");
-assert(cacheSource.includes("pruneStaleCacheEntries") && cacheSource.includes("lastAccessMs"), "Cache is missing stale-entry retention with last-access tracking");
+assert(pluginSource.includes('rebuildImageIndex("startup")') && pluginSource.includes("await this.cache.compactCache()"), "Startup indexing is not followed by full cache compaction");
+assert(cacheSource.includes("compactCache") && cacheSource.includes("compactPath") && cacheSource.includes("compactDeletedPath"), "Cache is missing full or point compaction operations");
+assert(cacheSource.includes("filter(([, entry]) => !this.isLegacyEntry(entry))") && !cacheSource.includes("if (this.isLegacyEntry(entry))"), "Legacy cache entries can still become fresh processing/statistics candidates");
+assert(cacheSource.includes("resolvePendingMoveEntry") && moveServiceSource.includes("move.skip.externalModification"), "Move flow does not reject conflicting pending cache identity");
+assert(moveServiceSource.includes("originalSha256BeforeMove") && moveServiceSource.includes("currentOriginalSha256"), "Move flow does not revalidate the backup-verified original before replacement");
+assert(!pluginSource.includes("GHOST_CLEANUP_COMPRESSED_THRESHOLD") && !pluginSource.includes("STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD") && !cacheSource.includes("pruneStaleCacheEntries") && !cacheSource.includes("cleanupGhostEntries"), "Legacy threshold/retention cache cleanup remains active");
assert(cacheSource.includes("scheduleLastAccessSave") && cacheSource.includes("lastAccessSaveIntervalMs"), "Cache lastAccessMs touches are not persisted through a bounded save path");
assert(cacheSource.includes("!this.hasNonNegativeSize(entry.outputSize)") && cacheSource.includes("!this.hasFiniteNumber(entry.outputMtime)"), "pending_move output matching still accepts entries without output size/mtime identity");
assert(cacheSource.includes("Cannot mark moved file without processed mtime/size"), "Moved cache entries still allow missing processed identity");
@@ -799,7 +862,7 @@ assert(!pluginSource.includes("await this.setupStatusBar()") && pluginSource.ind
assert(!setupStatusBarSource.includes('rebuildImageIndex("startup")') && !setupStatusBarSource.includes("await this.statusBarController.update()"), "setupStatusBar() still blocks on startup image indexing");
assert(pluginSource.includes('const key = "startup-image-index"') && pluginSource.includes("await this.runStartupImageIndexRebuild()") && pluginSource.includes("Startup image-index rebuild failed"), "Startup image index rebuild is missing timer ownership or error handling");
assert(pluginSource.indexOf("this.isInitialized = true;") < pluginSource.indexOf("this.scheduleStartupImageIndexRebuild();"), "Startup image index rebuild is scheduled before base plugin initialization is complete");
-assert(i18nSource.includes("init.failed"), "Initialization failure notice is missing i18n coverage");
+assert(i18nCatalogSource.includes("init.failed"), "Initialization failure notice is missing i18n coverage");
assert(pluginGuardSource.includes("guard.disabled") && pluginGuardSource.includes("guard.restored") && pluginGuardSource.includes("new obsidian.Notice"), "Plugin guard does not notify on disable/restore");
assert(pluginGuardSource.includes("releaseAllGuards") && pluginSource.includes("releaseAllGuards"), "Plugin guard does not restore guarded plugins during unload");
assert(pluginGuardSource.includes("observedEnabledAfterGuardDisable") && pluginGuardSource.includes("shouldRestoreGuardedPlugin") && pluginGuardSource.includes("startGuardStateMonitor"), "Plugin guard restore does not respect user/external toggles during guard");
@@ -833,13 +896,13 @@ assert(progressModalSource.includes('setAttribute("role", "progressbar")') && pr
assert(moveServiceSource.includes('setAttribute("role", "progressbar")') && moveServiceSource.includes('setAttribute("aria-valuetext"') && moveServiceSource.includes('setAttribute("aria-live", "polite")'), "Move progress modal is missing accessible progress semantics");
assert(pluginSource.includes("signal: abortController.signal") && pluginSource.includes("cancelled_batch_aborted") && pluginSource.includes("cancelled: isCancelled()"), "Batch compression does not propagate ProgressModal cancellation");
assert(pluginSource.includes("Batch compression failed unexpectedly") && pluginSource.includes("progressModal.setError(errorMessage)"), "processBatchCompression does not surface unexpected batch failures in the modal");
-assert(i18nSource.includes("progress.cancelling") && i18nSource.includes("progress.cancelled") && i18nSource.includes("common.cancel"), "Progress cancellation i18n keys are missing");
+assert(i18nCatalogSource.includes("progress.cancelling") && i18nCatalogSource.includes("progress.cancelled") && i18nCatalogSource.includes("common.cancel"), "Progress cancellation i18n keys are missing");
assert(!pluginSource.includes("app.setting") && pluginSource.includes("settingsTab?.refreshStatsIfVisible()") && settingsTabSource.includes("refreshStatsIfVisible()") && settingsTabSource.includes("this._isVisible = true"), "Settings indicator refresh still depends on private app.setting state instead of plugin-owned visibility");
assert(settingsTabSource.includes("requestRerenderAfterCurrentRender()") && settingsTabSource.includes("refreshStatsIfVisible()") && !pluginSource.includes("settingsTab._isRendering") && !pluginSource.includes("settingsTab._pendingRerender"), "Settings indicator refresh still mutates SettingsTab render internals directly");
assert(settingsTabSource.includes('setAttribute("tabindex", "0")') && settingsTabSource.includes('setAttribute("role", "group")') && settingsTabSource.includes("'focus', onFocus") && settingsTabSource.includes('"Escape"') && settingsTabSource.includes("container.doc || ownerWindow.document"), "Savings tooltip is not keyboard-accessible or popout-owned");
-assert(!i18nSource.includes('"Command Palette →"') && !i18nSource.includes('"Space Savings Details"') && !i18nSource.includes('"Original Size:"'), "English built-in locale contains title-case UI copy");
-assert(pluginSource.includes("new ProgressModal(this, t(this.app, \"common.refreshCache\")") && i18nSource.includes("status.indexing"), "forceRefreshCache does not show progress for cache/index refresh");
-assert(pluginSource.includes('setText(t(this.app, "status.loading"))') && pluginSource.includes('setText(t(this.app, "status.indexing"))') && !pluginSource.includes('setText("…")') && i18nSource.includes("status.loading"), "Status bar startup still uses a magic loading string or lacks indexing feedback");
+assert(!i18nCatalogSource.includes('"Command Palette →"') && !i18nCatalogSource.includes('"Space Savings Details"') && !i18nCatalogSource.includes('"Original Size:"'), "English built-in locale contains title-case UI copy");
+assert(pluginSource.includes("new ProgressModal(this, t(this.app, \"common.refreshCache\")") && i18nCatalogSource.includes("status.indexing"), "forceRefreshCache does not show progress for cache/index refresh");
+assert(pluginSource.includes('setText(t(this.app, "status.loading"))') && pluginSource.includes('setText(t(this.app, "status.indexing"))') && !pluginSource.includes('setText("…")') && i18nCatalogSource.includes("status.loading"), "Status bar startup still uses a magic loading string or lacks indexing feedback");
assert(pluginSource.includes("async showCacheBackupsList()") && settingsTabSource.includes("showCacheBackupsList") && !settingsTabSource.includes("openBackupsFolder"), "Cache backup list method is still named or called as opening a folder");
assert(cacheBackupsViewSource.includes("backupInfoLimiter = new ConcurrencyLimiter(8)") && cacheBackupsViewSource.includes("toLocaleString(locale)") && !cacheBackupsViewSource.includes("toLocaleString(locale === 'en'"), "Cache backup list stat/locale formatting is not bounded or explicit");
assert(savingsCalculatorSource.includes("Promise") && savingsCalculatorSource.includes('getErrorCode(error) === "ENOENT"') && savingsCalculatorSource.includes(".catch(() => null)"), "Compressed size lookup does not distinguish missing files from stat errors");
@@ -887,7 +950,7 @@ for (const staleBinaryLocaleKey of [
"paths.mozjpeg.desc",
"binaries.available"
]) {
- assert(!i18nSource.includes(`"${staleBinaryLocaleKey}"`), `Obsolete native-binary locale key returned: ${staleBinaryLocaleKey}`);
+ assert(!i18nCatalogSource.includes(`"${staleBinaryLocaleKey}"`), `Obsolete native-binary locale key returned: ${staleBinaryLocaleKey}`);
}
assert(compressorSource.includes('"compress.error.pngQuality"') && !compressorSource.includes('"compress.error.pngquantExit"'), "PNG quality failure still uses native pngquant wording");
assert(!settingsSource.includes("workerPoolSize") && !settingsSource.includes("pluginGuardTimeoutMs"), "Settings source still exposes technical runtime settings");
@@ -896,54 +959,52 @@ assert(!readmeRuSource.includes("Размер пула воркеров сжат
assert(readmeSource.includes("WebP, GIF, BMP") && readmeSource.includes("Internal safety limits are fixed") && readmeSource.includes("100 million"), "README.md is missing supported-format limitations or internal safety-limit documentation");
assert(readmeRuSource.includes("WebP, GIF, BMP") && readmeRuSource.includes("Внутренние лимиты безопасности фиксированы") && readmeRuSource.includes("100 млн"), "README.ru.md is missing supported-format limitations or internal safety-limit documentation");
assert(readmeSource.includes("| PNG quality (min-max) | Quality range for lossy PNG quantization | 1-100") && !readmeSource.includes("PNG quality (min-max) | Quality range for lossy PNG quantization | 0-100"), "README.md PNG quality range is out of sync with settings clamp");
-assert(readmeRuSource.includes("| Качество PNG (мин-макс) | Диапазон качества для lossy PNG quantization | 1-100") && !readmeRuSource.includes("Качество PNG (мин-макс) | Диапазон качества для lossy PNG quantization | 0-100"), "README.ru.md PNG quality range is out of sync with settings clamp");
+assert(readmeRuSource.includes("| Качество PNG (мин-макс) | Диапазон качества квантования PNG с потерями | 1-100") && !readmeRuSource.includes("Качество PNG (мин-макс) | Диапазон качества квантования PNG с потерями | 0-100"), "README.ru.md PNG quality range is out of sync with settings clamp");
for (const token of [
"Inactivity threshold",
- "Cache retention",
"Auto backup retention",
"Auto-move compressed files",
"Auto-move threshold",
"conservative estimates with capped ratios",
- "skips restore ownership"
+ "does not attempt to restore it"
]) {
assert(readmeSource.includes(token), `README.md is missing settings/savings/guard documentation token: ${token}`);
}
assert(!readmeSource.includes("Disable Paste Image Rename during compression") && !readmeSource.includes("with the setting off"), "README.md still documents a Paste Image Rename opt-out setting");
for (const token of [
"Порог неактивности",
- "Срок хранения кэша",
- "Автохранение бэкапов",
+ "Автохранение резервных копий",
"Автоперемещение сжатых файлов",
"Порог автоперемещения",
"консервативную оценку с ограниченными коэффициентами",
- "не присваивает себе восстановление"
+ "не пытается восстановить его"
]) {
assert(readmeRuSource.includes(token), `README.ru.md is missing settings/savings/guard documentation token: ${token}`);
}
assert(!readmeRuSource.includes("Отключать Paste Image Rename при сжатии") && !readmeRuSource.includes("если выключить"), "README.ru.md still documents a Paste Image Rename opt-out setting");
assert(manifestSource.minAppVersion === "1.4.0", "manifest.json minAppVersion must match the activeWindow/activeDocument/getBasePath API minimum");
assert(versionsSource[manifestSource.version] === manifestSource.minAppVersion, "versions.json current version must match manifest minAppVersion");
-assert(readmeSource.includes("Requires Obsidian `1.4.0+`") && readmeSource.includes("Min app version: `1.4.0`"), "README.md minimum Obsidian version is out of sync with manifest");
-assert(readmeRuSource.includes("Требуется Obsidian `1.4.0+`") && readmeRuSource.includes("Минимальная версия приложения: `1.4.0`"), "README.ru.md minimum Obsidian version is out of sync with manifest");
-assert(readmeSource.includes("Build and release model") && readmeSource.includes("RELEASE_POLICY.md") && readmeRuSource.includes("Модель сборки и релиза") && readmeRuSource.includes("RELEASE_POLICY.md"), "README files must link the release policy and explain the build/release model");
assert(manifestSource.authorUrl === "https://github.com/haperone", "manifest.json authorUrl must point to the author profile");
assert(packageSource.scripts["build:root"] === "node scripts/build-root.js", "package.json is missing build:root");
assert(packageSource.scripts.build === "npm run build:root", "package.json build must delegate to the TypeScript root build");
assert(!packageSource.scripts["build:baseline"] && !packageSource.scripts["test:baseline"] && !packageSource.scripts.verify && !packageSource.scripts.extract, "byte-exact baseline recovery scripts must stay decommissioned");
assert(packageSource.scripts["test:release"] === "npm test && npm run build:root && npm run audit:policy:bundle && npm run verify:release && npm run verify:root-ts", "package.json test:release must build and verify deterministic release output");
assert(packageSource.scripts["validate:license"] === "node scripts/validate-license.js", "package.json is missing validate:license");
+assert(packageSource.scripts["validate:readmes"] === "node scripts/validate-readme-locales.js" && packageSource.scripts.test.includes("npm run validate:readmes"), "package.json must keep localized README validation blocking in npm test");
+assert(packageSource.scripts["qa:i18n"] === "node scripts/validate-i18n.js" && packageSource.scripts.test.includes("npm run qa:i18n"), "package.json must keep interface localization QA blocking in npm test");
assert(packageSource.scripts["audit:policy"] === "node scripts/audit-policy.js" && packageSource.scripts.test.includes("npm run audit:policy"), "package.json must keep the policy audit blocking in npm test");
assert(packageSource.scripts["audit:policy:bundle"] === "node scripts/audit-policy.js --require-bundle" && packageSource.scripts["test:release"].includes("npm run audit:policy:bundle"), "Release tests must run the policy audit against the built production bundle");
assert(packageSource.scripts["lint:eslint"] === "eslint src-ts/" && packageSource.scripts.test.includes("npm run lint:eslint"), "package.json must keep lint:eslint executable and wired into npm test");
assert(packageSource.scripts["lint:obsidian"] === "node scripts/lint-obsidian.js" && packageSource.scripts.test.includes("npm run lint:obsidian"), "package.json must keep the Obsidian scanner executable and blocking in npm test");
assert(eslintConfigSource.includes("\"@typescript-eslint/no-unnecessary-type-assertion\": \"error\""), "Standard ESLint must reject unnecessary type assertions");
-assert(eslintObsidianConfigSource.includes("recommendedWithLocalesEn") && eslintObsidianConfigSource.includes("src-ts/i18n.ts") && eslintObsidianConfigSource.includes("sourcePrefix"), "Obsidian scanner config must cover the current recommended rules and layout-aware English locale source");
+assert(eslintObsidianConfigSource.includes("recommendedWithLocalesEn") && eslintObsidianConfigSource.includes("src-ts/locales/en.json") && eslintObsidianConfigSource.includes('language: "json/json"') && eslintObsidianConfigSource.includes("sourcePrefix"), "Obsidian scanner config must cover the current recommended rules and layout-aware English locale source");
assert(lintObsidianSource.includes("warningCount === 0") && lintObsidianSource.includes("errorCount === 0"), "Obsidian scanner wrapper must reject both errors and warnings");
assert(packageSource.devDependencies["@jsquash/jpeg"], "package.json is missing @jsquash/jpeg");
assert(packageSource.devDependencies["@jsquash/png"], "package.json is missing @jsquash/png");
assert(packageSource.devDependencies["@types/node"] === "25.7.0", "@types/node must be pinned exactly for repeatable type checks");
assert(packageSource.devDependencies.obsidian === "1.13.0", "obsidian API types must stay pinned to the reviewed 1.13.0 baseline");
assert(packageSource.devDependencies["eslint-plugin-obsidianmd"] === "0.3.0", "eslint-plugin-obsidianmd must stay pinned to the reviewed 0.3.0 scanner baseline");
+assert(packageSource.devDependencies["@eslint/json"] === "0.14.0", "@eslint/json must stay pinned for English locale linting");
assert(packageSource.devDependencies.eslint && packageSource.devDependencies["@typescript-eslint/parser"] && packageSource.devDependencies["@typescript-eslint/eslint-plugin"], "ESLint devDependencies are required for lint:eslint");
assertExactPackageSeries(packageSource.devDependencies.imagequant, /^0\.1\.\d+$/, "imagequant must stay on the 0.1.x series while pngquant_quality_failed depends on its error contract");
assertExactPackageSeries(packageSource.devDependencies.typescript, /^6\.0\.\d+$/, "typescript must stay on the reviewed 6.0.x series");
@@ -968,6 +1029,7 @@ assert(!releaseWorkflowSource.includes("|| true"), "Release workflow still silen
assert(rootPackageSource.license === "GPL-3.0-or-later", "Root package.json license must match bundled GPL codec obligations");
if (isDevLayout) {
assert(rootPackageSource.scripts.build === "npm --prefix source-recovery run build:root", "Root package.json build must delegate to the real source-recovery build");
+ assert(rootPackageSource.scripts["qa:i18n"] === "npm --prefix source-recovery run qa:i18n", "Root package.json must expose the fast interface localization QA");
assert(rootPackageSource.scripts.test === "npm run dev:test && npm run prod:test && npm --prefix source-recovery test" && rootPackageSource.scripts["test:release"] === "npm run dev:test && npm run prod:test && npm --prefix source-recovery run test:release", "Root package.json test scripts must run DEV deployment tests, promotion tests, and delegate to source-recovery");
} else {
assert(rootPackageSource.scripts.build === "npm run build:root", "Standalone package.json build must use the local source build");
@@ -978,6 +1040,7 @@ assert(!releaseWorkflowSource.includes("build/package.json") && !releaseWorkflow
assert(releaseWorkflowSource.includes('"*.*.*"') && releaseWorkflowSource.includes("^[0-9]+\\.[0-9]+\\.[0-9]+$") && !releaseWorkflowSource.includes('"v*"') && !releaseWorkflowSource.includes("GITHUB_REF_NAME#v"), "Release workflow does not combine a dotted tag trigger with exact numeric SemVer validation");
assert((releaseWorkflowSource.match(/actions\/checkout@v6/g) || []).length === 2 && (releaseWorkflowSource.match(/actions\/setup-node@v6/g) || []).length === 2 && (releaseWorkflowSource.match(/node-version:\s*"24"/g) || []).length === 2, "Release workflow must use checkout/setup-node v6 and Node 24 in both jobs");
const releasePrepareCommand = isDevLayout ? "npm --prefix source-recovery run prepare:release" : "npm run prepare:release";
+const releaseNotesCommand = isDevLayout ? "npm --prefix source-recovery run prepare:release-notes" : "npm run prepare:release-notes";
assert(
releaseWorkflowSource.includes(releasePrepareCommand)
&& prepareReleaseSource.includes('["manifest.json", "main.js", "styles.css"]')
@@ -985,6 +1048,7 @@ assert(
&& !prepareReleaseSource.includes('"versions.json"'),
"Release workflow does not use the exact supported Obsidian install-file staging allowlist"
);
+assert(releaseWorkflowSource.includes(releaseNotesCommand) && releaseWorkflowSource.includes("body_path: release-notes.md") && prepareReleaseNotesSource.includes("## Unreleased"), "Release workflow must generate its body from the promoted CHANGELOG Unreleased section");
assert(validateManifestSource.includes("forbiddenReleaseEntries") && validateManifestSource.includes("package.json must declare a files allowlist"), "Manifest validation does not guard release packaging against dev artifact leaks");
assert(validateManifestSource.includes("MIN_API_SURFACE_APP_VERSION") && validateManifestSource.includes("activeWindow/activeDocument/getBasePath"), "Manifest validation does not enforce API-surface minAppVersion");
assert(validateManifestSource.includes("manifest.json authorUrl must be a valid URL") && validateManifestSource.includes("must not point to localhost"), "Manifest validation does not reject malformed or local authorUrl values");
@@ -1049,7 +1113,7 @@ if (releaseReadinessSource) {
for (const token of ["Network", "Telemetry and ads", "Accounts and payments", "External files", "Other plugins"]) {
assert(readmeSource.includes(token), `README.md is missing policy disclosure: ${token}`);
}
-for (const token of ["Сеть", "Телеметрия и реклама", "Аккаунты и платежи", "Внешние файлы", "Другие плагины"]) {
+for (const token of ["Сеть", "Телеметрия и реклама", "Учётные записи и платежи", "Внешние файлы", "Другие плагины"]) {
assert(readmeRuSource.includes(token), `README.ru.md is missing policy disclosure: ${token}`);
}
assert(!rootPackageSource.dependencies?.["pngquant-bin"], "Root package.json still depends on pngquant-bin");
@@ -1076,6 +1140,7 @@ assert(
const originalLoad = Module._load;
let cachedObsidianMock = null;
+let mockObsidianLanguage = "en";
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "obsidian") {
if (cachedObsidianMock) {
@@ -1187,7 +1252,8 @@ Module._load = function patchedLoad(request, parent, isMain) {
TFile: class {},
TFolder: class {},
FuzzySuggestModal: class {},
- getLanguage: () => "en"
+ getLanguage: () => mockObsidianLanguage,
+ requireApiVersion: () => true
};
return cachedObsidianMock;
}
@@ -1921,6 +1987,19 @@ try {
}
assert(plugin.imageIndex?.isReady?.() === true, "Deferred startup image index rebuild did not make the index ready");
assert(plugin.app._getFilesCalls > 0, "Deferred startup image index rebuild did not scan vault files after onload");
+ const localeTitleExpectations = {};
+ for (const fileName of fs.readdirSync(path.join(sourceTsRoot, "locales")).filter((fileName) => fileName.endsWith(".json"))) {
+ localeTitleExpectations[path.basename(fileName, ".json")] = JSON.parse(fs.readFileSync(path.join(sourceTsRoot, "locales", fileName), "utf8"))["settings.title"];
+ }
+ for (const [locale, expectedTitle] of Object.entries(localeTitleExpectations)) {
+ mockObsidianLanguage = locale;
+ assert(plugin.moveService.getMoveText("settings.title") === expectedTitle, `Module-level Obsidian language detection did not select ${locale}`);
+ }
+ for (const [localeAlias, canonicalLocale] of [["pt_BR", "pt-br"], ["zh", "zh-cn"], ["zh_Hant", "zh-tw"], ["be", "ru"], ["ua", "uk"]]) {
+ mockObsidianLanguage = localeAlias;
+ assert(plugin.moveService.getMoveText("settings.title") === localeTitleExpectations[canonicalLocale], `Language alias ${localeAlias} did not select ${canonicalLocale}`);
+ }
+ mockObsidianLanguage = "en";
const originalBasePathForPathSmoke = plugin.app.vault.adapter.basePath;
const originalAbsolutePathForPathSmoke = plugin.app.vault.adapter.path.absolute;
@@ -2156,6 +2235,7 @@ try {
autoBackgroundThreshold: -1,
inactivityThresholdMinutes: 99,
cacheRetentionMonths: 999,
+ autoCleanupGhostsOnStart: true,
pluginGuardTimeoutMs: 999999,
autoBackupsRetentionDays: 9999,
autoMoveCompressedThreshold: 0
@@ -2175,7 +2255,7 @@ try {
assert(plugin.settings.autoBackgroundThreshold === 10, "normalizeSettings did not clamp autoBackgroundThreshold");
assert(plugin.settings.inactivityThresholdMinutes === 60, "normalizeSettings did not clamp inactivityThresholdMinutes");
assert(plugin.backgroundCompressionService.USER_INACTIVITY_THRESHOLD === 60 * 60 * 1000, "loadSettings() did not apply runtime inactivity threshold");
- assert(plugin.settings.cacheRetentionMonths === 60, "normalizeSettings did not clamp cacheRetentionMonths");
+ assert(!("cacheRetentionMonths" in plugin.settings) && !("autoCleanupGhostsOnStart" in plugin.settings), "normalizeSettings kept removed cache-maintenance settings");
assert(plugin.pluginGuardService.operationTimeoutMs === 8000, "loadSettings() did not apply internal plugin guard timeout");
assert(plugin.settings.autoBackupsRetentionDays === 365, "normalizeSettings did not clamp autoBackupsRetentionDays");
assert(plugin.settings.autoMoveCompressedThreshold === 1, "normalizeSettings did not clamp autoMoveCompressedThreshold");
@@ -2201,7 +2281,6 @@ try {
await plugin.loadSettings();
assert(plugin.settings.outputFolder === "Compressed", "loadSettings() did not fall back to defaults after loadData failure");
assert(plugin.settings.inactivityThresholdMinutes === 2 && plugin.backgroundCompressionService.USER_INACTIVITY_THRESHOLD === 2 * 60 * 1000, "loadSettings() did not restore default inactivity threshold after loadData failure");
- assert(plugin.settings.cacheRetentionMonths === 12, "loadSettings() did not restore default cache retention after loadData failure");
assert(plugin.pluginGuardService.operationTimeoutMs === 8000, "loadSettings() did not restore internal plugin guard timeout after loadData failure");
assert(plugin.compressor.processTimeoutMs === 120000, "loadSettings() did not restore internal compression timeout after loadData failure");
assert(plugin.compressor.initTimeoutMs === 60000, "loadSettings() did not restore internal WASM init timeout after loadData failure");
@@ -2378,6 +2457,7 @@ try {
plugin.autoCompressNewFile = originalAutoCompressNewFile;
plugin.settings.autoCompressNewFiles = false;
plugin.isUnloading = false;
+ plugin.cache.acceptingWrites = true;
plugin.newFileQueue.newFileCompressionTimers.clear();
}
@@ -2616,54 +2696,6 @@ try {
ObsidianMock.Notice = originalNoticeForBackgroundNotice;
}
- const originalValidateForGhostAutoCleanup = plugin.validateFileForCompression;
- const originalRunLimitedForGhostAutoCleanup = plugin.runLimitedCompression;
- const originalIsProcessedForGhostAutoCleanup = plugin.cache.isFileAlreadyProcessed;
- const originalGetCacheKeyForGhostAutoCleanup = plugin.cache.getCacheKey;
- const originalAddToCacheForGhostAutoCleanup = plugin.cache.addToCache;
- const originalCreateBackupForGhostAutoCleanup = plugin.cache.createBackup;
- const originalUpdateImageIndexForGhostAutoCleanup = plugin.updateImageIndexForFile;
- const originalCleanupGhostEntriesForAutoCleanup = plugin.cleanupGhostEntries;
- const originalUpdateSavingsIndicatorForGhostAutoCleanup = plugin.updateSavingsIndicatorInSettings;
- try {
- plugin.isUnloading = false;
- plugin.cache.acceptingWrites = true;
- plugin.moveService.moveOperationInProgress = false;
- plugin.ghostEntryDirtyCount = 0;
- let automaticGhostCleanupCalls = 0;
- plugin.validateFileForCompression = async () => ({ valid: true });
- plugin.runLimitedCompression = async () => ({ success: true, savings: 10 });
- plugin.cache.isFileAlreadyProcessed = async () => false;
- plugin.cache.getCacheKey = async (file) => `ghost-auto:${file.path}`;
- plugin.cache.addToCache = async () => {};
- plugin.cache.createBackup = async () => {};
- plugin.updateImageIndexForFile = async () => {};
- plugin.updateSavingsIndicatorInSettings = () => {};
- plugin.cleanupGhostEntries = async () => {
- automaticGhostCleanupCalls += 1;
- return 3;
- };
- const firstGhostBatch = Array.from({ length: 99 }, (_, index) => createMockFile(`Images/ghost-auto-${index}.jpg`, 100000, index + 1));
- const secondGhostBatch = [createMockFile("Images/ghost-auto-trigger.jpg", 100000, 200)];
- await plugin.runCompressionBatch(firstGhostBatch);
- assert(automaticGhostCleanupCalls === 0, "Automatic ghost cleanup ran before the compression threshold");
- assert(plugin.ghostEntryDirtyCount === 99, `Automatic ghost cleanup tracked wrong dirty count before threshold: ${plugin.ghostEntryDirtyCount}`);
- await plugin.runCompressionBatch(secondGhostBatch);
- assert(automaticGhostCleanupCalls === 1, `Automatic ghost cleanup ran ${automaticGhostCleanupCalls} times instead of once at threshold`);
- assert(plugin.ghostEntryDirtyCount === 0, "Automatic ghost cleanup did not reset the dirty counter after cleanup");
- } finally {
- plugin.validateFileForCompression = originalValidateForGhostAutoCleanup;
- plugin.runLimitedCompression = originalRunLimitedForGhostAutoCleanup;
- plugin.cache.isFileAlreadyProcessed = originalIsProcessedForGhostAutoCleanup;
- plugin.cache.getCacheKey = originalGetCacheKeyForGhostAutoCleanup;
- plugin.cache.addToCache = originalAddToCacheForGhostAutoCleanup;
- plugin.cache.createBackup = originalCreateBackupForGhostAutoCleanup;
- plugin.updateImageIndexForFile = originalUpdateImageIndexForGhostAutoCleanup;
- plugin.cleanupGhostEntries = originalCleanupGhostEntriesForAutoCleanup;
- plugin.updateSavingsIndicatorInSettings = originalUpdateSavingsIndicatorForGhostAutoCleanup;
- plugin.ghostEntryDirtyCount = 0;
- }
-
const originalRunLimitedForUnload = plugin.runLimitedCompression;
const originalAddToCacheForUnload = plugin.cache.addToCache;
const originalIsProcessedForUnload = plugin.cache.isFileAlreadyProcessed;
@@ -3912,7 +3944,6 @@ try {
assert(settingsTab.normalizeAllowedRootSelection("") === null, "Allowed roots should reject the empty vault root selection");
assert(settingsTab.normalizeAllowedRootSelection("/") === null, "Allowed roots should reject the slash vault root selection");
assert(settingsTab.normalizeAllowedRootSelection("Images") === "Images/", "Allowed roots should normalize folder selections with a trailing slash");
- assert(settingsTab.formatCountMessage("stats.ghosts.clearedCount", 3) === "3 ghost entries cleared", "Ghost cleanup count Notice should use a translated count template");
const loadingButtonCalls = [];
const loadingButton = {
@@ -3958,11 +3989,11 @@ try {
failedButtonCalls.push(["text", value]);
return this;
}
- }, "common.clearGhosts", "common.clearing", async () => {
+ }, "common.clear", "common.clearing", async () => {
throw new Error("simulated settings action failure");
});
assert(failedButtonNotices.some((message) => message.includes("Operation failed")), "runButtonTask() did not show a Notice after a failed async settings action");
- assert(JSON.stringify(failedButtonCalls.slice(-2)) === JSON.stringify([["text", "Clear ghosts"], ["disabled", false]]), "runButtonTask() did not restore button state after failure");
+ assert(JSON.stringify(failedButtonCalls.slice(-2)) === JSON.stringify([["text", "Clear"], ["disabled", false]]), "runButtonTask() did not restore button state after failure");
} finally {
ObsidianMockForButtonFailure.Notice = originalNoticeForButtonFailure;
console.error = originalConsoleErrorForButtonFailure;
@@ -4909,11 +4940,11 @@ try {
assert(disableCalls === 1, `Plugin guard notice test disabled plugin ${disableCalls} times`);
assert(enableCalls === 1, `Plugin guard notice test restored plugin ${enableCalls} times`);
assert(
- guardNotices.some((notice) => String(notice.message).includes("temporarily disabled") && String(notice.message).includes(guardedPluginId) && notice.duration === 5000),
+ guardNotices.some((notice) => String(notice.message).includes(englishLocale["guard.disabled"].replace("{id}", guardedPluginId)) && notice.duration === 5000),
`Plugin guard did not notify about disable: ${guardNotices.map((notice) => notice.message).join(" | ")}`
);
assert(
- guardNotices.some((notice) => String(notice.message).includes("restored") && String(notice.message).includes(guardedPluginId) && notice.duration === 5000),
+ guardNotices.some((notice) => String(notice.message).includes(englishLocale["guard.restored"].replace("{id}", guardedPluginId)) && notice.duration === 5000),
`Plugin guard did not notify about restore: ${guardNotices.map((notice) => notice.message).join(" | ")}`
);
} finally {
@@ -5412,135 +5443,95 @@ try {
fs.rmSync(corruptCacheTemp, { recursive: true, force: true });
}
- const ghostTemp = fs.mkdtempSync(path.join(os.tmpdir(), "local-image-compress-ghosts-"));
+ const originalFilesForCompaction = plugin.app._files;
+ const originalCacheCreateBackupForCompaction = plugin.cache.createBackup;
+ const originalCacheSaveCacheForCompaction = plugin.cache.saveCache;
+ const originalGetFileMd5ForCompaction = plugin.cache.getFileMd5;
+ const originalGetOutputMetadataForCompaction = plugin.cache.getOutputMetadata;
try {
- plugin.app.vault.adapter.basePath = ghostTemp;
- plugin.app.vault.adapter.path.absolute = ghostTemp;
- const existingGhostFile = path.join(ghostTemp, "Existing", "ok.png");
- fs.mkdirSync(path.dirname(existingGhostFile), { recursive: true });
- fs.writeFileSync(existingGhostFile, Buffer.alloc(10));
- plugin.cache.cacheData.entries = {
- [plugin.cache.buildCacheKey("Existing/ok.png", MOCK_MD5, 1)]: {
- path: "Existing/ok.png",
- timestamp: 1
- },
- ...Object.fromEntries(Array.from({ length: 401 }, (_, index) => [
- plugin.cache.buildCacheKey(`Missing/file-${index}.png`, MOCK_MD5, index + 1),
- {
- path: `Missing/file-${index}.png`,
- timestamp: index + 1
- }
- ]))
- };
- const originalExistsSync = fs.existsSync;
- const originalCacheYieldToUi = plugin.cache.yieldToUi;
- const originalCacheCreateBackup = plugin.cache.createBackup;
- const originalCacheSaveCache = plugin.cache.saveCache;
- let ghostYieldCalls = 0;
- let ghostBackupCalls = 0;
- let ghostSaveCalls = 0;
- try {
- fs.existsSync = () => {
- throw new Error("ghost paths must not use fs.existsSync");
- };
- plugin.cache.yieldToUi = async () => {
- ghostYieldCalls += 1;
- };
- plugin.cache.createBackup = () => {
- ghostBackupCalls += 1;
- };
- plugin.cache.saveCache = async () => {
- ghostSaveCalls += 1;
- };
- const ghostCount = await plugin.cache.getGhostEntriesCount();
- assert(ghostCount === 401, `Async ghost count returned wrong count: ${ghostCount}`);
- assert(ghostYieldCalls >= 2, `Async ghost count did not yield across batches: ${ghostYieldCalls}`);
- const removedGhosts = await plugin.cache.cleanupGhostEntries();
- assert(removedGhosts === 401, `Async ghost cleanup removed wrong count: ${removedGhosts}`);
- assert(ghostBackupCalls === 1, `Ghost cleanup created backup wrong number of times: ${ghostBackupCalls}`);
- assert(ghostSaveCalls === 1, `Ghost cleanup saved cache wrong number of times: ${ghostSaveCalls}`);
- assert(plugin.cache.getEntriesForPath("Existing/ok.png").length === 1, "Ghost cleanup removed an existing file entry");
+ const currentFile = createMockFile("Images/current.png", 80, 20);
+ const conflictFile = createMockFile("Images/conflict.png", 70, 30);
+ const activePendingFile = createMockFile("Images/active-pending.png", 60, 40);
+ const ambiguousFile = createMockFile("Images/ambiguous.png", 50, 50);
+ const ambiguousMovedFile = createMockFile("Images/ambiguous-moved.png", 40, 60);
+ plugin.app._files = [currentFile, conflictFile, activePendingFile, ambiguousFile, ambiguousMovedFile];
- ghostBackupCalls = 0;
- ghostSaveCalls = 0;
- const removedNone = await plugin.cache.cleanupGhostEntries();
- assert(removedNone === 0, "Ghost cleanup removed entries when no ghosts remained");
- assert(ghostBackupCalls === 0, "Ghost cleanup created backup when no ghosts were removed");
- assert(ghostSaveCalls === 0, "Ghost cleanup saved cache when no ghosts were removed");
- } finally {
- fs.existsSync = originalExistsSync;
- plugin.cache.yieldToUi = originalCacheYieldToUi;
- plugin.cache.createBackup = originalCacheCreateBackup;
- plugin.cache.saveCache = originalCacheSaveCache;
- }
- } finally {
- plugin.app.vault.adapter.basePath = root;
- plugin.app.vault.adapter.path.absolute = root;
- restoreCacheTestPaths();
- plugin.cache.cacheData = originalCacheData;
- fs.rmSync(ghostTemp, { recursive: true, force: true });
- }
-
- try {
- const now = Date.now();
- const oldTimestamp = now - 13 * 30 * 24 * 60 * 60 * 1000;
- const recentTimestamp = now - 2 * 24 * 60 * 60 * 1000;
- const oldKey = plugin.cache.buildCacheKey("Images/old-cache.png", MOCK_MD5, oldTimestamp);
- const recentKey = plugin.cache.buildCacheKey("Images/recent-cache.png", MOCK_MD5, recentTimestamp);
- const unknownKey = "legacy:path-only-cache";
+ const currentKey = "modern:current";
+ const staleKey = "modern:stale";
+ const missingModernKey = "modern:missing";
+ const missingLegacyKey = "legacy:missing";
+ const conflictCurrentKey = "modern:conflict-current";
+ const conflictPendingKey = "modern:conflict-pending";
+ const stalePendingKey = "modern:stale-pending";
+ const activePendingKey = "modern:active-pending";
+ const ambiguousLeftKey = "modern:ambiguous-left";
+ const ambiguousRightKey = "modern:ambiguous-right";
+ const movedLeftKey = "modern:moved-left";
+ const movedRightKey = "modern:moved-right";
plugin.cache.cacheData = plugin.cache.getEmptyCacheData();
plugin.cache.cacheData.entries = {
- [oldKey]: {
- path: "Images/old-cache.png",
- timestamp: oldTimestamp,
- lastAccessMs: oldTimestamp
- },
- [recentKey]: {
- path: "Images/recent-cache.png",
- timestamp: oldTimestamp,
- lastAccessMs: recentTimestamp
- },
- [unknownKey]: {
- path: "Images/path-only-cache.png"
- }
+ [currentKey]: { path: currentFile.path, state: "skipped", sourceMtime: 20, sourceSize: 80, md5: "1".repeat(32) },
+ [staleKey]: { path: currentFile.path, state: "skipped", sourceMtime: 10, sourceSize: 100, md5: "2".repeat(32) },
+ [missingModernKey]: { path: "Missing/modern.png", state: "skipped", sourceMtime: 1, sourceSize: 10, md5: "3".repeat(32) },
+ [missingLegacyKey]: { path: "Missing/legacy.png", state: "processed", timestamp: 1 },
+ [conflictCurrentKey]: { path: conflictFile.path, state: "skipped", sourceMtime: 30, sourceSize: 70, md5: "4".repeat(32) },
+ [conflictPendingKey]: { path: conflictFile.path, state: "pending_move", sourceMtime: 29, sourceSize: 90, outputPath: "Compressed/conflict.png", outputMtime: 31, outputSize: 45, md5: "5".repeat(32) },
+ [stalePendingKey]: { path: conflictFile.path, state: "pending_move", sourceMtime: 28, sourceSize: 95, outputPath: "Compressed/stale.png", outputMtime: 32, outputSize: 46, md5: "6".repeat(32) },
+ [activePendingKey]: { path: activePendingFile.path, state: "pending_move", sourceMtime: 40, sourceSize: 60, outputPath: "Compressed/active-pending.png", outputMtime: 41, outputSize: 35, md5: "7".repeat(32) },
+ [ambiguousLeftKey]: { path: ambiguousFile.path, state: "skipped", sourceMtime: 50, sourceSize: 50, md5: "8".repeat(32) },
+ [ambiguousRightKey]: { path: ambiguousFile.path, state: "skipped", sourceMtime: 50, sourceSize: 50, md5: "9".repeat(32) },
+ [movedLeftKey]: { path: ambiguousMovedFile.path, state: "moved", processedMtime: 60, processedSize: 40, md5: "a".repeat(32) },
+ [movedRightKey]: { path: ambiguousMovedFile.path, state: "moved", processedMtime: 60, processedSize: 40, md5: "b".repeat(32) }
};
- const originalCacheCreateBackupForPrune = plugin.cache.createBackup;
- const originalCacheSaveCacheForPrune = plugin.cache.saveCache;
- let pruneBackupCalls = 0;
- let pruneSaveCalls = 0;
- try {
- plugin.cache.createBackup = async () => {
- pruneBackupCalls += 1;
- };
- plugin.cache.saveCache = async () => {
- pruneSaveCalls += 1;
- };
- const prunedCount = await plugin.cache.pruneStaleCacheEntries(12, now);
- assert(prunedCount === 1, `Stale cache retention pruned wrong number of entries: ${prunedCount}`);
- assert(!plugin.cache.cacheData.entries[oldKey], "Stale cache retention kept an expired entry");
- assert(plugin.cache.cacheData.entries[recentKey], "Stale cache retention removed a recently accessed entry");
- assert(plugin.cache.cacheData.entries[unknownKey], "Stale cache retention removed an entry with no retention timestamp");
- assert(pruneBackupCalls === 1 && pruneSaveCalls === 1, "Stale cache retention did not back up and save exactly once");
- const touchKey = plugin.cache.buildCacheKey("Images/touched-cache.png", MOCK_MD5, oldTimestamp);
- plugin.cache.cacheData.entries = {
- [touchKey]: {
- path: "Images/touched-cache.png",
- timestamp: oldTimestamp
- }
- };
- const touchedFile = createMockFile("Images/touched-cache.png", 50000, oldTimestamp);
- const freshEntry = await plugin.cache.getFreshEntryForFile(touchedFile);
- assert(freshEntry?.cacheKey === touchKey, "Fresh cache lookup did not return the touched entry");
- assert(plugin.cache.cacheData.entries[touchKey].lastAccessMs >= now, "Fresh cache lookup did not update lastAccessMs");
- const untouchedPrunedCount = await plugin.cache.pruneStaleCacheEntries(12, Date.now());
- assert(untouchedPrunedCount === 0, "Stale cache retention pruned an entry touched during lookup");
- } finally {
- plugin.cache.createBackup = originalCacheCreateBackupForPrune;
- plugin.cache.saveCache = originalCacheSaveCacheForPrune;
- }
+ const md5Paths = [];
+ let compactionBackupCalls = 0;
+ let compactionSaveCalls = 0;
+ let compactionSaveOptions = null;
+ plugin.cache.getFileMd5 = async (file) => {
+ md5Paths.push(file.path);
+ return "f".repeat(32);
+ };
+ plugin.cache.getOutputMetadata = async (outputPath) => {
+ if (outputPath === "Compressed/conflict.png") return { outputPath, outputMtime: 31, outputSize: 45 };
+ if (outputPath === "Compressed/active-pending.png") return { outputPath, outputMtime: 41, outputSize: 35 };
+ return null;
+ };
+ plugin.cache.createBackup = async () => {
+ compactionBackupCalls += 1;
+ };
+ plugin.cache.saveCache = async (options) => {
+ compactionSaveCalls += 1;
+ compactionSaveOptions = options;
+ };
+
+ const compactionResult = await plugin.cache.compactCache();
+ assert(compactionResult.removed === 3 && compactionResult.missingFilesRemoved === 1 && compactionResult.supersededRemoved === 2, `Cache compaction returned wrong counts: ${JSON.stringify(compactionResult)}`);
+ assert(!plugin.cache.cacheData.entries[staleKey] && !plugin.cache.cacheData.entries[missingModernKey] && !plugin.cache.cacheData.entries[stalePendingKey], "Cache compaction kept proven stale modern entries");
+ assert(plugin.cache.cacheData.entries[currentKey] && plugin.cache.cacheData.entries[missingLegacyKey], "Cache compaction removed current or legacy entries");
+ assert(plugin.cache.cacheData.entries[conflictPendingKey] && plugin.cache.cacheData.entries[activePendingKey], "Cache compaction removed active/conflicting pending_move entries");
+ assert(plugin.cache.cacheData.entries[ambiguousLeftKey] && plugin.cache.cacheData.entries[ambiguousRightKey], "Cache compaction removed ambiguous source records");
+ assert(plugin.cache.cacheData.entries[movedLeftKey] && plugin.cache.cacheData.entries[movedRightKey], "Cache compaction removed ambiguous moved records");
+ assert(JSON.stringify(md5Paths) === JSON.stringify([ambiguousFile.path]), `Cache compaction hashed unexpected states: ${JSON.stringify(md5Paths)}`);
+ assert(compactionBackupCalls === 1 && compactionSaveCalls === 1, "Cache compaction did not create exactly one backup and one save");
+ assert(compactionSaveOptions?.mergeDiskEntries === false && compactionSaveOptions?.authoritative === true, "Cache compaction save was not authoritative");
+
+ const secondCompactionResult = await plugin.cache.compactCache();
+ assert(secondCompactionResult.removed === 0, "Cache compaction removed ambiguous/current entries on a repeated pass");
+ assert(compactionBackupCalls === 1 && compactionSaveCalls === 1, "No-op cache compaction wrote a backup or cache file");
+
+ plugin.cache.cacheData.entries["deleted:modern"] = { path: "Deleted/child.png", state: "skipped", sourceMtime: 1, sourceSize: 10 };
+ plugin.cache.cacheData.entries["deleted:legacy"] = { path: "Deleted/legacy.png", state: "processed", timestamp: 1 };
+ const deletedPathResult = await plugin.cache.compactDeletedPath("Deleted");
+ assert(deletedPathResult.removed === 1 && !plugin.cache.cacheData.entries["deleted:modern"], "Point compaction did not remove a deleted modern child entry");
+ assert(plugin.cache.cacheData.entries["deleted:legacy"], "Point compaction removed a deleted legacy child entry");
+ assert(compactionBackupCalls === 2 && compactionSaveCalls === 2, "Point compaction did not create exactly one backup and one save");
} finally {
- restoreCacheTestPaths();
+ plugin.app._files = originalFilesForCompaction;
+ plugin.cache.createBackup = originalCacheCreateBackupForCompaction;
+ plugin.cache.saveCache = originalCacheSaveCacheForCompaction;
+ plugin.cache.getFileMd5 = originalGetFileMd5ForCompaction;
+ plugin.cache.getOutputMetadata = originalGetOutputMetadataForCompaction;
plugin.cache.cacheData = originalCacheData;
}
@@ -5941,8 +5932,8 @@ try {
await plugin.cache.addSkippedEntry("Images/skipped-after-unload.png", "too_small");
await plugin.cache.markProcessedFileMoved("Images/write-lock.png", { mtimeMs: 2, size: 50 }, 100);
await plugin.cache.clearCache();
- const removedLockedGhosts = await plugin.cache.cleanupGhostEntries();
- assert(removedLockedGhosts === 0, "cleanupGhostEntries() removed entries after cache writes were locked");
+ const lockedCompactionResult = await plugin.cache.compactCache();
+ assert(lockedCompactionResult.removed === 0, "compactCache() removed entries after cache writes were locked");
assert(
JSON.stringify(plugin.cache.cacheData.entries) === JSON.stringify(lockedEntrySnapshot),
"Cache write lock allowed a post-unload mutation"
@@ -7048,7 +7039,7 @@ try {
}
});
counts = await plugin.getImageCompressionCounts();
- assert(counts.uncompressedImages === 0, "Legacy moved compressed file was not treated as processed");
+ assert(counts.uncompressedImages === 1, "Legacy original-size cache entry still hid an original from compression");
await setMockFiles(plugin, [createMockFile("Images/legacy-mtime-only.jpg", 100000, 9)]);
await setCacheEntries(plugin, {
@@ -7059,7 +7050,7 @@ try {
}
});
counts = await plugin.getImageCompressionCounts();
- assert(counts.uncompressedImages === 0, "Legacy mtime-only cache entry was not treated as processed");
+ assert(counts.uncompressedImages === 1, "Legacy mtime-only cache entry still hid an original from compression");
await setMockFiles(plugin, [createMockFile("Images/legacy-path-only.jpg", 120000, 99)]);
await setCacheEntries(plugin, {
@@ -7071,7 +7062,8 @@ try {
}
});
counts = await plugin.getImageCompressionCounts();
- assert(counts.uncompressedImages === 0, "Legacy path-only cache entry should stay processed for old pre-fingerprint caches");
+ assert(counts.uncompressedImages === 1, "Legacy path-only cache entry still hid an original from compression");
+ assert(plugin.cache.getEntriesForPath("Images/legacy-path-only.jpg").length === 1, "Legacy path-only cache entry was deleted instead of being ignored");
await setMockFiles(plugin, [createMockFile("Images/future-moved.jpg", 70000, 9)]);
await setCacheEntries(plugin, {
@@ -7669,6 +7661,70 @@ try {
fs.promises.copyFile = originalCopyFileForSelfMove;
}
+ const pendingConflictOriginal = path.join(moveLookupTemp, "Images", "pending-conflict.jpg");
+ const pendingConflictCompressed = path.join(moveLookupTemp, "Compressed", "Images", "pending-conflict.jpg");
+ fs.mkdirSync(path.dirname(pendingConflictOriginal), { recursive: true });
+ fs.mkdirSync(path.dirname(pendingConflictCompressed), { recursive: true });
+ fs.writeFileSync(pendingConflictOriginal, Buffer.alloc(100, 0x11));
+ fs.writeFileSync(pendingConflictCompressed, Buffer.alloc(50, 0x22));
+ const pendingConflictOriginalStats = fs.statSync(pendingConflictOriginal);
+ const pendingConflictCompressedStats = fs.statSync(pendingConflictCompressed);
+ const pendingConflictFile = Object.assign(new ObsidianMock.TFile(), createMockFile("Images/pending-conflict.jpg", pendingConflictOriginalStats.size, pendingConflictOriginalStats.mtimeMs));
+ const previousMoveLookupFiles = plugin.app._files;
+ const previousMoveLookupEntries = plugin.cache.cacheData.entries;
+ try {
+ plugin.app._files = [];
+ plugin.cache.cacheData.entries = {
+ "pending-conflict": {
+ path: pendingConflictFile.path,
+ state: "pending_move",
+ md5: MOCK_MD5,
+ sourceMtime: pendingConflictOriginalStats.mtimeMs - 1,
+ sourceSize: pendingConflictOriginalStats.size,
+ outputPath: "Compressed/Images/pending-conflict.jpg",
+ outputMtime: pendingConflictCompressedStats.mtimeMs,
+ outputSize: pendingConflictCompressedStats.size
+ }
+ };
+ const pendingConflictRecord = {
+ compressedPath: pendingConflictCompressed,
+ originalPath: pendingConflictOriginal,
+ relativePath: "Images/pending-conflict.jpg",
+ name: "pending-conflict.jpg",
+ size: pendingConflictCompressedStats.size
+ };
+ const pendingConflictResult = await plugin.moveService.createBackupBeforeMove([pendingConflictRecord]);
+ assert(pendingConflictResult.files.length === 0 && pendingConflictResult.skippedCount === 1, "Pending identity conflict entered the move set");
+ assert(pendingConflictRecord.moveSkipReason === plugin.moveService.getMoveText("move.skip.externalModification"), `Pending identity conflict used wrong skip reason: ${pendingConflictRecord.moveSkipReason}`);
+ assert(fs.statSync(pendingConflictOriginal).size === 100 && fs.existsSync(pendingConflictCompressed), "Pending identity conflict changed move inputs");
+
+ plugin.cache.cacheData.entries = {};
+ const postBackupOriginal = path.join(moveLookupTemp, "Images", "post-backup-change.jpg");
+ const postBackupCompressed = path.join(moveLookupTemp, "Compressed", "Images", "post-backup-change.jpg");
+ fs.writeFileSync(postBackupOriginal, Buffer.alloc(100, 0x33));
+ fs.writeFileSync(postBackupCompressed, Buffer.alloc(50, 0x44));
+ const postBackupStats = fs.statSync(postBackupOriginal);
+ plugin.app._files = [Object.assign(new ObsidianMock.TFile(), createMockFile("Images/post-backup-change.jpg", postBackupStats.size, postBackupStats.mtimeMs))];
+ const postBackupRecord = {
+ compressedPath: postBackupCompressed,
+ originalPath: postBackupOriginal,
+ relativePath: "Images/post-backup-change.jpg",
+ name: "post-backup-change.jpg",
+ size: 50
+ };
+ const postBackupResult = await plugin.moveService.createBackupBeforeMove([postBackupRecord]);
+ assert(postBackupResult.files.length === 1, "No-record fallback did not produce a backup-verified move task");
+ const verifiedPostBackupRecord = postBackupResult.files[0];
+ fs.writeFileSync(postBackupOriginal, Buffer.alloc(100, 0x55));
+ verifiedPostBackupRecord.originalMtimeMsBeforeMove = fs.statSync(postBackupOriginal).mtimeMs;
+ await plugin.moveService.moveSingleFile(verifiedPostBackupRecord);
+ assert(verifiedPostBackupRecord.moveSkipReason === plugin.moveService.getMoveText("move.skip.externalModification"), "Post-backup original content change was not rejected");
+ assert(fs.readFileSync(postBackupOriginal).equals(Buffer.alloc(100, 0x55)) && fs.existsSync(postBackupCompressed), "Post-backup conflict overwrote the changed original or removed the output");
+ } finally {
+ plugin.app._files = previousMoveLookupFiles;
+ plugin.cache.cacheData.entries = previousMoveLookupEntries;
+ }
+
const backupRaceOriginal = path.join(moveLookupTemp, "Images", "backup-race.jpg");
const backupRaceCompressed = path.join(moveLookupTemp, "Compressed", "Images", "backup-race.jpg");
fs.mkdirSync(path.dirname(backupRaceOriginal), { recursive: true });
diff --git a/scripts/validate-i18n.js b/scripts/validate-i18n.js
new file mode 100644
index 0000000..bf3f8a9
--- /dev/null
+++ b/scripts/validate-i18n.js
@@ -0,0 +1,92 @@
+"use strict";
+
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const { resolveRepositoryLayout } = require("./repository-layout");
+
+const { repositoryRoot, sourceRoot } = resolveRepositoryLayout(__dirname);
+const localeDir = path.join(sourceRoot, "src-ts", "locales");
+const dictionaries = {};
+for (const fileName of fs.readdirSync(localeDir).filter((fileName) => fileName.endsWith(".json"))) {
+ dictionaries[path.basename(fileName, ".json")] = JSON.parse(fs.readFileSync(path.join(localeDir, fileName), "utf8"));
+}
+const readmeLocales = [
+ "en",
+ ...fs.readdirSync(path.join(repositoryRoot, "assets"))
+ .map((fileName) => fileName.match(/^README\.(.+)\.md$/)?.[1])
+ .filter(Boolean)
+].sort();
+const locales = Object.keys(dictionaries).sort();
+const englishKeys = Object.keys(dictionaries.en || {}).sort();
+const placeholderPattern = /\{([a-zA-Z0-9_]+)\}/g;
+const interfaceKeys = [
+ "settings.title",
+ "command.compressInNote",
+ "command.compressInFolder",
+ "command.compressAll",
+ "command.moveCompressed",
+ "context.compressImage",
+ "context.compressImagesInFolder",
+ "section.quality",
+ "section.paths",
+ "section.automation",
+ "section.stats",
+ "section.move",
+ "section.cacheBackups",
+ "section.instructions",
+ "common.add",
+ "common.cancel",
+ "progress.start",
+ "progress.completed",
+ "folderSelect.title",
+ "tooltip.savings.header",
+ "move.title",
+ "backups.cache.title"
+];
+const allowedEnglishInterfaceMatches = new Set(["fr:section.instructions"]);
+const requiredScripts = {
+ ar: /[\u0600-\u06ff]/,
+ fa: /[\u0600-\u06ff]/,
+ ja: /[\u3040-\u30ff\u3400-\u9fff]/,
+ ko: /[\uac00-\ud7af]/,
+ th: /[\u0e00-\u0e7f]/,
+ "zh-cn": /[\u3400-\u9fff]/,
+ "zh-tw": /[\u3400-\u9fff]/
+};
+const technicalLiteralKeys = ["auto.move.threshold.desc", "move.noCompressedFolder"];
+
+function placeholders(value) {
+ return [...String(value).matchAll(placeholderPattern)].map((match) => match[1]).sort();
+}
+
+assert.deepStrictEqual(locales, readmeLocales, "UI locale inventory must match README languages");
+assert(englishKeys.length > 100, "English locale unexpectedly contains too few UI strings");
+
+for (const locale of locales) {
+ const dictionary = dictionaries[locale];
+ assert(dictionary && typeof dictionary === "object", `Missing built-in locale: ${locale}`);
+ assert.deepStrictEqual(Object.keys(dictionary).sort(), englishKeys, `${locale} locale keys differ from English`);
+ for (const key of englishKeys) {
+ assert(typeof dictionary[key] === "string" && dictionary[key].trim(), `${locale} has an empty translation: ${key}`);
+ assert.deepStrictEqual(placeholders(dictionary[key]), placeholders(dictionaries.en[key]), `${locale} placeholder mismatch: ${key}`);
+ assert(!dictionary[key].includes("[missing translation key]"), `${locale} contains a missing-key marker: ${key}`);
+ assert(!dictionary[key].includes("__LIC_"), `${locale} contains a translation-generation marker: ${key}`);
+ }
+ for (const key of interfaceKeys) {
+ assert(dictionary[key], `${locale} is missing interface surface key: ${key}`);
+ if (locale !== "en") {
+ assert(dictionary[key] !== dictionaries.en[key] || allowedEnglishInterfaceMatches.has(`${locale}:${key}`), `${locale} falls back to English for interface surface: ${key}`);
+ }
+ }
+ for (const key of technicalLiteralKeys) {
+ assert(dictionary[key].includes("Compressed"), `${locale} must preserve the Compressed folder name: ${key}`);
+ }
+ const requiredScript = requiredScripts[locale];
+ if (requiredScript) {
+ const localizedCount = englishKeys.filter((key) => requiredScript.test(dictionary[key])).length;
+ assert(localizedCount >= englishKeys.length * 0.8, `${locale} does not contain enough text in its expected script`);
+ }
+}
+
+process.stdout.write(`I18N QA passed: ${locales.length} locales, ${englishKeys.length} keys, placeholders and interface surfaces verified.\n`);
diff --git a/scripts/validate-license.js b/scripts/validate-license.js
index db8a97e..45ee1f1 100644
--- a/scripts/validate-license.js
+++ b/scripts/validate-license.js
@@ -25,7 +25,7 @@ const packageJson = readJson("package.json");
const licenseText = readText("LICENSE");
const thirdPartyNotices = readText("THIRD_PARTY_NOTICES.md");
const readme = readText("README.md");
-const readmeRu = readText("README.ru.md");
+const readmeRu = readText("assets/README.ru.md");
const apacheLicense = readText("licenses/Apache-2.0.txt");
const jpegCodecLicense = readText("licenses/jpeg-codec.txt");
const pngCodecLicense = readText("licenses/png-codec.txt");
@@ -46,9 +46,9 @@ if (bundlesGplCodec) {
assert(licenseText.includes("END OF TERMS AND CONDITIONS") && licenseText.includes("How to Apply These Terms to Your New Programs"), "LICENSE is missing the canonical GPL ending");
assert(!/libimagequant|pngquant's original license|Local Image Compress/i.test(licenseText), "LICENSE must not mix project or third-party notices into the canonical GPL text");
assert(/GPL-3\.0-or-later/i.test(readme), "README.md must document the GPL-3.0-or-later distribution license");
- assert(/GPL-3\.0-or-later/i.test(readmeRu), "README.ru.md must document the GPL-3.0-or-later distribution license");
+ assert(/GPL-3\.0-or-later/i.test(readmeRu), "assets/README.ru.md must document the GPL-3.0-or-later distribution license");
assert(!/Plugin code:\s*MIT/i.test(readme), "README.md still documents plugin code as MIT");
- assert(!/Код плагина:\s*MIT/i.test(readmeRu), "README.ru.md still documents plugin code as MIT");
+ assert(!/Код плагина:\s*MIT/i.test(readmeRu), "assets/README.ru.md still documents plugin code as MIT");
}
assert(apacheLicense === installedApacheLicense, "Tracked Apache-2.0 text does not match @jsquash/jpeg");
diff --git a/scripts/validate-manifest.js b/scripts/validate-manifest.js
index 7d0a6fd..700b7cb 100644
--- a/scripts/validate-manifest.js
+++ b/scripts/validate-manifest.js
@@ -11,6 +11,7 @@ const packagePath = path.join(root, "package.json");
const releaseWorkflowPath = path.join(root, ".github", "workflows", "release.yml");
const gitignorePath = path.join(root, ".gitignore");
const releasePreparePath = path.join(sourceRoot, "scripts", "prepare-release.js");
+const releaseNotesPreparePath = path.join(sourceRoot, "scripts", "prepare-release-notes.js");
const sourceTsRoot = path.join(sourceRoot, "src-ts");
const MIN_API_SURFACE_APP_VERSION = "1.4.0";
const DESKTOP_ONLY_REQUIRED_REASON = "cache, move, backup, and WASM artifact paths still depend on desktop Node fs/path APIs";
@@ -165,11 +166,20 @@ assert(fs.existsSync(releaseWorkflowPath), "Release workflow is required");
const releaseWorkflow = fs.readFileSync(releaseWorkflowPath, "utf8");
assert(fs.existsSync(releasePreparePath), "Release preparation script is required");
const releasePrepare = fs.readFileSync(releasePreparePath, "utf8");
+assert(fs.existsSync(releaseNotesPreparePath), "Release notes preparation script is required");
+const releaseNotesPrepare = fs.readFileSync(releaseNotesPreparePath, "utf8");
const gitignore = fs.readFileSync(gitignorePath, "utf8");
assert(
releaseWorkflow.includes(isDevLayout ? "npm --prefix source-recovery run prepare:release" : "npm run prepare:release"),
"Release workflow must use the validated release preparation script"
);
+assert(
+ releaseWorkflow.includes(isDevLayout ? "npm --prefix source-recovery run prepare:release-notes" : "npm run prepare:release-notes")
+ && releaseWorkflow.includes("body_path: release-notes.md")
+ && releaseNotesPrepare.includes("CHANGELOG.md")
+ && releaseNotesPrepare.includes("## Unreleased"),
+ "Release workflow must publish notes from the promoted CHANGELOG"
+);
assert(releaseWorkflow.includes('"*.*.*"'), "Release workflow must trigger on dotted tag candidates");
assert(releaseWorkflow.includes("^[0-9]+\\.[0-9]+\\.[0-9]+$"), "Release workflow must validate exact numeric SemVer tags");
assert(!releaseWorkflow.includes("GITHUB_REF_NAME#v") && !releaseWorkflow.includes('"v*"'), "Release workflow must reject v-prefixed tags");
@@ -189,6 +199,7 @@ for (const pattern of [
/^node_modules\/$/m,
/^main\.js$/m,
/^build\/$/m,
+ /^release-notes\.md$/m,
/^(?:source-recovery\/)?dist-ts\/$/m,
/^\.obsidian\/$/m,
/^data\.json$/m,
diff --git a/scripts/validate-readme-locales.js b/scripts/validate-readme-locales.js
new file mode 100644
index 0000000..39a0760
--- /dev/null
+++ b/scripts/validate-readme-locales.js
@@ -0,0 +1,68 @@
+"use strict";
+
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const { resolveRepositoryLayout } = require("./repository-layout");
+
+const { repositoryRoot } = resolveRepositoryLayout(__dirname);
+const localeNames = [
+ "ar", "de", "es", "fa", "fr", "id", "it", "ja", "ko", "nl",
+ "pl", "pt-br", "pt", "ru", "th", "tr", "uk", "vi", "zh-cn", "zh-tw"
+];
+const localeFiles = localeNames.map((locale) => path.join(repositoryRoot, "assets", `README.${locale}.md`));
+const allReadmes = [path.join(repositoryRoot, "README.md"), ...localeFiles];
+const requiredTokens = [
+ "PNG", "JPEG", "WebP", "GIF", "BMP", "HEIC/HEIF", "AVIF",
+ "65-80", "85", "Compressed", "10-1000", "1-60", "1-365", "30", "50", "true", "false",
+ "Vault/.local-image-compress/backups/cache/",
+ "Vault/.local-image-compress/backups/originals/",
+ "obsidian-paste-image-rename", "app.plugins", "main.js", "GPL-3.0-or-later", "THIRD_PARTY_NOTICES.md"
+];
+
+function slugHeading(heading) {
+ return heading
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{M}\p{N}\s-]/gu, "")
+ .replace(/[\u200c\u200d]/g, "")
+ .replace(/\s+/g, "-");
+}
+
+for (const filePath of allReadmes) {
+ assert(fs.existsSync(filePath), `Missing README: ${path.relative(repositoryRoot, filePath)}`);
+ const source = fs.readFileSync(filePath, "utf8");
+ const expectedFeatureImage = filePath === path.join(repositoryRoot, "README.md")
+ ? ""
+ : "";
+ assert(source.includes(expectedFeatureImage), `Missing feature GIF in ${path.relative(repositoryRoot, filePath)}`);
+ assert(fs.existsSync(path.resolve(path.dirname(filePath), expectedFeatureImage.match(/\(([^)]+)\)/)[1])), `Broken feature GIF link in ${path.relative(repositoryRoot, filePath)}`);
+ const languageLine = source.split(/\r?\n/).find((line) => line.startsWith("Read in your language:"));
+ assert(languageLine, `Missing language selector: ${path.relative(repositoryRoot, filePath)}`);
+ const languageTargets = [...languageLine.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)].map((match) => match[1]);
+ assert.strictEqual(languageTargets.length, 21, `Language selector must contain 21 links: ${path.relative(repositoryRoot, filePath)}`);
+ for (const target of languageTargets) {
+ assert(fs.existsSync(path.resolve(path.dirname(filePath), target)), `Broken language link in ${path.relative(repositoryRoot, filePath)}: ${target}`);
+ }
+
+ const headings = new Set([...source.matchAll(/^#{1,6}\s+(.+)$/gm)].map((match) => slugHeading(match[1])));
+ for (const anchor of [...source.matchAll(/\]\(#([^)]+)\)/g)].map((match) => match[1])) {
+ assert(headings.has(anchor), `Broken heading link in ${path.relative(repositoryRoot, filePath)}: #${anchor}`);
+ }
+}
+
+for (const filePath of allReadmes) {
+ const source = fs.readFileSync(filePath, "utf8");
+ const relativePath = path.relative(repositoryRoot, filePath);
+ assert.strictEqual([...source.matchAll(/^### .+$/gm)].length, 12, `${relativePath} must contain the 12 canonical sections`);
+ assert.strictEqual([...source.matchAll(/^- \[[^\]]+\]\(#[^)]+\)$/gm)].length, 11, `${relativePath} must contain the 11 canonical table-of-contents links`);
+ assert.strictEqual([...source.matchAll(/^$/gm)].length, 1, `${relativePath} must contain one collapsible details block`);
+ assert.strictEqual([...source.matchAll(/^.+<\/summary>$/gm)].length, 1, `${relativePath} must contain one details summary`);
+ assert.strictEqual([...source.matchAll(/^\|.+\|$/gm)].length, 14, `${relativePath} must contain the complete 12-setting table`);
+ for (const token of requiredTokens) {
+ assert(source.includes(token), `${relativePath} is missing canonical token: ${token}`);
+ }
+ assert(source.includes("100 MB") || source.includes("100 МБ"), `${relativePath} is missing the 100 MB safety limit`);
+}
+
+process.stdout.write(`README locale validation passed: ${localeFiles.length} locales, ${allReadmes.length * 21} language links.\n`);
diff --git a/src-ts/cache.ts b/src-ts/cache.ts
index 961875a..643a076 100644
--- a/src-ts/cache.ts
+++ b/src-ts/cache.ts
@@ -6,7 +6,7 @@ import { pipeline } from "stream/promises";
import { getBackupStoragePaths } from "./backup-storage";
import { getBrokenCacheFilePath, getCacheBackupPath as buildCacheBackupPath, getCacheBackupTimestamp as buildCacheBackupTimestamp, getCacheTempFilePath, isBrokenCacheFileName, isCacheBackupFileName, isCacheTempFileName as isLegacyCacheTempFileName, isValidCacheBackupFileName, LEGACY_CACHE_FILE_NAME } from "./cache-file-names";
import { ConcurrencyLimiter } from "./concurrency-limiter";
-import type { CacheData, CacheEntry, CacheEntryState, CachePathEntries, FileStatsLike, FreshCacheEntry, ImageFileLike, TimerHandle } from "./types";
+import type { CacheCompactionResult, CacheData, CacheEntry, CacheEntryState, CachePathEntries, FileStatsLike, FreshCacheEntry, ImageFileLike, TimerHandle } from "./types";
import { getErrorCode, getLogTag, getVaultBasePath, getVaultFileByPath, isAbsoluteFilesystemPath, isSafeVaultRelativePath, normalizeVaultPathForComparison, randomHexSuffix, randomHexSuffixSync, toVaultRelativePath, vaultPathsEqual } from "./utils";
type CacheWriteOptions = {
@@ -16,6 +16,14 @@ type CacheWriteOptions = {
authoritative?: boolean;
};
+type CacheFileIdentity = {
+ path: string;
+ stat: {
+ mtime: number;
+ size: number;
+ };
+};
+
type CacheApp = obsidian.App & {
manifest?: {
dir?: string;
@@ -54,7 +62,7 @@ export class Cache {
acceptingWrites: boolean;
syncFlushReplayPromise: Promise | null;
retainedFilesStatBatchSize: number;
- staleEntryPruneBatchSize: number;
+ compactionBatchSize: number;
lastInvalidMtimeFallback: number;
cacheLockOwnerId: string;
pendingSaveMergeDiskEntries: boolean;
@@ -96,7 +104,7 @@ export class Cache {
this.acceptingWrites = true;
this.syncFlushReplayPromise = null;
this.retainedFilesStatBatchSize = 1000;
- this.staleEntryPruneBatchSize = 1000;
+ this.compactionBatchSize = 200;
this.lastInvalidMtimeFallback = 0;
this.cacheLockOwnerId = `${process.pid}-${Date.now()}-${randomHexSuffixSync()}`;
this.pendingSaveMergeDiskEntries = true;
@@ -1118,6 +1126,7 @@ export class Cache {
}
this.cacheData.entries = nextEntries;
await this.saveCache({ mergeDiskEntries: false });
+ await this.compactPath(newNormalized);
}
}
getEntriesByPathMap() {
@@ -1218,13 +1227,13 @@ export class Cache {
const stateAwareEntry = sortedEntries.find(([, entry]) => !this.isLegacyEntry(entry));
return stateAwareEntry || sortedEntries[0] || null;
}
- sourceMatchesCurrentFile(entry: CacheEntry, file: ImageFileLike) {
+ sourceMatchesCurrentFile(entry: CacheEntry, file: CacheFileIdentity) {
if (!this.hasFiniteNumber(entry.sourceMtime) || !this.hasNonNegativeSize(entry.sourceSize) || !this.hasFiniteNumber(file?.stat?.mtime) || !this.hasNonNegativeSize(file?.stat?.size)) {
return false;
}
return this.normalizeMtime(entry.sourceMtime) === this.normalizeMtime(file.stat.mtime) && Number(entry.sourceSize) === Number(file.stat.size);
}
- processedMatchesCurrentFile(entry: CacheEntry, file: ImageFileLike) {
+ processedMatchesCurrentFile(entry: CacheEntry, file: CacheFileIdentity) {
if (!this.hasFiniteNumber(entry.processedMtime) || !this.hasNonNegativeSize(entry.processedSize) || !this.hasFiniteNumber(file?.stat?.mtime) || !this.hasNonNegativeSize(file?.stat?.size)) {
return false;
}
@@ -1269,9 +1278,6 @@ export class Cache {
if (this.sourceMatchesCurrentFile(entry, file)) {
return true;
}
- if (this.isLegacyEntry(entry)) {
- return true;
- }
return false;
}
return false;
@@ -1287,9 +1293,7 @@ export class Cache {
return null;
}
const sortedEntries = this.sortEntriesByTimestamp(entries);
- const stateAwareEntries = sortedEntries.filter(([, entry]) => !this.isLegacyEntry(entry));
- const candidates = stateAwareEntries.length > 0 ? stateAwareEntries : sortedEntries;
- for (const [cacheKey, entry] of candidates) {
+ for (const [cacheKey, entry] of sortedEntries.filter(([, entry]) => !this.isLegacyEntry(entry))) {
if (await this.entryMatchesCurrentFile(entry, file)) {
if (this.touchCacheEntry(entry)) {
this.scheduleLastAccessSave();
@@ -1377,6 +1381,7 @@ export class Cache {
...(outputMetadata?.outputSize !== undefined ? { outputSize: outputMetadata.outputSize } : {})
};
await this.saveCache({ mergeDiskEntries: true });
+ await this.compactPath(filePath);
} catch (error) {
console.warn(getLogTag(this), "addToCache failed:", error);
}
@@ -1415,6 +1420,7 @@ export class Cache {
};
this.cacheData.entries[cacheKey] = entry;
await this.saveCache({ mergeDiskEntries: true });
+ await this.compactPath(normalizedFilePath);
} catch (error) {
console.warn(getLogTag(this), "addSkippedEntry failed:", error);
}
@@ -1466,6 +1472,7 @@ export class Cache {
}
this.cacheData.entries[cacheKey] = movedEntry;
await this.saveCache({ mergeDiskEntries: true });
+ await this.compactPath(normalizedPath);
} catch (error) {
console.warn(getLogTag(this), "markProcessedFileMoved failed:", error);
}
@@ -1518,6 +1525,7 @@ export class Cache {
}
this.cacheData.entries[cacheKey] = skippedEntry;
await this.saveCache({ mergeDiskEntries: true });
+ await this.compactPath(normalizedPath);
} catch (error) {
console.warn(getLogTag(this), "markProcessedFileSkippedIdentical failed:", error);
}
@@ -1861,11 +1869,6 @@ export class Cache {
return [];
}
}
- // Count ghost entries (files that no longer exist)
- async getGhostEntriesCount() {
- const entriesToRemove = await this.collectGhostEntryKeys();
- return entriesToRemove.length;
- }
async yieldToUi() {
await new Promise((resolve) => {
try {
@@ -1877,92 +1880,160 @@ export class Cache {
window.setTimeout(resolve, 0);
});
}
- async pathExists(filePath: string) {
- try {
- if (!filePath) {
+ isModernCompactionEntry(entry: CacheEntry) {
+ const state = this.getCacheEntryState(entry);
+ const hasSourceIdentity = this.hasFiniteNumber(entry.sourceMtime) && this.hasNonNegativeSize(entry.sourceSize);
+ const hasProcessedIdentity = this.hasFiniteNumber(entry.processedMtime) && this.hasNonNegativeSize(entry.processedSize);
+ const hasOutputIdentity = !!this.getEntryOutputPath(entry)
+ && this.hasFiniteNumber(entry.outputMtime)
+ && this.hasNonNegativeSize(entry.outputSize);
+ switch (state) {
+ case "pending_move":
+ return hasSourceIdentity && hasOutputIdentity;
+ case "moved":
+ return hasProcessedIdentity;
+ case "skipped":
+ case "skipped_identical":
+ return hasSourceIdentity;
+ case "processed":
return false;
- }
- await fs2.promises.access(filePath);
- return true;
- } catch {
- return false;
}
}
- async collectGhostEntryKeys() {
- const entriesToRemove: string[] = [];
- const entries = this.getCachePathEntries();
- const batchSize = 200;
- for (let index = 0; index < entries.length; index++) {
- const entryPair = entries[index];
- if (!entryPair) {
+ isSourceHashCompactionState(entry: CacheEntry) {
+ const state = this.getCacheEntryState(entry);
+ return state === "pending_move" || state === "skipped" || state === "skipped_identical";
+ }
+ compactionEntryMatchesCurrentFile(entry: CacheEntry, file: ImageFileLike) {
+ switch (this.getCacheEntryState(entry)) {
+ case "pending_move":
+ case "skipped":
+ case "skipped_identical":
+ return this.sourceMatchesCurrentFile(entry, file);
+ case "moved":
+ return this.processedMatchesCurrentFile(entry, file);
+ case "processed":
+ return false;
+ }
+ }
+ async findCanonicalCompactionKey(file: ImageFileLike, entries: CachePathEntries) {
+ const matching: CachePathEntries = [];
+ for (const entryPair of entries) {
+ if (this.compactionEntryMatchesCurrentFile(entryPair[1], file)) {
+ matching.push(entryPair);
+ }
+ }
+ if (matching.length === 1) {
+ return matching[0]?.[0] || null;
+ }
+ if (matching.length < 2 || !matching.every(([, entry]) => this.isSourceHashCompactionState(entry) && /^[a-f0-9]{32}$/i.test(entry.md5 || ""))) {
+ return null;
+ }
+ const currentMd5 = await this.getFileMd5(file);
+ if (!currentMd5) {
+ return null;
+ }
+ const normalizedCurrentMd5 = currentMd5.toLowerCase();
+ const hashMatches = matching.filter(([, entry]) => entry.md5?.toLowerCase() === normalizedCurrentMd5);
+ return hashMatches.length === 1 ? hashMatches[0]?.[0] || null : null;
+ }
+ async pendingOutputExists(entry: CacheEntry) {
+ return this.getCacheEntryState(entry) === "pending_move" && await this.outputMatchesEntry(entry);
+ }
+ async collectCompactionKeys(entries: CachePathEntries) {
+ const groups = new Map();
+ for (const entryPair of entries) {
+ const entryPath = this.getEntryPath(entryPair[0], entryPair[1]);
+ const pathKey = normalizeVaultPathForComparison(entryPath);
+ const pathEntries = groups.get(pathKey) || [];
+ pathEntries.push(entryPair);
+ groups.set(pathKey, pathEntries);
+ }
+ const keys = new Set();
+ let missingFilesRemoved = 0;
+ let supersededRemoved = 0;
+ let processedGroups = 0;
+ for (const groupEntries of groups.values()) {
+ const modernEntries = groupEntries.filter(([, entry]) => this.isModernCompactionEntry(entry));
+ if (modernEntries.length === 0) {
continue;
}
- const [cacheKey, entry] = entryPair;
- const filePath = this.getEntryPath(cacheKey, entry);
- try {
- const fullPath = this.resolveVaultPath(filePath);
- if (!await this.pathExists(fullPath)) {
- entriesToRemove.push(cacheKey);
+ const firstModernEntry = modernEntries[0];
+ if (!firstModernEntry) {
+ continue;
+ }
+ const filePath = this.getEntryPath(firstModernEntry[0], firstModernEntry[1]);
+ const file = getVaultFileByPath(this.app.vault, filePath);
+ if (!file) {
+ for (const [cacheKey] of modernEntries) {
+ keys.add(cacheKey);
+ missingFilesRemoved++;
+ }
+ } else {
+ const canonicalKey = await this.findCanonicalCompactionKey(file, modernEntries);
+ if (canonicalKey) {
+ for (const [cacheKey, entry] of modernEntries) {
+ if (cacheKey === canonicalKey || await this.pendingOutputExists(entry)) {
+ continue;
+ }
+ keys.add(cacheKey);
+ supersededRemoved++;
+ }
}
- } catch {
- entriesToRemove.push(cacheKey);
}
- if ((index + 1) % batchSize === 0) {
+ processedGroups++;
+ if (processedGroups % this.compactionBatchSize === 0) {
await this.yieldToUi();
}
}
- return entriesToRemove;
+ return { keys, missingFilesRemoved, supersededRemoved };
}
- // Cleanup ghost entries from cache
- async cleanupGhostEntries() {
- if (!this.isAcceptingWrites()) {
- return 0;
+ async applyCompaction(entries: CachePathEntries): Promise {
+ const emptyResult = { removed: 0, missingFilesRemoved: 0, supersededRemoved: 0 };
+ if (!this.isAcceptingWrites() || entries.length === 0) {
+ return emptyResult;
}
- const entriesToRemove = await this.collectGhostEntryKeys();
- if (entriesToRemove.length === 0) {
- return 0;
- }
- if (!this.isAcceptingWrites()) {
- return 0;
+ const collected = await this.collectCompactionKeys(entries);
+ if (collected.keys.size === 0 || !this.isAcceptingWrites()) {
+ return emptyResult;
}
await this.createBackup();
- for (const cacheKey of entriesToRemove) {
+ for (const cacheKey of collected.keys) {
delete this.cacheData.entries[cacheKey];
}
- await this.saveCache({ mergeDiskEntries: false });
- return entriesToRemove.length;
+ await this.saveCache({ mergeDiskEntries: false, authoritative: true });
+ return {
+ removed: collected.keys.size,
+ missingFilesRemoved: collected.missingFilesRemoved,
+ supersededRemoved: collected.supersededRemoved
+ };
}
- async pruneStaleCacheEntries(retentionMonths: number, now = Date.now()) {
- if (!this.isAcceptingWrites()) {
- return 0;
+ async compactCache() {
+ return await this.applyCompaction(this.getCachePathEntries());
+ }
+ async compactPath(filePath: string) {
+ return await this.applyCompaction(this.getEntriesForPath(filePath));
+ }
+ async compactDeletedPath(filePath: string) {
+ const normalized = this.normalizeVaultPath(filePath);
+ const entries = this.getCachePathEntries().filter(([cacheKey, entry]) => {
+ const entryPath = this.getEntryPath(cacheKey, entry);
+ return vaultPathsEqual(entryPath, normalized) || normalizeVaultPathForComparison(entryPath).startsWith(`${normalizeVaultPathForComparison(normalized)}/`);
+ });
+ return await this.applyCompaction(entries);
+ }
+ async resolvePendingMoveEntry(file: CacheFileIdentity, outputPath: string): Promise<{ status: "none" | "match" | "conflict"; entry?: CacheEntry }> {
+ const normalizedOutput = this.normalizeVaultPath(outputPath);
+ const candidates = this.sortEntriesByTimestamp(this.getEntriesForPath(file.path)).filter(([, entry]) =>
+ this.getCacheEntryState(entry) === "pending_move" && vaultPathsEqual(this.getEntryOutputPath(entry), normalizedOutput)
+ );
+ if (candidates.length === 0) {
+ return { status: "none" };
}
- const numericMonths = Number(retentionMonths);
- const safeMonths = Number.isFinite(numericMonths) ? Math.max(1, Math.min(60, Math.trunc(numericMonths))) : 12;
- const cutoffMs = now - safeMonths * 30 * 24 * 60 * 60 * 1000;
- const entries = this.getCachePathEntries();
- const entriesToRemove: string[] = [];
- for (let index = 0; index < entries.length; index++) {
- const entryPair = entries[index];
- if (!entryPair) {
- continue;
- }
- const [cacheKey, entry] = entryPair;
- const retentionTime = this.getEntryRetentionTime(entry);
- if (retentionTime > 0 && retentionTime < cutoffMs) {
- entriesToRemove.push(cacheKey);
- }
- if ((index + 1) % this.staleEntryPruneBatchSize === 0) {
- await this.yieldToUi();
+ for (const [, entry] of candidates) {
+ if (this.isModernCompactionEntry(entry) && this.sourceMatchesCurrentFile(entry, file) && await this.outputMatchesEntry(entry)) {
+ return { status: "match", entry };
}
}
- if (entriesToRemove.length === 0 || !this.isAcceptingWrites()) {
- return 0;
- }
- await this.createBackup();
- for (const cacheKey of entriesToRemove) {
- delete this.cacheData.entries[cacheKey];
- }
- await this.saveCache({ mergeDiskEntries: false });
- return entriesToRemove.length;
+ return { status: "conflict" };
}
}
diff --git a/src-ts/i18n.ts b/src-ts/i18n.ts
index c15035d..8c3dc1d 100644
--- a/src-ts/i18n.ts
+++ b/src-ts/i18n.ts
@@ -1,535 +1,12 @@
import * as fs from "fs";
import * as path from "path";
-import { Notice, type App } from "obsidian";
+import { getLanguage as getObsidianLanguage, Notice, requireApiVersion, type App } from "obsidian";
+import { BUILTIN_I18N } from "./locales";
import { getLogTag, getVaultBasePath, normalizeVaultPathForComparison } from "./utils";
-type LocaleApp = Partial & {
- getLanguage?: () => string;
-};
+type LocaleApp = Partial;
-// i18n helper
-export const I18N: Record> = {
- en: {
- "settings.title": "Settings",
- "settings.loadFailed": "Settings could not be loaded; defaults were used",
- "init.failed": "Plugin initialization failed. Reload the plugin after fixing the reported error.",
- "migration.partialFailure": "Legacy plugin data migration completed with errors. Check the developer console.",
- "i18n.externalLoadFailed": "External language file could not be loaded",
- "warning.wasmInitFailed": "WebAssembly modules failed to initialize. Please reload the plugin or report a bug.",
- "command.compressInNote": "Compress all images in note",
- "command.compressInFolder": "Compress all images in folder",
- "command.compressAll": "Compress all images in vault",
- "command.moveCompressed": "Move compressed files",
- "context.compressImage": "Compress image",
- "context.compressImagesInFolder": "Compress images in folder",
- "notice.cacheUpdated": "Cache updated",
- "notice.cacheCleared": "Cache cleared",
- "notice.compressionDeferredDueToMove": "Compression is deferred while a move operation is in progress",
- "notice.operationFailed": "Operation failed. Check the developer console.",
- "validation.pathNotAllowed": "Path is not allowed in settings",
- "validation.outputFolder": "File is inside the output folder",
- "validation.alreadyCompressed": "File is already compressed",
- "validation.tooSmall": "File is too small",
- "validation.bytes": "bytes",
- "compress.error.fileAccess": "Unable to access file",
- "compress.error.unknown": "unknown error",
- "compress.error.tooLarge": "Image is too large to compress safely",
- "compress.error.notSmaller": "Compressed file is not smaller than original",
- "compress.error.unsupportedFormat": "Unsupported file format",
- "compress.error.copyCompressed": "Could not copy compressed file",
- "compress.error.copyCompressedJpeg": "Could not copy compressed JPEG file",
- "compress.error.pngQuality": "PNG encoder could not meet the configured quality range",
- "section.quality": "Compression quality",
- "section.paths": "Paths",
- "section.automation": "Automation",
- "section.stats": "Statistics & cache",
- "section.move": "Move compressed files",
- "section.cacheBackups": "Cache backups",
- "section.instructions": "Instructions",
- "quality.png.name": "PNG quality (min-max)",
- "quality.png.desc": "Quality range for PNG compression (65-80 by default)",
- "quality.jpeg.name": "JPEG quality",
- "quality.jpeg.desc": "JPEG compression quality (1-95)",
- "paths.allowedRoots.name": "Allowed roots",
- "paths.allowedRoots.desc": "Select folders where compression is allowed (empty = all paths)",
- "paths.allowedRoots.empty": "Not selected (compress everywhere)",
- "paths.allowedRoots.pill.remove": "Click to remove",
- "paths.allowedRoots.modal.placeholder": "Select a folder...",
- "paths.allowedRoots.cannotAddRoot": "Leave the list empty to allow all folders",
- "paths.allowedRoots.clear": "Clear list",
- "paths.output.name": "Output folder",
- "paths.output.desc": "Folder name to store compressed files",
- "auto.newFiles.name": "Auto-compress new files",
- "auto.newFiles.desc": "Automatically compress new images when added to vault",
- "auto.bg.name": "Automatic background compression",
- "auto.bg.desc": "Automatically compress in background when you are inactive",
- "auto.bg.threshold.name": "Background compression threshold",
- "auto.bg.threshold.desc": "Number of uncompressed images to auto-start",
- "auto.bg.inactivity.name": "Inactivity threshold (minutes)",
- "auto.bg.inactivity.desc": "Minutes without input before background compression can start",
- "guard.disabled": "{id} was temporarily disabled during compression",
- "guard.restored": "{id} was restored after compression",
- "auto.retention.toggle.name": "Auto-delete image backups",
- "auto.retention.toggle.desc": "Delete image backups by retention period",
- "auto.retention.days.name": "Backups retention (days)",
- "auto.retention.days.desc": "Delete backups older than the specified number of days",
- "auto.move.toggle.name": "Enable auto-move of compressed files",
- "auto.move.toggle.desc": "Automatically move compressed files when threshold is reached",
- "auto.move.threshold.name": "Auto-move threshold (items)",
- "auto.move.threshold.desc": "How many files in 'Compressed' to trigger auto-move",
- "auto.queueFull": "Auto-compress queue is full ({max}); new files were skipped",
- "background.starting": "Background compression started for {count} images",
- "background.finished": "Background compression finished: {count} images compressed",
- "auto.cleanupGhosts.name": "Auto-clean ghost entries on start",
- "auto.cleanupGhosts.desc": "Automatically remove cache entries pointing to deleted files on startup",
- "stats.uncompressed.name": "Uncompressed images",
- "stats.uncompressed.ready": "images ready to compress",
- "stats.cache.name": "Cache entries",
- "stats.cache.entries": "entries",
- "stats.cache.size": "size",
- "stats.cache.retention.name": "Cache retention (months)",
- "stats.cache.retention.desc": "Remove cache entries that have not been used for the selected number of months",
- "cache.corruptSaved": "Cache could not be read. A recovery copy was saved:",
- "stats.ghosts.name": "Ghost entries",
- "stats.ghosts.pointToMissing": "cache entries point to deleted files",
- "stats.ghosts.clearedCount": "{count} ghost entries cleared",
- "move.title": "Move compressed files",
- "move.ready": "compressed files are ready to move",
- "move.button": "Move",
- "move.noCompressedFolder": "Compressed folder not found",
- "move.noneToMove": "No compressed files to move",
- "move.noneWithOriginals": "No compressed files with originals in allowed roots",
- "move.backupCreated": "Created backup of original and compressed files",
- "move.backup.createdCount": "Created backup of {count} original files",
- "move.skip.ambiguousOriginal": "Ambiguous original filename",
- "move.skip.originalMissingBeforeBackup": "Original missing before backup",
- "move.skip.compressedMissingBeforeBackup": "Compressed output missing before backup",
- "move.skip.originalModifiedDuringBackup": "Original changed during backup",
- "move.skip.compressedModifiedDuringBackup": "Compressed output changed during backup",
- "move.skip.originalContentChangedDuringBackup": "Original content changed during backup",
- "move.skip.compressedContentChangedDuringBackup": "Compressed output content changed during backup",
- "move.skip.contentChangedDuringCopy": "Content changed while backup was being copied",
- "move.skip.originalNotFoundAtMoveTime": "Original not found at move time",
- "move.skip.selfMove": "Compressed path matches the original file",
- "move.skip.externalModification": "External modification detected during move",
- "move.skip.invalidBackupTask": "Invalid backup task",
- "move.skip.noOriginalCandidate": "Original candidate not found",
- "move.skip.unloading": "Plugin unloading",
- "move.warning.externalModification": "External modification detected during move: {name}",
- "backups.imagesFolder.name": "Image backups folder",
- "backups.imagesFolder.desc": "Open folder with backups of original/compressed images",
- "backups.imagesFolder.openButton": "Open image backups folder",
- "backups.imagesFolder.openError": "Failed to open image backups folder",
- "backups.imagesFolder.clearName": "Clear image backups",
- "backups.imagesFolder.clearDesc": "Delete backups of moved original/compressed images",
- "backups.imagesFolder.clearButton": "Clear image backups",
- "backups.imagesFolder.notFound": "Backups folder not found",
- "backups.imagesFolder.noneToDelete": "No backups to delete",
- "backups.imagesFolder.deletedCount": "Deleted {count} original file backups",
- "backups.imagesFolder.clearError": "Error while clearing backups",
- "backups.cache.title": "Cache backups",
- "backups.cache.folder.name": "Cache backups folder",
- "backups.cache.folder.desc": "Open folder with cache backup files",
- "backups.cache.folder.openButton": "Open cache backups folder",
- "backups.pathLabel": "Path",
- "backups.foundLabel": "Backups found",
- "backups.cache.none": "No cache backups available",
- "backups.cache.restore": "Restore cache from backup",
- "backups.cache.available": "Available backups:",
- "backups.cache.selectPlaceholder": "-- Select backup --",
- "common.add": "Add",
- "common.refresh": "Refresh",
- "common.refreshing": "Refreshing...",
- "common.processing": "Processing...",
- "common.clearCache": "Clear cache",
- "common.refreshCache": "Refresh cache",
- "common.clearGhosts": "Clear ghosts",
- "common.clear": "Clear",
- "common.clearing": "Clearing...",
- "common.cancel": "Cancel",
- "common.close": "Close",
- "units.kb": "KB",
- "instructions.usageTitle": "Usage:",
- "instructions.notesTitle": "Notes:",
- "instructions.notes.saved": "Compressed files are saved in",
- "instructions.notes.originalUnchanged": "Original files are not modified",
- "instructions.notes.recompressionSkipped": "Re-compression is skipped thanks to cache"
- ,"progress.start": "Starting..."
- ,"progress.processing": "Processing"
- ,"progress.skippedAlready": "Skipped (already compressed)"
- ,"progress.skipped": "Skipped"
- ,"progress.compressed": "Compressed"
- ,"progress.error": "Error"
- ,"progress.cancelling": "Cancelling..."
- ,"progress.cancelled": "Cancelled"
- ,"status.loading": "Loading..."
- ,"status.indexing": "Indexing images..."
- ,"progress.completed": "Completed! Compressed"
- ,"folders.noneInVault": "No folders found in vault"
- ,"folderSelect.title": "Select a folder for image compression"
- ,"folderSelect.selectLabel": "Folder"
- ,"folderSelect.root": "Root folder"
- ,"folderSelect.select": "Select"
- ,"folderSelect.cancel": "Cancel"
- ,"instructions.action.rightClick": "Right-click an image →"
- ,"instructions.action.commandPalette": "Command palette →"
- ,"savings.original": "Original"
- ,"savings.current": "Current"
- ,"savings.saved": "Saved"
- ,"tooltip.savings.header": "Space savings details"
- ,"tooltip.savings.original": "Original size:"
- ,"tooltip.savings.current": "Current size:"
- ,"tooltip.savings.saved": "Space saved:"
- ,"tooltip.savings.filesProcessed": "Files processed:"
- ,"tooltip.savings.estimated": "estimated"
- },
- ru: {
- "settings.title": "Настройки",
- "settings.loadFailed": "Не удалось загрузить настройки; использованы значения по умолчанию",
- "init.failed": "Не удалось инициализировать плагин. Перезагрузите плагин после исправления ошибки.",
- "migration.partialFailure": "Перенос данных старой версии завершился с ошибками. Проверьте консоль разработчика.",
- "i18n.externalLoadFailed": "Не удалось загрузить внешний файл языка",
- "warning.wasmInitFailed": "Не удалось инициализировать WebAssembly модули. Перезагрузите плагин или сообщите о проблеме.",
- "command.compressInNote": "Сжать все изображения в заметке",
- "command.compressInFolder": "Сжать все изображения в папке",
- "command.compressAll": "Сжать все изображения в vault",
- "command.moveCompressed": "Переместить сжатые файлы",
- "context.compressImage": "Сжать изображение",
- "context.compressImagesInFolder": "Сжать изображения в папке",
- "notice.cacheUpdated": "Кэш обновлен",
- "notice.cacheCleared": "Кэш очищен",
- "notice.compressionDeferredDueToMove": "Сжатие отложено, пока выполняется перенос файлов",
- "notice.operationFailed": "Операция не выполнена. Проверьте консоль разработчика.",
- "validation.pathNotAllowed": "Путь не разрешён в настройках",
- "validation.outputFolder": "Файл находится в папке для сжатых файлов",
- "validation.alreadyCompressed": "Файл уже сжат",
- "validation.tooSmall": "Файл слишком маленький",
- "validation.bytes": "байт",
- "compress.error.fileAccess": "Невозможно получить доступ к файлу",
- "compress.error.unknown": "неизвестная ошибка",
- "compress.error.tooLarge": "Изображение слишком большое для безопасного сжатия",
- "compress.error.unsupportedFormat": "Неподдерживаемый формат файла",
- "compress.error.copyCompressed": "Не удалось скопировать сжатый файл",
- "compress.error.copyCompressedJpeg": "Не удалось скопировать сжатый JPEG файл",
- "compress.error.pngQuality": "PNG-кодек не смог обеспечить заданный диапазон качества",
- "section.quality": "Качество сжатия",
- "section.paths": "Пути",
- "section.automation": "Автоматизация",
- "section.stats": "Статистика и кэш",
- "section.move": "Перемещение сжатых файлов",
- "section.cacheBackups": "Бэкапы кеша",
- "section.instructions": "Инструкции",
- "quality.png.name": "Качество PNG (мин-макс)",
- "quality.png.desc": "Диапазон качества для сжатия PNG файлов (65-80 по умолчанию)",
- "quality.jpeg.name": "Качество JPEG",
- "quality.jpeg.desc": "Качество сжатия JPEG файлов (1-95)",
- "paths.allowedRoots.name": "Разрешённые корни",
- "paths.allowedRoots.desc": "Выберите папки, где разрешено сжатие (пусто = все пути)",
- "paths.allowedRoots.empty": "Не выбрано (сжимаем везде)",
- "paths.allowedRoots.pill.remove": "Нажмите, чтобы удалить",
- "paths.allowedRoots.modal.placeholder": "Выберите папку...",
- "paths.allowedRoots.cannotAddRoot": "Оставьте список пустым, чтобы разрешить все папки",
- "paths.allowedRoots.clear": "Очистить список",
- "paths.output.name": "Выходная папка",
- "paths.output.desc": "Имя папки для сохранения сжатых файлов",
- "auto.newFiles.name": "Автосжатие новых файлов",
- "auto.newFiles.desc": "Автоматически сжимать новые изображения при добавлении в vault",
- "auto.bg.name": "Автоматическое фоновое сжатие",
- "auto.bg.desc": "Автоматически сжимать в фоне, когда вы неактивны",
- "auto.bg.threshold.name": "Порог для фонового сжатия",
- "auto.bg.threshold.desc": "Количество несжатых изображений для автозапуска",
- "guard.disabled": "{id} временно отключён на время сжатия",
- "guard.restored": "{id} восстановлен после сжатия",
- "auto.retention.toggle.name": "Автоудаление бэкапов изображений",
- "auto.retention.toggle.desc": "Удалять бэкапы изображений по сроку хранения",
- "auto.retention.days.name": "Срок хранения бэкапов (дней)",
- "auto.retention.days.desc": "Удалять бэкапы, которым больше указанного количества дней",
- "auto.move.toggle.name": "Автоперемещение сжатых файлов — включить",
- "auto.move.toggle.desc": "Автоматически перемещать сжатые файлы при достижении порога",
- "auto.move.threshold.name": "Порог автоперемещения (шт.)",
- "auto.move.threshold.desc": "Сколько сжатых файлов в 'Compressed' нужно для автоперемещения",
- "auto.queueFull": "Очередь автосжатия заполнена ({max}); новые файлы пропущены",
- "background.starting": "Фоновое сжатие запущено для изображений: {count}",
- "background.finished": "Фоновое сжатие завершено, сжато изображений: {count}",
- "auto.bg.inactivity.name": "Порог бездействия (минуты)",
- "auto.bg.inactivity.desc": "Сколько минут без ввода ждать перед запуском фонового сжатия",
- "auto.cleanupGhosts.name": "Автоочистка призраков при старте",
- "auto.cleanupGhosts.desc": "Автоматически удалять записи кэша, указывающие на удалённые файлы, при запуске Obsidian",
- "stats.uncompressed.name": "Несжатых изображений",
- "stats.uncompressed.ready": "изображений готовы к сжатию",
- "stats.cache.name": "Записей в кэше",
- "stats.cache.entries": "записей",
- "stats.cache.size": "размер",
- "stats.cache.retention.name": "Хранение кеша (месяцы)",
- "stats.cache.retention.desc": "Удалять записи кеша, которые не использовались выбранное количество месяцев",
- "cache.corruptSaved": "Кеш не удалось прочитать. Копия для восстановления сохранена:",
- "stats.ghosts.name": "Призрачные записи",
- "stats.ghosts.pointToMissing": "записей в кэше ссылаются на удалённые файлы",
- "stats.ghosts.clearedCount": "Очищено призрачных записей: {count}",
- "move.title": "Переместить сжатые файлы",
- "move.ready": "сжатых файлов готовы к перемещению",
- "move.button": "Переместить",
- "move.noCompressedFolder": "Папка Compressed не найдена",
- "move.noneToMove": "Нет сжатых файлов для перемещения",
- "move.noneWithOriginals": "Нет сжатых файлов с оригиналами в разрешённых корнях",
- "move.backupCreated": "Создан бэкап оригинальных и сжатых файлов",
- "move.backup.createdCount": "Создана резервная копия {count} оригинальных файлов",
- "move.skip.ambiguousOriginal": "Неоднозначное имя оригинала",
- "move.skip.originalMissingBeforeBackup": "Оригинал отсутствует перед бэкапом",
- "move.skip.compressedMissingBeforeBackup": "Сжатый файл отсутствует перед бэкапом",
- "move.skip.originalModifiedDuringBackup": "Оригинал изменился во время бэкапа",
- "move.skip.compressedModifiedDuringBackup": "Сжатый файл изменился во время бэкапа",
- "move.skip.originalContentChangedDuringBackup": "Содержимое оригинала изменилось во время бэкапа",
- "move.skip.compressedContentChangedDuringBackup": "Содержимое сжатого файла изменилось во время бэкапа",
- "move.skip.contentChangedDuringCopy": "Содержимое изменилось во время копирования бэкапа",
- "move.skip.originalNotFoundAtMoveTime": "Оригинал не найден во время перемещения",
- "move.skip.selfMove": "Путь сжатого файла совпадает с оригиналом",
- "move.skip.externalModification": "Внешнее изменение во время перемещения",
- "move.skip.invalidBackupTask": "Некорректная задача бэкапа",
- "move.skip.noOriginalCandidate": "Кандидат оригинала не найден",
- "move.skip.unloading": "Плагин выгружается",
- "move.warning.externalModification": "Обнаружено внешнее изменение во время перемещения: {name}",
- "backups.imagesFolder.name": "Папка с бэкапами изображений",
- "backups.imagesFolder.desc": "Открыть папку с бэкапами оригинальных/сжатых изображений",
- "backups.imagesFolder.openButton": "Открыть папку бэкапов изображений",
- "backups.imagesFolder.openError": "Не удалось открыть папку бэкапов изображений",
- "backups.imagesFolder.clearName": "Очистить бэкапы изображений",
- "backups.imagesFolder.clearDesc": "Удалить бэкапы перемещённых оригинальных/сжатых изображений",
- "backups.imagesFolder.clearButton": "Очистить бэкапы изображений",
- "backups.imagesFolder.notFound": "Папка бэкапов не найдена",
- "backups.imagesFolder.noneToDelete": "Нет бэкапов для удаления",
- "backups.imagesFolder.deletedCount": "Удалено бэкапов оригинальных файлов: {count}",
- "backups.imagesFolder.clearError": "Ошибка при очистке бэкапов",
- "backups.cache.title": "Бэкапы кеша",
- "backups.cache.folder.name": "Папка бэкапов кеша",
- "backups.cache.folder.desc": "Открыть папку с файлами бэкапов кеша",
- "backups.cache.folder.openButton": "Открыть папку бэкапов кеша",
- "backups.pathLabel": "Путь",
- "backups.foundLabel": "Найдено бэкапов",
- "backups.cache.none": "Нет доступных бэкапов кеша",
- "backups.cache.restore": "Восстановить кеш из бэкапа",
- "backups.cache.available": "Доступно бэкапов:",
- "backups.cache.selectPlaceholder": "-- Выберите бэкап --",
- "common.add": "Добавить",
- "common.refresh": "Обновить",
- "common.refreshing": "Обновление...",
- "common.processing": "Обработка...",
- "common.clearCache": "Очистить кэш",
- "common.refreshCache": "Обновить кэш",
- "common.clearGhosts": "Очистить призраки",
- "common.clear": "Очистить",
- "common.clearing": "Очистка...",
- "common.cancel": "Отмена",
- "common.close": "Закрыть",
- "units.kb": "КБ",
- "instructions.usageTitle": "Использование:",
- "instructions.notesTitle": "Примечания:",
- "instructions.notes.saved": "Сжатые файлы сохраняются в папку",
- "instructions.notes.originalUnchanged": "Оригинальные файлы не изменяются",
- "instructions.notes.recompressionSkipped": "Повторное сжатие пропускается благодаря кэшу"
- ,"progress.start": "Начинаем обработку..."
- ,"progress.processing": "Обработка"
- ,"progress.skippedAlready": "Пропущен (уже сжат)"
- ,"progress.skipped": "Пропущен"
- ,"progress.compressed": "Сжат"
- ,"progress.error": "Ошибка"
- ,"progress.cancelling": "Отмена..."
- ,"progress.cancelled": "Отменено"
- ,"status.loading": "Загрузка..."
- ,"status.indexing": "Индексация изображений..."
- ,"progress.completed": "Завершено! Сжато"
- ,"folders.noneInVault": "Во vault нет папок"
- ,"folderSelect.title": "Выберите папку для сжатия изображений"
- ,"folderSelect.selectLabel": "Папка"
- ,"folderSelect.root": "Корневая папка"
- ,"folderSelect.select": "Выбрать"
- ,"folderSelect.cancel": "Отмена"
- ,"instructions.action.rightClick": "Правый клик по изображению →"
- ,"instructions.action.commandPalette": "Палитра команд →"
- ,"savings.original": "Оригинал"
- ,"savings.current": "Текущий"
- ,"savings.saved": "Сэкономлено"
- ,"tooltip.savings.header": "Детали экономии места"
- ,"tooltip.savings.original": "Исходный размер:"
- ,"tooltip.savings.current": "Текущий размер:"
- ,"tooltip.savings.saved": "Экономия места:"
- ,"tooltip.savings.filesProcessed": "Обработано файлов:"
- ,"tooltip.savings.estimated": "оценено"
- },
- uk: {
- "settings.title": "Налаштування",
- "settings.loadFailed": "Не вдалося завантажити налаштування; використано типові значення",
- "init.failed": "Не вдалося ініціалізувати плагін. Перезавантажте плагін після виправлення помилки.",
- "migration.partialFailure": "Перенесення даних старої версії завершилося з помилками. Перевірте консоль розробника.",
- "i18n.externalLoadFailed": "Не вдалося завантажити зовнішній мовний файл",
- "warning.wasmInitFailed": "Не вдалося ініціалізувати WebAssembly модулі. Перезавантажте плагін або повідомте про проблему.",
- "command.compressInNote": "Стиснути всі зображення в нотатці",
- "command.compressInFolder": "Стиснути всі зображення в папці",
- "command.compressAll": "Стиснути всі зображення у vault",
- "command.moveCompressed": "Перемістити стиснені файли",
- "context.compressImage": "Стиснути зображення",
- "context.compressImagesInFolder": "Стиснути зображення в папці",
- "notice.cacheUpdated": "Кеш оновлено",
- "notice.cacheCleared": "Кеш очищено",
- "notice.compressionDeferredDueToMove": "Стиснення відкладено, доки триває перенесення файлів",
- "notice.operationFailed": "Не вдалося виконати операцію. Перевірте консоль розробника.",
- "validation.pathNotAllowed": "Шлях не дозволено в налаштуваннях",
- "validation.outputFolder": "Файл розташовано в папці для стиснених файлів",
- "validation.alreadyCompressed": "Файл уже стиснено",
- "validation.tooSmall": "Файл занадто малий",
- "validation.bytes": "байт",
- "compress.error.fileAccess": "Неможливо отримати доступ до файлу",
- "compress.error.unknown": "невідома помилка",
- "compress.error.tooLarge": "Зображення завелике для безпечного стиснення",
- "compress.error.unsupportedFormat": "Непідтримуваний формат файлу",
- "compress.error.copyCompressed": "Не вдалося скопіювати стиснений файл",
- "compress.error.copyCompressedJpeg": "Не вдалося скопіювати стиснений JPEG файл",
- "compress.error.pngQuality": "PNG-кодек не зміг забезпечити заданий діапазон якості",
- "section.quality": "Якість стиснення",
- "section.paths": "Шляхи",
- "section.automation": "Автоматизація",
- "section.stats": "Статистика та кеш",
- "section.move": "Переміщення стиснених файлів",
- "section.cacheBackups": "Бекапи кешу",
- "section.instructions": "Інструкції",
- "quality.png.name": "Якість PNG (мін-макс)",
- "quality.png.desc": "Діапазон якості для стиснення PNG (65-80 за замовчуванням)",
- "quality.jpeg.name": "Якість JPEG",
- "quality.jpeg.desc": "Якість стиснення JPEG (1-95)",
- "paths.allowedRoots.name": "Дозволені корені",
- "paths.allowedRoots.desc": "Виберіть папки, де дозволено стиснення (порожньо = всі шляхи)",
- "paths.allowedRoots.empty": "Не вибрано (стискаємо всюди)",
- "paths.allowedRoots.pill.remove": "Натисніть, щоб видалити",
- "paths.allowedRoots.modal.placeholder": "Виберіть папку...",
- "paths.allowedRoots.cannotAddRoot": "Залиште список порожнім, щоб дозволити всі папки",
- "paths.allowedRoots.clear": "Очистити список",
- "paths.output.name": "Вихідна папка",
- "paths.output.desc": "Папка для збереження стиснених файлів",
- "auto.newFiles.name": "Автостиснення нових файлів",
- "auto.newFiles.desc": "Автоматично стискати нові зображення при додаванні до vault",
- "auto.bg.name": "Автоматичне фонове стиснення",
- "auto.bg.desc": "Автоматично стискати у фоні, коли ви неактивні",
- "auto.bg.threshold.name": "Поріг для фонового стиснення",
- "auto.bg.threshold.desc": "Кількість нестиснених зображень для автозапуску",
- "guard.disabled": "{id} тимчасово вимкнено під час стиснення",
- "guard.restored": "{id} відновлено після стиснення",
- "auto.retention.toggle.name": "Автоочищення бекапів зображень",
- "auto.retention.toggle.desc": "Видаляти бекапи зображень за строком зберігання",
- "auto.retention.days.name": "Строк зберігання бекапів (днів)",
- "auto.retention.days.desc": "Видаляти бекапи, старші за вказану кількість днів",
- "auto.move.toggle.name": "Увімкнути автопереміщення стиснених файлів",
- "auto.move.toggle.desc": "Автоматично переміщувати стиснені файли при досягненні порогу",
- "auto.move.threshold.name": "Поріг автопереміщення (шт.)",
- "auto.move.threshold.desc": "Скільки файлів у 'Compressed' потрібно для автопереміщення",
- "auto.queueFull": "Чергу автостиснення заповнено ({max}); нові файли пропущено",
- "background.starting": "Фонове стиснення запущено для зображень: {count}",
- "background.finished": "Фонове стиснення завершено, стиснено зображень: {count}",
- "auto.bg.inactivity.name": "Поріг неактивності (хвилини)",
- "auto.bg.inactivity.desc": "Скільки хвилин без вводу чекати перед запуском фонового стиснення",
- "auto.cleanupGhosts.name": "Автоочищення 'привидів' при старті",
- "auto.cleanupGhosts.desc": "Автоматично видаляти записи кешу, що вказують на видалені файли, при запуску Obsidian",
- "stats.uncompressed.name": "Нестиснених зображень",
- "stats.uncompressed.ready": "зображень готові до стиснення",
- "stats.cache.name": "Записів у кеші",
- "stats.cache.entries": "записів",
- "stats.cache.size": "розмір",
- "stats.cache.retention.name": "Зберігання кешу (місяці)",
- "stats.cache.retention.desc": "Видаляти записи кешу, які не використовувалися вибрану кількість місяців",
- "cache.corruptSaved": "Кеш не вдалося прочитати. Копію для відновлення збережено:",
- "stats.ghosts.name": "Привидні записи",
- "stats.ghosts.pointToMissing": "записів у кеші вказують на видалені файли",
- "stats.ghosts.clearedCount": "Очищено привидних записів: {count}",
- "move.title": "Перемістити стиснені файли",
- "move.ready": "стиснені файли готові до переміщення",
- "move.button": "Перемістити",
- "move.noCompressedFolder": "Папку Compressed не знайдено",
- "move.noneToMove": "Немає стиснених файлів для переміщення",
- "move.noneWithOriginals": "Немає стиснених файлів з оригіналами у дозволених коренях",
- "move.backupCreated": "Створено бекап оригінальних і стиснених файлів",
- "move.backup.createdCount": "Створено резервну копію {count} оригінальних файлів",
- "move.skip.ambiguousOriginal": "Неоднозначна назва оригіналу",
- "move.skip.originalMissingBeforeBackup": "Оригінал відсутній перед бекапом",
- "move.skip.compressedMissingBeforeBackup": "Стиснений файл відсутній перед бекапом",
- "move.skip.originalModifiedDuringBackup": "Оригінал змінився під час бекапу",
- "move.skip.compressedModifiedDuringBackup": "Стиснений файл змінився під час бекапу",
- "move.skip.originalContentChangedDuringBackup": "Вміст оригіналу змінився під час бекапу",
- "move.skip.compressedContentChangedDuringBackup": "Вміст стисненого файлу змінився під час бекапу",
- "move.skip.contentChangedDuringCopy": "Вміст змінився під час копіювання бекапу",
- "move.skip.originalNotFoundAtMoveTime": "Оригінал не знайдено під час переміщення",
- "move.skip.selfMove": "Шлях стиснутого файлу збігається з оригіналом",
- "move.skip.externalModification": "Зовнішня зміна під час переміщення",
- "move.skip.invalidBackupTask": "Некоректне завдання бекапу",
- "move.skip.noOriginalCandidate": "Кандидат оригіналу не знайдено",
- "move.skip.unloading": "Плагін вивантажується",
- "move.warning.externalModification": "Виявлено зовнішню зміну під час переміщення: {name}",
- "backups.imagesFolder.name": "Папка з бекапами зображень",
- "backups.imagesFolder.desc": "Відкрити папку з бекапами оригінальних/стиснених зображень",
- "backups.imagesFolder.openButton": "Відкрити папку бекапів зображень",
- "backups.imagesFolder.openError": "Не вдалося відкрити папку бекапів зображень",
- "backups.imagesFolder.clearName": "Очистити бекапи зображень",
- "backups.imagesFolder.clearDesc": "Видалити бекапи переміщених оригінальних/стиснених зображень",
- "backups.imagesFolder.clearButton": "Очистити бекапи зображень",
- "backups.imagesFolder.notFound": "Папку бекапів не знайдено",
- "backups.imagesFolder.noneToDelete": "Немає бекапів для видалення",
- "backups.imagesFolder.deletedCount": "Видалено бекапів оригінальних файлів: {count}",
- "backups.imagesFolder.clearError": "Помилка під час очищення бекапів",
- "backups.cache.title": "Бекапи кешу",
- "backups.cache.folder.name": "Папка бекапів кешу",
- "backups.cache.folder.desc": "Відкрити папку з файлами бекапів кешу",
- "backups.cache.folder.openButton": "Відкрити папку бекапів кешу",
- "backups.pathLabel": "Шлях",
- "backups.foundLabel": "Знайдено бекапів",
- "backups.cache.none": "Немає доступних бекапів кешу",
- "backups.cache.restore": "Відновити кеш із бекапу",
- "backups.cache.available": "Доступно бекапів:",
- "backups.cache.selectPlaceholder": "-- Оберіть бекап --",
- "common.add": "Додати",
- "common.refresh": "Оновити",
- "common.refreshing": "Оновлення...",
- "common.processing": "Обробка...",
- "common.clearCache": "Очистити кеш",
- "common.refreshCache": "Оновити кеш",
- "common.clearGhosts": "Очистити привиди",
- "common.clear": "Очистити",
- "common.clearing": "Очищення...",
- "common.cancel": "Скасувати",
- "common.close": "Закрити",
- "units.kb": "КБ",
- "instructions.usageTitle": "Використання:",
- "instructions.notesTitle": "Примітки:",
- "instructions.notes.saved": "Стиснені файли зберігаються в папку",
- "instructions.notes.originalUnchanged": "Оригінальні файли не змінюються",
- "instructions.notes.recompressionSkipped": "Повторне стиснення пропускається завдяки кешу"
- ,"progress.start": "Починаємо обробку..."
- ,"progress.processing": "Обробка"
- ,"progress.skippedAlready": "Пропущено (вже стиснено)"
- ,"progress.skipped": "Пропущено"
- ,"progress.compressed": "Стиснено"
- ,"progress.error": "Помилка"
- ,"progress.cancelling": "Скасування..."
- ,"progress.cancelled": "Скасовано"
- ,"status.loading": "Завантаження..."
- ,"status.indexing": "Індексація зображень..."
- ,"progress.completed": "Завершено! Стиснено"
- ,"folders.noneInVault": "У сховищі (vault) немає папок"
- ,"folderSelect.title": "Виберіть папку для стиснення зображень"
- ,"folderSelect.selectLabel": "Папка"
- ,"folderSelect.root": "Коренева папка"
- ,"folderSelect.select": "Вибрати"
- ,"folderSelect.cancel": "Скасувати"
- ,"instructions.action.rightClick": "Клацніть правою кнопкою по зображенню →"
- ,"instructions.action.commandPalette": "Палітра команд →"
- ,"savings.original": "Оригінал"
- ,"savings.current": "Поточний"
- ,"savings.saved": "Заощаджено"
- ,"tooltip.savings.header": "Деталі заощадження місця"
- ,"tooltip.savings.original": "Початковий розмір:"
- ,"tooltip.savings.current": "Поточний розмір:"
- ,"tooltip.savings.saved": "Заощаджено місця:"
- ,"tooltip.savings.filesProcessed": "Опрацьовано файлів:"
- ,"tooltip.savings.estimated": "оцінено"
- }
-};
+export const I18N = BUILTIN_I18N;
// Optional external translations loader (lang/*.json). Preloaded async; t() stays sync and memory-only.
type TranslationParams = Record;
type LoadedLangCache = {
@@ -552,7 +29,7 @@ export function resolvePluginDirFromApp(app: LocaleApp | null | undefined): stri
}
function normalizeLanguageTag(lang: string | null | undefined): string {
- return String(lang || "en").toLowerCase();
+ return String(lang || "en").toLowerCase().replace(/_/g, "-");
}
function getPrimaryLanguage(lang: string): string {
@@ -560,10 +37,27 @@ function getPrimaryLanguage(lang: string): string {
return fullLang.split(/[_.-]/)[0] || "en";
}
-function getBuiltinLanguage(lang: string): "en" | "ru" | "uk" {
- const primary = getPrimaryLanguage(lang);
- if (primary === "ru" || primary === "be" || primary === "by") return "ru";
- if (primary === "uk" || primary === "ua") return "uk";
+function getBuiltinLanguage(lang: string): string {
+ const fullLang = normalizeLanguageTag(lang);
+ const aliases: Record = {
+ be: "ru",
+ by: "ru",
+ ua: "uk",
+ zh: "zh-cn",
+ "zh-hans": "zh-cn",
+ "zh-sg": "zh-cn",
+ "zh-hant": "zh-tw",
+ "zh-hk": "zh-tw",
+ "zh-mo": "zh-tw"
+ };
+ const exact = aliases[fullLang] || fullLang;
+ if (I18N[exact]) {
+ return exact;
+ }
+ const primary = aliases[getPrimaryLanguage(fullLang)] || getPrimaryLanguage(fullLang);
+ if (I18N[primary]) {
+ return primary;
+ }
return "en";
}
@@ -636,15 +130,11 @@ export function getMergedDict(app: LocaleApp | null | undefined, lang: string):
}
return merged;
}
-export function getUserLang(app: LocaleApp | null | undefined): string {
+export function getUserLang(_app: LocaleApp | null | undefined): string {
try {
- const detected = typeof app?.getLanguage === "function" ? app.getLanguage() : null;
+ const detected = requireApiVersion("1.8.7") ? getObsidianLanguage() : null;
const raw = detected && detected !== "system" ? detected : null;
- const l = normalizeLanguageTag(raw || "en");
- const primary = getPrimaryLanguage(l);
- if (primary === "ru" || primary === "be" || primary === "by") return "ru";
- if (primary === "uk" || primary === "ua") return "uk";
- return "en";
+ return getBuiltinLanguage(raw || "en");
} catch (error) {
console.debug(getLogTag({ manifest: { name: 'Local Image Compress' } }), "i18n: failed to detect user language", error);
}
diff --git a/src-ts/locales/ar.json b/src-ts/locales/ar.json
new file mode 100644
index 0000000..1228180
--- /dev/null
+++ b/src-ts/locales/ar.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "الإعدادات",
+ "settings.loadFailed": "لا يمكن تحميل الإعدادات؛ تم استخدام الإعدادات الافتراضية",
+ "init.failed": "فشلت تهيئة الإضافة. أعد تحميل الإضافة بعد إصلاح الخطأ المُبلغ عنه.",
+ "migration.partialFailure": "اكتملت ترحيل بيانات الإضافة القديمة مع وجود أخطاء. تحقق من وحدة تحكم المطور.",
+ "i18n.externalLoadFailed": "تعذر تحميل ملف اللغة الخارجي",
+ "warning.wasmInitFailed": "تعذرت تهيئة وحدات WebAssembly. أعد تحميل الإضافة أو أبلغ عن الخطأ.",
+ "command.compressInNote": "ضغط جميع الصور في الملاحظة",
+ "command.compressInFolder": "ضغط جميع الصور في المجلد",
+ "command.compressAll": "ضغط جميع الصور في الخزنة",
+ "command.moveCompressed": "نقل الملفات المضغوطة",
+ "context.compressImage": "ضغط الصورة",
+ "context.compressImagesInFolder": "ضغط الصور في المجلد",
+ "notice.cacheUpdated": "تم تحديث ذاكرة التخزين المؤقت",
+ "notice.cacheCleared": "تم مسح ذاكرة التخزين المؤقت",
+ "notice.compressionDeferredDueToMove": "يتم تأجيل الضغط بينما تكون عملية النقل قيد التقدم",
+ "notice.operationFailed": "فشلت العملية. تحقق من وحدة تحكم المطور.",
+ "validation.pathNotAllowed": "المسار غير مسموح به في الإعدادات",
+ "validation.outputFolder": "الملف موجود داخل مجلد الإخراج",
+ "validation.alreadyCompressed": "الملف مضغوط بالفعل",
+ "validation.tooSmall": "الملف صغير جدًا",
+ "validation.bytes": "بايت",
+ "compress.error.fileAccess": "غير قادر على الوصول إلى الملف",
+ "compress.error.unknown": "خطأ غير معروف",
+ "compress.error.tooLarge": "الصورة كبيرة جدًا بحيث لا يمكن ضغطها بأمان",
+ "compress.error.notSmaller": "الملف المضغوط ليس أصغر من الملف الأصلي",
+ "compress.error.unsupportedFormat": "تنسيق الملف غير معتمد",
+ "compress.error.copyCompressed": "لا يمكن نسخ الملف المضغوط",
+ "compress.error.copyCompressedJpeg": "لا يمكن نسخ ملف JPEG المضغوط",
+ "compress.error.pngQuality": "تعذر على برنامج تشفير PNG تلبية نطاق الجودة الذي تم تكوينه",
+ "section.quality": "جودة الضغط",
+ "section.paths": "المسارات",
+ "section.automation": "الأتمتة",
+ "section.stats": "الإحصائيات وذاكرة التخزين المؤقت",
+ "section.move": "نقل الملفات المضغوطة",
+ "section.cacheBackups": "النسخ الاحتياطية لذاكرة التخزين المؤقت",
+ "section.instructions": "تعليمات",
+ "quality.png.name": "جودة PNG (الحد الأدنى - الأقصى)",
+ "quality.png.desc": "نطاق الجودة لضغط PNG (65-80 افتراضيًا)",
+ "quality.jpeg.name": "جودة JPEG",
+ "quality.jpeg.desc": "جودة ضغط JPEG (1-95)",
+ "paths.allowedRoots.name": "الجذور المسموح بها",
+ "paths.allowedRoots.desc": "حدد المجلدات التي يُسمح فيها بالضغط (فارغ = جميع المسارات)",
+ "paths.allowedRoots.empty": "لم يتم التحديد (الضغط في كل مكان)",
+ "paths.allowedRoots.pill.remove": "انقر لإزالة",
+ "paths.allowedRoots.modal.placeholder": "حدد مجلد...",
+ "paths.allowedRoots.cannotAddRoot": "اترك القائمة فارغة للسماح بجميع المجلدات",
+ "paths.allowedRoots.clear": "مسح القائمة",
+ "paths.output.name": "مجلد الإخراج",
+ "paths.output.desc": "اسم المجلد لتخزين الملفات المضغوطة",
+ "auto.newFiles.name": "ضغط الملفات الجديدة تلقائيًا",
+ "auto.newFiles.desc": "ضغط الصور تلقائيًا عند إضافتها إلى الخزنة",
+ "auto.bg.name": "الضغط التلقائي في الخلفية",
+ "auto.bg.desc": "ضغط الصور تلقائيًا في الخلفية عند عدم النشاط",
+ "auto.bg.threshold.name": "عتبة الضغط في الخلفية",
+ "auto.bg.threshold.desc": "عدد الصور غير المضغوطة اللازم لبدء الضغط تلقائيًا",
+ "auto.bg.inactivity.name": "عتبة عدم النشاط (بالدقائق)",
+ "auto.bg.inactivity.desc": "عدد دقائق عدم نشاط المستخدم قبل بدء الضغط في الخلفية",
+ "guard.disabled": "تم تعطيل {id} مؤقتًا أثناء الضغط",
+ "guard.restored": "أُعيد تفعيل {id} بعد الضغط",
+ "auto.retention.toggle.name": "الحذف التلقائي للنسخ الاحتياطية للصور",
+ "auto.retention.toggle.desc": "حذف النسخ الاحتياطية للصور حسب فترة الاحتفاظ",
+ "auto.retention.days.name": "الاحتفاظ بالنسخ الاحتياطية (أيام)",
+ "auto.retention.days.desc": "حذف النسخ الاحتياطية الأقدم من عدد الأيام المحدد",
+ "auto.move.toggle.name": "تمكين النقل التلقائي للملفات المضغوطة",
+ "auto.move.toggle.desc": "نقل الملفات المضغوطة تلقائيًا عند بلوغ العتبة",
+ "auto.move.threshold.name": "عتبة النقل التلقائي (الملفات)",
+ "auto.move.threshold.desc": "عدد الملفات في 'Compressed' اللازم لتشغيل النقل التلقائي",
+ "auto.queueFull": "قائمة انتظار الضغط التلقائي ممتلئة ({max})؛ تم تخطي الملفات الجديدة",
+ "background.starting": "بدأ الضغط في الخلفية. عدد الصور: {count}",
+ "background.finished": "اكتمل الضغط في الخلفية. عدد الصور المضغوطة: {count}",
+ "stats.uncompressed.name": "صور غير مضغوطة",
+ "stats.uncompressed.ready": "الصور الجاهزة للضغط",
+ "stats.cache.name": "إدخالات ذاكرة التخزين المؤقت",
+ "stats.cache.entries": "إدخالات ذاكرة التخزين المؤقت",
+ "stats.cache.size": "الحجم",
+ "cache.corruptSaved": "لا يمكن قراءة ذاكرة التخزين المؤقت. تم حفظ نسخة الاسترداد:",
+ "move.title": "نقل الملفات المضغوطة",
+ "move.ready": "الملفات الجاهزة للنقل",
+ "move.button": "نقل",
+ "move.noCompressedFolder": "لم يتم العثور على المجلد Compressed",
+ "move.noneToMove": "لا توجد ملفات مضغوطة لنقلها",
+ "move.noneWithOriginals": "لا توجد ملفات مضغوطة لها ملفات أصلية في المجلدات الجذرية المسموح بها",
+ "move.backupCreated": "تم إنشاء نسخة احتياطية من الملفات الأصلية والمضغوطة",
+ "move.backup.createdCount": "أزواج الملفات الأصلية/المضغوطة التي تم نسخها احتياطيًا: {count}",
+ "move.skip.ambiguousOriginal": "اسم الملف الأصلي غامض",
+ "move.skip.originalMissingBeforeBackup": "الأصل مفقود قبل النسخ الاحتياطي",
+ "move.skip.compressedMissingBeforeBackup": "الملف المضغوط مفقود قبل النسخ الاحتياطي",
+ "move.skip.originalModifiedDuringBackup": "تم تغيير الأصل أثناء النسخ الاحتياطي",
+ "move.skip.compressedModifiedDuringBackup": "تغير الملف المضغوط أثناء النسخ الاحتياطي",
+ "move.skip.originalContentChangedDuringBackup": "تغير محتوى الملف الأصلي أثناء النسخ الاحتياطي",
+ "move.skip.compressedContentChangedDuringBackup": "تغير محتوى الملف المضغوط أثناء النسخ الاحتياطي",
+ "move.skip.contentChangedDuringCopy": "تم تغيير المحتوى أثناء نسخ النسخة الاحتياطية",
+ "move.skip.originalNotFoundAtMoveTime": "لم يتم العثور على الأصل في وقت النقل",
+ "move.skip.selfMove": "مسار الملف المضغوط يطابق مسار الملف الأصلي",
+ "move.skip.externalModification": "تم اكتشاف تعديل خارجي أثناء النقل",
+ "move.skip.invalidBackupTask": "مهمة النسخ الاحتياطي غير صالحة",
+ "move.skip.noOriginalCandidate": "لم يتم العثور على ملف أصلي مطابق",
+ "move.skip.unloading": "جارٍ إلغاء تحميل الإضافة",
+ "move.warning.externalModification": "تم اكتشاف تعديل خارجي أثناء النقل: {name}",
+ "backups.imagesFolder.name": "مجلد النسخ الاحتياطية للصور",
+ "backups.imagesFolder.desc": "افتح المجلد الذي يحتوي على نسخ احتياطية من الصور الأصلية/المضغوطة",
+ "backups.imagesFolder.openButton": "افتح مجلد النسخ الاحتياطية للصور",
+ "backups.imagesFolder.openError": "فشل في فتح مجلد النسخ الاحتياطية للصور",
+ "backups.imagesFolder.clearName": "مسح النسخ الاحتياطية للصور",
+ "backups.imagesFolder.clearDesc": "حذف النسخ الاحتياطية للصور الأصلية/المضغوطة المنقولة",
+ "backups.imagesFolder.clearButton": "مسح النسخ الاحتياطية للصور",
+ "backups.imagesFolder.notFound": "لم يتم العثور على مجلد النسخ الاحتياطية",
+ "backups.imagesFolder.noneToDelete": "لا توجد نسخ احتياطية لحذفها",
+ "backups.imagesFolder.deletedCount": "مجموعات النسخ الاحتياطية للصور المحذوفة: {count}",
+ "backups.imagesFolder.clearError": "حدث خطأ أثناء مسح النسخ الاحتياطية",
+ "backups.cache.title": "النسخ الاحتياطية لذاكرة التخزين المؤقت",
+ "backups.cache.folder.name": "مجلد النسخ الاحتياطية لذاكرة التخزين المؤقت",
+ "backups.cache.folder.desc": "افتح المجلد الذي يحتوي على ملفات النسخ الاحتياطي لذاكرة التخزين المؤقت",
+ "backups.cache.folder.openButton": "افتح مجلد النسخ الاحتياطية لذاكرة التخزين المؤقت",
+ "backups.pathLabel": "المسار",
+ "backups.foundLabel": "تم العثور على نسخ احتياطية",
+ "backups.cache.none": "لا تتوفر نسخ احتياطية لذاكرة التخزين المؤقت",
+ "backups.cache.restore": "استعادة ذاكرة التخزين المؤقت من النسخة الاحتياطية",
+ "backups.cache.available": "النسخ الاحتياطية المتاحة:",
+ "backups.cache.selectPlaceholder": "- اختر نسخة احتياطية -",
+ "common.add": "أضف",
+ "common.refresh": "تحديث",
+ "common.refreshing": "جارٍ التحديث...",
+ "common.processing": "جارٍ المعالجة...",
+ "common.clearCache": "مسح ذاكرة التخزين المؤقت",
+ "common.refreshCache": "تحديث ذاكرة التخزين المؤقت",
+ "common.clear": "مسح",
+ "common.clearing": "جارٍ المسح...",
+ "common.cancel": "إلغاء",
+ "common.close": "إغلاق",
+ "units.kb": "كيلو بايت",
+ "instructions.usageTitle": "الاستخدام:",
+ "instructions.notesTitle": "ملاحظات:",
+ "instructions.notes.saved": "يتم حفظ الملفات المضغوطة في",
+ "instructions.notes.originalUnchanged": "لا يتم تعديل الملفات الأصلية",
+ "instructions.notes.recompressionSkipped": "يتم تخطي إعادة الضغط بفضل ذاكرة التخزين المؤقت",
+ "progress.start": "جارٍ البدء...",
+ "progress.processing": "المعالجة",
+ "progress.skippedAlready": "تم تخطيه (مضغوط بالفعل)",
+ "progress.skipped": "تم تخطيه",
+ "progress.compressed": "مضغوط",
+ "progress.error": "خطأ",
+ "progress.cancelling": "جارٍ الإلغاء...",
+ "progress.cancelled": "تم الإلغاء",
+ "status.loading": "جارٍ التحميل...",
+ "status.indexing": "فهرسة الصور...",
+ "progress.completed": "اكتملت العملية",
+ "folders.noneInVault": "لم يتم العثور على مجلدات في الخزنة",
+ "folderSelect.title": "حدد مجلدًا لضغط الصور",
+ "folderSelect.selectLabel": "مجلد",
+ "folderSelect.root": "المجلد الجذر",
+ "folderSelect.select": "اختر",
+ "folderSelect.cancel": "إلغاء",
+ "instructions.action.rightClick": "انقر بزر الماوس الأيمن على الصورة →",
+ "instructions.action.commandPalette": "لوحة الأوامر →",
+ "savings.original": "أصلي",
+ "savings.current": "الحالي",
+ "savings.saved": "الموفَّر",
+ "tooltip.savings.header": "تفاصيل توفير المساحة",
+ "tooltip.savings.original": "الحجم الأصلي:",
+ "tooltip.savings.current": "الحجم الحالي:",
+ "tooltip.savings.saved": "المساحة الموفّرة:",
+ "tooltip.savings.filesProcessed": "الملفات التي تمت معالجتها:",
+ "tooltip.savings.estimated": "الملفات التقديرية"
+}
diff --git a/src-ts/locales/de.json b/src-ts/locales/de.json
new file mode 100644
index 0000000..e589168
--- /dev/null
+++ b/src-ts/locales/de.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Einstellungen",
+ "settings.loadFailed": "Einstellungen konnten nicht geladen werden; es wurden Standardwerte verwendet",
+ "init.failed": "Die Erweiterung konnte nicht initialisiert werden. Laden Sie die Erweiterung neu, nachdem Sie den gemeldeten Fehler behoben haben.",
+ "migration.partialFailure": "Die Migration älterer Erweiterungsdaten wurde mit Fehlern abgeschlossen. Prüfen Sie die Entwicklerkonsole.",
+ "i18n.externalLoadFailed": "Externe Sprachdatei konnte nicht geladen werden",
+ "warning.wasmInitFailed": "WebAssembly-Module konnten nicht initialisiert werden. Laden Sie die Erweiterung neu oder melden Sie den Fehler.",
+ "command.compressInNote": "Alle Bilder in der aktuellen Notiz komprimieren",
+ "command.compressInFolder": "Alle Bilder in einem Ordner komprimieren",
+ "command.compressAll": "Alle Bilder im Vault komprimieren",
+ "command.moveCompressed": "Komprimierte Dateien verschieben",
+ "context.compressImage": "Bild komprimieren",
+ "context.compressImagesInFolder": "Bilder im Ordner komprimieren",
+ "notice.cacheUpdated": "Cache aktualisiert",
+ "notice.cacheCleared": "Cache geleert",
+ "notice.compressionDeferredDueToMove": "Die Komprimierung wird verzögert, während ein Verschiebungsvorgang ausgeführt wird",
+ "notice.operationFailed": "Der Vorgang ist fehlgeschlagen. Überprüfen Sie die Entwicklerkonsole.",
+ "validation.pathNotAllowed": "Der Pfad ist in den Einstellungen nicht zulässig",
+ "validation.outputFolder": "Die Datei befindet sich im Ausgabeordner",
+ "validation.alreadyCompressed": "Die Datei ist bereits komprimiert",
+ "validation.tooSmall": "Datei ist zu klein",
+ "validation.bytes": "Bytes",
+ "compress.error.fileAccess": "Auf die Datei kann nicht zugegriffen werden",
+ "compress.error.unknown": "unbekannter Fehler",
+ "compress.error.tooLarge": "Das Bild ist zu groß, um sicher komprimiert zu werden",
+ "compress.error.notSmaller": "Die komprimierte Datei ist nicht kleiner als das Original",
+ "compress.error.unsupportedFormat": "Nicht unterstütztes Dateiformat",
+ "compress.error.copyCompressed": "Die komprimierte Datei konnte nicht kopiert werden",
+ "compress.error.copyCompressedJpeg": "Die komprimierte JPEG-Datei konnte nicht kopiert werden",
+ "compress.error.pngQuality": "Der PNG-Encoder konnte den konfigurierten Qualitätsbereich nicht erfüllen",
+ "section.quality": "Komprimierungsqualität",
+ "section.paths": "Pfade",
+ "section.automation": "Automatisierung",
+ "section.stats": "Statistiken & Cache",
+ "section.move": "Komprimierte Dateien verschieben",
+ "section.cacheBackups": "Cache-Backups",
+ "section.instructions": "Anweisungen",
+ "quality.png.name": "PNG-Qualität (min.-max.)",
+ "quality.png.desc": "Qualitätsbereich für die PNG-Komprimierung (standardmäßig 65–80)",
+ "quality.jpeg.name": "JPEG-Qualität",
+ "quality.jpeg.desc": "JPEG-Komprimierungsqualität (1-95)",
+ "paths.allowedRoots.name": "Erlaubte Stammordner",
+ "paths.allowedRoots.desc": "Wählen Sie Ordner aus, in denen die Komprimierung zulässig ist (leer = alle Pfade).",
+ "paths.allowedRoots.empty": "Nicht ausgewählt (überall komprimieren)",
+ "paths.allowedRoots.pill.remove": "Klicken Sie zum Entfernen",
+ "paths.allowedRoots.modal.placeholder": "Wählen Sie einen Ordner aus...",
+ "paths.allowedRoots.cannotAddRoot": "Lassen Sie die Liste leer, um alle Ordner zuzulassen",
+ "paths.allowedRoots.clear": "Liste leeren",
+ "paths.output.name": "Ausgabeordner",
+ "paths.output.desc": "Ordnername zum Speichern komprimierter Dateien",
+ "auto.newFiles.name": "Neue Dateien automatisch komprimieren",
+ "auto.newFiles.desc": "Neue Bilder beim Hinzufügen zum Vault automatisch komprimieren",
+ "auto.bg.name": "Automatische Hintergrundkomprimierung",
+ "auto.bg.desc": "Komprimieren Sie automatisch im Hintergrund, wenn Sie inaktiv sind",
+ "auto.bg.threshold.name": "Schwellenwert für die Hintergrundkomprimierung",
+ "auto.bg.threshold.desc": "Anzahl unkomprimierter Bilder, ab der die Hintergrundkomprimierung automatisch startet",
+ "auto.bg.inactivity.name": "Inaktivitätsschwellenwert (Minuten)",
+ "auto.bg.inactivity.desc": "Minuten ohne Benutzeraktivität vor dem Start der Hintergrundkomprimierung",
+ "guard.disabled": "{id} wurde während der Komprimierung vorübergehend deaktiviert",
+ "guard.restored": "{id} wurde nach der Komprimierung wieder aktiviert",
+ "auto.retention.toggle.name": "Image-Backups automatisch löschen",
+ "auto.retention.toggle.desc": "Image-Backups nach Aufbewahrungszeitraum löschen",
+ "auto.retention.days.name": "Aufbewahrungsdauer der Backups (Tage)",
+ "auto.retention.days.desc": "Löschen Sie Backups, die älter als die angegebene Anzahl von Tagen sind",
+ "auto.move.toggle.name": "Aktivieren Sie die automatische Verschiebung komprimierter Dateien",
+ "auto.move.toggle.desc": "Komprimierte Dateien automatisch verschieben, wenn der Schwellenwert erreicht ist",
+ "auto.move.threshold.name": "Schwellenwert für automatisches Verschieben (Dateien)",
+ "auto.move.threshold.desc": "Wie viele Dateien in 'Compressed' sollen die automatische Verschiebung auslösen?",
+ "auto.queueFull": "Die Warteschlange für die automatische Komprimierung ist voll ({max}); neue Dateien wurden übersprungen",
+ "background.starting": "Hintergrundkomprimierung gestartet. Anzahl der Bilder: {count}",
+ "background.finished": "Hintergrundkomprimierung abgeschlossen. Komprimierte Bilder: {count}",
+ "stats.uncompressed.name": "Unkomprimierte Bilder",
+ "stats.uncompressed.ready": "Bereit zum Komprimieren",
+ "stats.cache.name": "Cache-Einträge",
+ "stats.cache.entries": "Einträge",
+ "stats.cache.size": "Größe",
+ "cache.corruptSaved": "Cache konnte nicht gelesen werden. Eine Wiederherstellungskopie wurde gespeichert:",
+ "move.title": "Komprimierte Dateien verschieben",
+ "move.ready": "Bereit zum Verschieben",
+ "move.button": "Verschieben",
+ "move.noCompressedFolder": "Ordner Compressed nicht gefunden",
+ "move.noneToMove": "Keine komprimierten Dateien zum Verschieben",
+ "move.noneWithOriginals": "Keine komprimierten Dateien mit Originalen in zulässigen Stammverzeichnissen",
+ "move.backupCreated": "Sicherung der Originaldateien und der komprimierten Dateien erstellt",
+ "move.backup.createdCount": "Gesicherte Original-/komprimierte Dateipaare: {count}",
+ "move.skip.ambiguousOriginal": "Mehrdeutiger Dateiname der Originaldatei",
+ "move.skip.originalMissingBeforeBackup": "Original fehlt vor der Sicherung",
+ "move.skip.compressedMissingBeforeBackup": "Komprimierte Datei fehlt vor der Sicherung",
+ "move.skip.originalModifiedDuringBackup": "Original während der Sicherung geändert",
+ "move.skip.compressedModifiedDuringBackup": "Komprimierte Datei wurde während der Sicherung geändert",
+ "move.skip.originalContentChangedDuringBackup": "Inhalt der Originaldatei wurde während der Sicherung geändert",
+ "move.skip.compressedContentChangedDuringBackup": "Inhalt der komprimierten Datei wurde während der Sicherung geändert",
+ "move.skip.contentChangedDuringCopy": "Der Inhalt wurde beim Kopieren des Backups geändert",
+ "move.skip.originalNotFoundAtMoveTime": "Original beim Verschieben nicht gefunden",
+ "move.skip.selfMove": "Der Pfad der komprimierten Datei entspricht dem Pfad der Originaldatei",
+ "move.skip.externalModification": "Beim Verschieben wurde eine externe Änderung erkannt",
+ "move.skip.invalidBackupTask": "Ungültige Sicherungsaufgabe",
+ "move.skip.noOriginalCandidate": "Kandidat für die Originaldatei nicht gefunden",
+ "move.skip.unloading": "Erweiterung wird entladen",
+ "move.warning.externalModification": "Externe Änderung beim Verschieben erkannt: {name}",
+ "backups.imagesFolder.name": "Image-Backup-Ordner",
+ "backups.imagesFolder.desc": "Öffnen Sie den Ordner mit Backups der Original-/komprimierten Bilder",
+ "backups.imagesFolder.openButton": "Öffnen Sie den Image-Backup-Ordner",
+ "backups.imagesFolder.openError": "Der Image-Backup-Ordner konnte nicht geöffnet werden",
+ "backups.imagesFolder.clearName": "Löschen Sie Image-Backups",
+ "backups.imagesFolder.clearDesc": "Löschen Sie Sicherungen verschobener Original-/komprimierter Bilder",
+ "backups.imagesFolder.clearButton": "Löschen Sie Image-Backups",
+ "backups.imagesFolder.notFound": "Backup-Ordner nicht gefunden",
+ "backups.imagesFolder.noneToDelete": "Keine Backups zum Löschen",
+ "backups.imagesFolder.deletedCount": "Gelöschte Bildsicherungssätze: {count}",
+ "backups.imagesFolder.clearError": "Fehler beim Löschen von Backups",
+ "backups.cache.title": "Cache-Backups",
+ "backups.cache.folder.name": "Cache-Backup-Ordner",
+ "backups.cache.folder.desc": "Öffnen Sie den Ordner mit den Cache-Sicherungsdateien",
+ "backups.cache.folder.openButton": "Öffnen Sie den Cache-Backup-Ordner",
+ "backups.pathLabel": "Pfad",
+ "backups.foundLabel": "Backups gefunden",
+ "backups.cache.none": "Keine Cache-Backups verfügbar",
+ "backups.cache.restore": "Cache aus Backup wiederherstellen",
+ "backups.cache.available": "Verfügbare Backups:",
+ "backups.cache.selectPlaceholder": "-- Backup auswählen --",
+ "common.add": "Hinzufügen",
+ "common.refresh": "Aktualisieren",
+ "common.refreshing": "Wird aktualisiert...",
+ "common.processing": "Verarbeitung...",
+ "common.clearCache": "Cache leeren",
+ "common.refreshCache": "Cache aktualisieren",
+ "common.clear": "Leeren",
+ "common.clearing": "Wird geleert...",
+ "common.cancel": "Abbrechen",
+ "common.close": "Schließen",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Verwendung:",
+ "instructions.notesTitle": "Hinweise:",
+ "instructions.notes.saved": "Komprimierte Dateien werden gespeichert unter",
+ "instructions.notes.originalUnchanged": "Originaldateien werden nicht verändert",
+ "instructions.notes.recompressionSkipped": "Der Cache verhindert eine erneute Komprimierung",
+ "progress.start": "Wird gestartet...",
+ "progress.processing": "Verarbeitung",
+ "progress.skippedAlready": "Übersprungen (bereits komprimiert)",
+ "progress.skipped": "Übersprungen",
+ "progress.compressed": "Komprimiert",
+ "progress.error": "Fehler",
+ "progress.cancelling": "Wird abgebrochen...",
+ "progress.cancelled": "Abgebrochen",
+ "status.loading": "Wird geladen...",
+ "status.indexing": "Bilder werden indiziert...",
+ "progress.completed": "Abgeschlossen",
+ "folders.noneInVault": "Im Vault wurden keine Ordner gefunden",
+ "folderSelect.title": "Wählen Sie einen Ordner für die Bildkomprimierung aus",
+ "folderSelect.selectLabel": "Ordner",
+ "folderSelect.root": "Stammordner",
+ "folderSelect.select": "Auswählen",
+ "folderSelect.cancel": "Abbrechen",
+ "instructions.action.rightClick": "Klicken Sie mit der rechten Maustaste auf ein Bild →",
+ "instructions.action.commandPalette": "Befehlspalette →",
+ "savings.original": "Original",
+ "savings.current": "Aktuell",
+ "savings.saved": "Eingespart",
+ "tooltip.savings.header": "Details zur Platzersparnis",
+ "tooltip.savings.original": "Originalgröße:",
+ "tooltip.savings.current": "Aktuelle Größe:",
+ "tooltip.savings.saved": "Platz gespart:",
+ "tooltip.savings.filesProcessed": "Verarbeitete Dateien:",
+ "tooltip.savings.estimated": "Geschätzte Dateien"
+}
diff --git a/src-ts/locales/en.json b/src-ts/locales/en.json
new file mode 100644
index 0000000..60ae17f
--- /dev/null
+++ b/src-ts/locales/en.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Settings",
+ "settings.loadFailed": "Settings could not be loaded; defaults were used",
+ "init.failed": "Plugin initialization failed. Reload the plugin after fixing the reported error.",
+ "migration.partialFailure": "Legacy plugin data migration completed with errors. Check the developer console.",
+ "i18n.externalLoadFailed": "External language file could not be loaded",
+ "warning.wasmInitFailed": "WebAssembly modules failed to initialize. Please reload the plugin or report a bug.",
+ "command.compressInNote": "Compress all images in current note",
+ "command.compressInFolder": "Compress all images in a folder",
+ "command.compressAll": "Compress all images in vault",
+ "command.moveCompressed": "Move compressed files",
+ "context.compressImage": "Compress image",
+ "context.compressImagesInFolder": "Compress images in folder",
+ "notice.cacheUpdated": "Cache updated",
+ "notice.cacheCleared": "Cache cleared",
+ "notice.compressionDeferredDueToMove": "Compression is deferred while a move operation is in progress",
+ "notice.operationFailed": "Operation failed. Check the developer console.",
+ "validation.pathNotAllowed": "Path is not allowed in settings",
+ "validation.outputFolder": "File is inside the output folder",
+ "validation.alreadyCompressed": "File is already compressed",
+ "validation.tooSmall": "File is too small",
+ "validation.bytes": "bytes",
+ "compress.error.fileAccess": "Unable to access file",
+ "compress.error.unknown": "unknown error",
+ "compress.error.tooLarge": "Image is too large to compress safely",
+ "compress.error.notSmaller": "Compressed file is not smaller than original",
+ "compress.error.unsupportedFormat": "Unsupported file format",
+ "compress.error.copyCompressed": "Could not copy compressed file",
+ "compress.error.copyCompressedJpeg": "Could not copy compressed JPEG file",
+ "compress.error.pngQuality": "PNG encoder could not meet the configured quality range",
+ "section.quality": "Compression quality",
+ "section.paths": "Paths",
+ "section.automation": "Automation",
+ "section.stats": "Statistics & cache",
+ "section.move": "Move compressed files",
+ "section.cacheBackups": "Cache backups",
+ "section.instructions": "Instructions",
+ "quality.png.name": "PNG quality (min-max)",
+ "quality.png.desc": "Quality range for PNG compression (65-80 by default)",
+ "quality.jpeg.name": "JPEG quality",
+ "quality.jpeg.desc": "JPEG compression quality (1-95)",
+ "paths.allowedRoots.name": "Allowed root folders",
+ "paths.allowedRoots.desc": "Select folders where compression is allowed (leave empty to allow all folders)",
+ "paths.allowedRoots.empty": "Not selected (compress everywhere)",
+ "paths.allowedRoots.pill.remove": "Click to remove",
+ "paths.allowedRoots.modal.placeholder": "Select a folder...",
+ "paths.allowedRoots.cannotAddRoot": "Leave the list empty to allow all folders",
+ "paths.allowedRoots.clear": "Clear list",
+ "paths.output.name": "Output folder",
+ "paths.output.desc": "Folder name to store compressed files",
+ "auto.newFiles.name": "Auto-compress new files",
+ "auto.newFiles.desc": "Automatically compress new images when added to vault",
+ "auto.bg.name": "Automatic background compression",
+ "auto.bg.desc": "Automatically compress in the background when you are inactive",
+ "auto.bg.threshold.name": "Background compression threshold",
+ "auto.bg.threshold.desc": "Number of uncompressed images required to start background compression",
+ "auto.bg.inactivity.name": "Inactivity threshold (minutes)",
+ "auto.bg.inactivity.desc": "Minutes of user inactivity before background compression starts",
+ "guard.disabled": "{id} was temporarily disabled during compression",
+ "guard.restored": "{id} was re-enabled after compression",
+ "auto.retention.toggle.name": "Auto-delete image backups",
+ "auto.retention.toggle.desc": "Delete image backups by retention period",
+ "auto.retention.days.name": "Backup retention (days)",
+ "auto.retention.days.desc": "Delete backups older than the specified number of days",
+ "auto.move.toggle.name": "Enable auto-move of compressed files",
+ "auto.move.toggle.desc": "Automatically move compressed files when threshold is reached",
+ "auto.move.threshold.name": "Auto-move threshold (files)",
+ "auto.move.threshold.desc": "How many files in 'Compressed' to trigger auto-move",
+ "auto.queueFull": "Auto-compress queue is full ({max}); new files were skipped",
+ "background.starting": "Background compression started. Images: {count}",
+ "background.finished": "Background compression completed. Compressed images: {count}",
+ "stats.uncompressed.name": "Uncompressed images",
+ "stats.uncompressed.ready": "Ready to compress",
+ "stats.cache.name": "Cache entries",
+ "stats.cache.entries": "Entries",
+ "stats.cache.size": "size",
+ "cache.corruptSaved": "Cache could not be read. A recovery copy was saved:",
+ "move.title": "Move compressed files",
+ "move.ready": "Ready to move",
+ "move.button": "Move",
+ "move.noCompressedFolder": "Compressed folder not found",
+ "move.noneToMove": "No compressed files to move",
+ "move.noneWithOriginals": "No compressed files whose originals are in allowed root folders",
+ "move.backupCreated": "Created backup of original and compressed files",
+ "move.backup.createdCount": "Backed up original/compressed file pairs: {count}",
+ "move.skip.ambiguousOriginal": "Ambiguous original filename",
+ "move.skip.originalMissingBeforeBackup": "Original missing before backup",
+ "move.skip.compressedMissingBeforeBackup": "Compressed file missing before backup",
+ "move.skip.originalModifiedDuringBackup": "Original changed during backup",
+ "move.skip.compressedModifiedDuringBackup": "Compressed file changed during backup",
+ "move.skip.originalContentChangedDuringBackup": "Original file content changed during backup",
+ "move.skip.compressedContentChangedDuringBackup": "Compressed file content changed during backup",
+ "move.skip.contentChangedDuringCopy": "Content changed while backup was being copied",
+ "move.skip.originalNotFoundAtMoveTime": "Original not found at move time",
+ "move.skip.selfMove": "Compressed file path matches the original file path",
+ "move.skip.externalModification": "External modification detected during move",
+ "move.skip.invalidBackupTask": "Invalid backup task",
+ "move.skip.noOriginalCandidate": "Original candidate not found",
+ "move.skip.unloading": "Plugin is unloading",
+ "move.warning.externalModification": "External modification detected during move: {name}",
+ "backups.imagesFolder.name": "Image backups folder",
+ "backups.imagesFolder.desc": "Open folder with backups of original/compressed images",
+ "backups.imagesFolder.openButton": "Open image backups folder",
+ "backups.imagesFolder.openError": "Failed to open image backups folder",
+ "backups.imagesFolder.clearName": "Clear image backups",
+ "backups.imagesFolder.clearDesc": "Delete backups of moved original/compressed images",
+ "backups.imagesFolder.clearButton": "Clear image backups",
+ "backups.imagesFolder.notFound": "Backups folder not found",
+ "backups.imagesFolder.noneToDelete": "No backups to delete",
+ "backups.imagesFolder.deletedCount": "Deleted image backup sets: {count}",
+ "backups.imagesFolder.clearError": "Error while clearing backups",
+ "backups.cache.title": "Cache backups",
+ "backups.cache.folder.name": "Cache backups folder",
+ "backups.cache.folder.desc": "Open folder with cache backup files",
+ "backups.cache.folder.openButton": "Open cache backups folder",
+ "backups.pathLabel": "Path",
+ "backups.foundLabel": "Backups found",
+ "backups.cache.none": "No cache backups available",
+ "backups.cache.restore": "Restore cache from backup",
+ "backups.cache.available": "Available backups:",
+ "backups.cache.selectPlaceholder": "-- Select backup --",
+ "common.add": "Add",
+ "common.refresh": "Refresh",
+ "common.refreshing": "Refreshing...",
+ "common.processing": "Processing...",
+ "common.clearCache": "Clear cache",
+ "common.refreshCache": "Refresh cache",
+ "common.clear": "Clear",
+ "common.clearing": "Clearing...",
+ "common.cancel": "Cancel",
+ "common.close": "Close",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Usage:",
+ "instructions.notesTitle": "Notes:",
+ "instructions.notes.saved": "Compressed files are saved in",
+ "instructions.notes.originalUnchanged": "Original files are not modified",
+ "instructions.notes.recompressionSkipped": "The cache prevents recompression",
+ "progress.start": "Starting...",
+ "progress.processing": "Processing",
+ "progress.skippedAlready": "Skipped (already compressed)",
+ "progress.skipped": "Skipped",
+ "progress.compressed": "Compressed",
+ "progress.error": "Error",
+ "progress.cancelling": "Cancelling...",
+ "progress.cancelled": "Cancelled",
+ "status.loading": "Loading...",
+ "status.indexing": "Indexing images...",
+ "progress.completed": "Completed",
+ "folders.noneInVault": "No folders found in vault",
+ "folderSelect.title": "Select a folder for image compression",
+ "folderSelect.selectLabel": "Folder",
+ "folderSelect.root": "Root folder",
+ "folderSelect.select": "Select",
+ "folderSelect.cancel": "Cancel",
+ "instructions.action.rightClick": "Right-click an image →",
+ "instructions.action.commandPalette": "Command palette →",
+ "savings.original": "Original",
+ "savings.current": "Current",
+ "savings.saved": "Saved",
+ "tooltip.savings.header": "Space savings details",
+ "tooltip.savings.original": "Original size:",
+ "tooltip.savings.current": "Current size:",
+ "tooltip.savings.saved": "Space saved:",
+ "tooltip.savings.filesProcessed": "Files processed:",
+ "tooltip.savings.estimated": "Estimated files"
+}
diff --git a/src-ts/locales/es.json b/src-ts/locales/es.json
new file mode 100644
index 0000000..ea0f2e0
--- /dev/null
+++ b/src-ts/locales/es.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Preferencias",
+ "settings.loadFailed": "No se pudieron cargar las preferencias; se utilizaron los valores predeterminados",
+ "init.failed": "La inicialización del complemento falló. Vuelva a cargar el complemento después de corregir el error informado.",
+ "migration.partialFailure": "La migración de datos antiguos del complemento se completó con errores. Revisa la consola para desarrolladores.",
+ "i18n.externalLoadFailed": "No se pudo cargar el archivo de idioma externo",
+ "warning.wasmInitFailed": "Los módulos WebAssembly no pudieron inicializarse. Vuelva a cargar el complemento o informe un error.",
+ "command.compressInNote": "Comprimir todas las imágenes de la nota",
+ "command.compressInFolder": "Comprimir todas las imágenes en la carpeta",
+ "command.compressAll": "Comprimir todas las imágenes en la bóveda",
+ "command.moveCompressed": "Mover archivos comprimidos",
+ "context.compressImage": "Comprimir imagen",
+ "context.compressImagesInFolder": "Comprimir imágenes de la carpeta",
+ "notice.cacheUpdated": "Caché actualizado",
+ "notice.cacheCleared": "Caché borrado",
+ "notice.compressionDeferredDueToMove": "La compresión se aplaza mientras hay una operación de movimiento en curso",
+ "notice.operationFailed": "La operación falló. Consulte la consola del desarrollador.",
+ "validation.pathNotAllowed": "La ruta no está permitida en la configuración",
+ "validation.outputFolder": "El archivo está dentro de la carpeta de salida.",
+ "validation.alreadyCompressed": "El archivo ya está comprimido",
+ "validation.tooSmall": "El archivo es demasiado pequeño",
+ "validation.bytes": "bytes",
+ "compress.error.fileAccess": "No se puede acceder al archivo",
+ "compress.error.unknown": "error desconocido",
+ "compress.error.tooLarge": "La imagen es demasiado grande para comprimirla de forma segura",
+ "compress.error.notSmaller": "El archivo comprimido no es más pequeño que el original.",
+ "compress.error.unsupportedFormat": "Formato de archivo no compatible",
+ "compress.error.copyCompressed": "No se pudo copiar el archivo comprimido",
+ "compress.error.copyCompressedJpeg": "No se pudo copiar el archivo JPEG comprimido",
+ "compress.error.pngQuality": "El codificador PNG no pudo cumplir con el rango de calidad configurado",
+ "section.quality": "Calidad de compresión",
+ "section.paths": "Rutas",
+ "section.automation": "Automatización",
+ "section.stats": "Estadísticas y caché",
+ "section.move": "Mover archivos comprimidos",
+ "section.cacheBackups": "Copias de seguridad de la caché",
+ "section.instructions": "Instrucciones",
+ "quality.png.name": "Calidad PNG (mín.-máx.)",
+ "quality.png.desc": "Rango de calidad para la compresión PNG (65-80 por defecto)",
+ "quality.jpeg.name": "Calidad JPEG",
+ "quality.jpeg.desc": "Calidad de compresión JPEG (1-95)",
+ "paths.allowedRoots.name": "Carpetas raíz permitidas",
+ "paths.allowedRoots.desc": "Solo se procesan las carpetas seleccionadas y sus subcarpetas (lista vacía = todas las rutas)",
+ "paths.allowedRoots.empty": "No seleccionado (comprimir en todas partes)",
+ "paths.allowedRoots.pill.remove": "Haga clic para eliminar",
+ "paths.allowedRoots.modal.placeholder": "Seleccione una carpeta...",
+ "paths.allowedRoots.cannotAddRoot": "Deje la lista vacía para permitir todas las carpetas.",
+ "paths.allowedRoots.clear": "Borrar lista",
+ "paths.output.name": "Carpeta de salida",
+ "paths.output.desc": "Nombre de la carpeta para almacenar archivos comprimidos",
+ "auto.newFiles.name": "Comprimir automáticamente archivos nuevos",
+ "auto.newFiles.desc": "Comprime automáticamente nuevas imágenes cuando se agregan a la bóveda",
+ "auto.bg.name": "Compresión automática en segundo plano",
+ "auto.bg.desc": "Comprime automáticamente en segundo plano cuando estás inactivo",
+ "auto.bg.threshold.name": "Umbral de compresión en segundo plano",
+ "auto.bg.threshold.desc": "Número de imágenes sin comprimir necesario para iniciar automáticamente la compresión en segundo plano",
+ "auto.bg.inactivity.name": "Umbral de inactividad (minutos)",
+ "auto.bg.inactivity.desc": "Minutos sin actividad del usuario antes de que pueda comenzar la compresión en segundo plano",
+ "guard.disabled": "{id} se deshabilitó temporalmente durante la compresión",
+ "guard.restored": "{id} se volvió a habilitar después de la compresión",
+ "auto.retention.toggle.name": "Eliminar automáticamente copias de seguridad de imágenes",
+ "auto.retention.toggle.desc": "Eliminar copias de seguridad de imágenes por período de retención",
+ "auto.retention.days.name": "Retención de copias de seguridad (días)",
+ "auto.retention.days.desc": "Eliminar copias de seguridad con una antigüedad superior al número de días indicado",
+ "auto.move.toggle.name": "Habilitar el movimiento automático de archivos comprimidos",
+ "auto.move.toggle.desc": "Mueve automáticamente archivos comprimidos cuando se alcanza el umbral",
+ "auto.move.threshold.name": "Umbral de movimiento automático (archivos)",
+ "auto.move.threshold.desc": "Número de archivos en 'Compressed' necesario para activar el movimiento automático",
+ "auto.queueFull": "La cola de compresión automática está llena ({max}); se omitieron nuevos archivos",
+ "background.starting": "Compresión en segundo plano iniciada. Imágenes: {count}",
+ "background.finished": "Compresión en segundo plano finalizada. Imágenes comprimidas: {count}",
+ "stats.uncompressed.name": "Imágenes sin comprimir",
+ "stats.uncompressed.ready": "Imágenes listas para comprimir",
+ "stats.cache.name": "Entradas de caché",
+ "stats.cache.entries": "Entradas",
+ "stats.cache.size": "tamaño",
+ "cache.corruptSaved": "No se pudo leer la caché. Se guardó una copia de recuperación:",
+ "move.title": "Mover archivos comprimidos",
+ "move.ready": "Archivos listos para mover",
+ "move.button": "Mover",
+ "move.noCompressedFolder": "Carpeta Compressed no encontrada",
+ "move.noneToMove": "No hay archivos comprimidos para mover",
+ "move.noneWithOriginals": "No hay archivos comprimidos cuyos originales estén en las raíces permitidas",
+ "move.backupCreated": "Se creó una copia de seguridad de los archivos originales y comprimidos",
+ "move.backup.createdCount": "Pares de archivos originales/comprimidos incluidos en la copia de seguridad: {count}",
+ "move.skip.ambiguousOriginal": "Nombre de archivo original ambiguo",
+ "move.skip.originalMissingBeforeBackup": "Falta el original antes de la copia de seguridad",
+ "move.skip.compressedMissingBeforeBackup": "Archivo comprimido no encontrado antes de la copia de seguridad",
+ "move.skip.originalModifiedDuringBackup": "Original cambiado durante la copia de seguridad",
+ "move.skip.compressedModifiedDuringBackup": "El archivo comprimido cambió durante la copia de seguridad",
+ "move.skip.originalContentChangedDuringBackup": "El contenido del archivo original cambió durante la copia de seguridad",
+ "move.skip.compressedContentChangedDuringBackup": "El contenido del archivo comprimido cambió durante la copia de seguridad",
+ "move.skip.contentChangedDuringCopy": "El contenido cambió mientras se copiaba la copia de seguridad",
+ "move.skip.originalNotFoundAtMoveTime": "Original no encontrado en el momento del desplazamiento",
+ "move.skip.selfMove": "La ruta del archivo comprimido coincide con la ruta del archivo original",
+ "move.skip.externalModification": "Modificación externa detectada durante el movimiento.",
+ "move.skip.invalidBackupTask": "Tarea de copia de seguridad no válida",
+ "move.skip.noOriginalCandidate": "No se encontró ningún archivo original correspondiente",
+ "move.skip.unloading": "El complemento se está desactivando",
+ "move.warning.externalModification": "Modificación externa detectada durante el movimiento: {name}",
+ "backups.imagesFolder.name": "Carpeta de copias de seguridad de imágenes",
+ "backups.imagesFolder.desc": "Abrir carpeta con copias de seguridad de imágenes originales/comprimidas",
+ "backups.imagesFolder.openButton": "Abrir carpeta de copias de seguridad de imágenes",
+ "backups.imagesFolder.openError": "No se pudo abrir la carpeta de copias de seguridad de imágenes",
+ "backups.imagesFolder.clearName": "Borrar copias de seguridad de imágenes",
+ "backups.imagesFolder.clearDesc": "Eliminar copias de seguridad de imágenes originales/comprimidas movidas",
+ "backups.imagesFolder.clearButton": "Borrar copias de seguridad de imágenes",
+ "backups.imagesFolder.notFound": "Carpeta de copias de seguridad no encontrada",
+ "backups.imagesFolder.noneToDelete": "No hay copias de seguridad para eliminar",
+ "backups.imagesFolder.deletedCount": "Conjuntos de copias de seguridad de imágenes eliminados: {count}",
+ "backups.imagesFolder.clearError": "Error al borrar copias de seguridad",
+ "backups.cache.title": "Copias de seguridad de la caché",
+ "backups.cache.folder.name": "Carpeta de copias de seguridad de la caché",
+ "backups.cache.folder.desc": "Carpeta que contiene los archivos de copia de seguridad de la caché",
+ "backups.cache.folder.openButton": "Abrir la carpeta de copias de seguridad de la caché",
+ "backups.pathLabel": "Ruta",
+ "backups.foundLabel": "Copias de seguridad encontradas",
+ "backups.cache.none": "No hay copias de seguridad de la caché disponibles",
+ "backups.cache.restore": "Restaurar caché desde la copia de seguridad",
+ "backups.cache.available": "Copias de seguridad disponibles:",
+ "backups.cache.selectPlaceholder": "-- Seleccionar copia de seguridad --",
+ "common.add": "Añadir",
+ "common.refresh": "Actualizar",
+ "common.refreshing": "Actualizando...",
+ "common.processing": "Procesando...",
+ "common.clearCache": "Borrar caché",
+ "common.refreshCache": "Actualizar caché",
+ "common.clear": "Borrar",
+ "common.clearing": "Borrando...",
+ "common.cancel": "Cancelar",
+ "common.close": "Cerrar",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Uso:",
+ "instructions.notesTitle": "Notas:",
+ "instructions.notes.saved": "Los archivos comprimidos se guardan en",
+ "instructions.notes.originalUnchanged": "Los archivos originales no se modifican.",
+ "instructions.notes.recompressionSkipped": "La recompresión se omite gracias al caché",
+ "progress.start": "Empezando...",
+ "progress.processing": "Procesamiento",
+ "progress.skippedAlready": "Omitido (ya comprimido)",
+ "progress.skipped": "Omitido",
+ "progress.compressed": "Comprimido",
+ "progress.error": "Error",
+ "progress.cancelling": "Cancelando...",
+ "progress.cancelled": "Cancelado",
+ "status.loading": "Cargando...",
+ "status.indexing": "Indexando imágenes...",
+ "progress.completed": "Completado",
+ "folders.noneInVault": "No se encontraron carpetas en la bóveda",
+ "folderSelect.title": "Seleccione una carpeta para comprimir imágenes",
+ "folderSelect.selectLabel": "Carpeta",
+ "folderSelect.root": "Carpeta raíz",
+ "folderSelect.select": "Seleccionar",
+ "folderSelect.cancel": "Cancelar",
+ "instructions.action.rightClick": "Haga clic derecho en una imagen →",
+ "instructions.action.commandPalette": "Paleta de comandos →",
+ "savings.original": "Original",
+ "savings.current": "Actual",
+ "savings.saved": "Ahorrado",
+ "tooltip.savings.header": "Detalles de ahorro de espacio",
+ "tooltip.savings.original": "Tamaño original:",
+ "tooltip.savings.current": "Tamaño actual:",
+ "tooltip.savings.saved": "Espacio ahorrado:",
+ "tooltip.savings.filesProcessed": "Archivos procesados:",
+ "tooltip.savings.estimated": "Archivos estimados"
+}
diff --git a/src-ts/locales/fa.json b/src-ts/locales/fa.json
new file mode 100644
index 0000000..ef8d322
--- /dev/null
+++ b/src-ts/locales/fa.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "تنظیمات",
+ "settings.loadFailed": "تنظیمات بارگیری نشد؛ از مقادیر پیشفرض استفاده شد",
+ "init.failed": "راهاندازی افزونه ناموفق بود. پس از رفع خطای گزارششده، افزونه را دوباره بارگیری کنید.",
+ "migration.partialFailure": "مهاجرت دادههای قدیمی افزونه با خطا به پایان رسید. کنسول توسعهدهنده را بررسی کنید.",
+ "i18n.externalLoadFailed": "فایل زبان خارجی بارگیری نشد",
+ "warning.wasmInitFailed": "ماژول های WebAssembly اولیه سازی نشدند. لطفاً افزونه را دوباره بارگیری کنید یا یک اشکال را گزارش کنید.",
+ "command.compressInNote": "فشرده سازی تمام تصاویر در یادداشت",
+ "command.compressInFolder": "تمام تصاویر موجود در پوشه را فشرده کنید",
+ "command.compressAll": "همهٔ تصاویر خزانه را فشرده کنید",
+ "command.moveCompressed": "انتقال فایل های فشرده",
+ "context.compressImage": "فشرده سازی تصویر",
+ "context.compressImagesInFolder": "فشرده سازی تصاویر در پوشه",
+ "notice.cacheUpdated": "کش به روز شد",
+ "notice.cacheCleared": "حافظه پنهان پاک شد",
+ "notice.compressionDeferredDueToMove": "فشرده سازی زمانی که عملیات جابجایی در حال انجام است به تعویق می افتد",
+ "notice.operationFailed": "عملیات شکست خورد. کنسول توسعه دهنده را بررسی کنید.",
+ "validation.pathNotAllowed": "مسیر در تنظیمات مجاز نیست",
+ "validation.outputFolder": "فایل داخل پوشه خروجی است",
+ "validation.alreadyCompressed": "فایل قبلاً فشرده شده است",
+ "validation.tooSmall": "فایل خیلی کوچک است",
+ "validation.bytes": "بایت",
+ "compress.error.fileAccess": "دسترسی به فایل امکان پذیر نیست",
+ "compress.error.unknown": "خطای ناشناخته",
+ "compress.error.tooLarge": "تصویر برای فشرده سازی ایمن بیش از حد بزرگ است",
+ "compress.error.notSmaller": "فایل فشرده کوچکتر از اصلی نیست",
+ "compress.error.unsupportedFormat": "فرمت فایل پشتیبانی نشده",
+ "compress.error.copyCompressed": "فایل فشرده کپی نشد",
+ "compress.error.copyCompressedJpeg": "فایل JPEG فشرده کپی نشد",
+ "compress.error.pngQuality": "رمزگذار PNG نتوانست محدوده کیفیت پیکربندی شده را برآورده کند",
+ "section.quality": "کیفیت فشرده سازی",
+ "section.paths": "مسیرها",
+ "section.automation": "اتوماسیون",
+ "section.stats": "آمار و حافظه پنهان",
+ "section.move": "انتقال فایل های فشرده",
+ "section.cacheBackups": "نسخههای پشتیبان کش",
+ "section.instructions": "دستورالعمل ها",
+ "quality.png.name": "کیفیت PNG (حداقل–حداکثر)",
+ "quality.png.desc": "محدوده کیفیت برای فشرده سازی PNG (به طور پیش فرض 65-80)",
+ "quality.jpeg.name": "کیفیت JPEG",
+ "quality.jpeg.desc": "کیفیت فشرده سازی JPEG (1-95)",
+ "paths.allowedRoots.name": "ریشه های مجاز",
+ "paths.allowedRoots.desc": "پوشههایی را انتخاب کنید که در آن فشردهسازی مجاز است (خالی = همه مسیرها)",
+ "paths.allowedRoots.empty": "انتخاب نشده است (در همه جا فشرده شود)",
+ "paths.allowedRoots.pill.remove": "برای حذف کلیک کنید",
+ "paths.allowedRoots.modal.placeholder": "انتخاب پوشه...",
+ "paths.allowedRoots.cannotAddRoot": "لیست را خالی بگذارید تا همه پوشه ها مجاز باشند",
+ "paths.allowedRoots.clear": "پاک کردن لیست",
+ "paths.output.name": "پوشه خروجی",
+ "paths.output.desc": "نام پوشه برای ذخیره فایل های فشرده",
+ "auto.newFiles.name": "فشرده سازی خودکار فایل های جدید",
+ "auto.newFiles.desc": "هنگام افزودهشدن به خزانه، تصاویر را بهطور خودکار فشرده کنید",
+ "auto.bg.name": "فشردهسازی خودکار در پسزمینه",
+ "auto.bg.desc": "فشردهسازی خودکار تصاویر در پسزمینه هنگام عدم فعالیت",
+ "auto.bg.threshold.name": "آستانهٔ فشردهسازی در پسزمینه",
+ "auto.bg.threshold.desc": "تعداد تصاویر فشردهنشدهٔ لازم برای شروع خودکار",
+ "auto.bg.inactivity.name": "آستانه عدم فعالیت (دقیقه)",
+ "auto.bg.inactivity.desc": "تعداد دقیقههای بدون فعالیت کاربر پیش از شروع فشردهسازی در پسزمینه",
+ "guard.disabled": "{id} به طور موقت در طول فشرده سازی غیرفعال شد",
+ "guard.restored": "{id} پس از فشردهسازی دوباره فعال شد",
+ "auto.retention.toggle.name": "حذف خودکار نسخههای پشتیبان تصاویر",
+ "auto.retention.toggle.desc": "نسخههای پشتیبان تصاویر را بر اساس دورهٔ نگهداری حذف کنید",
+ "auto.retention.days.name": "نگهداری نسخه پشتیبان (روزها)",
+ "auto.retention.days.desc": "بک آپ های قدیمی تر از تعداد روزهای مشخص شده را حذف کنید",
+ "auto.move.toggle.name": "فعال کردن انتقال خودکار فایل های فشرده",
+ "auto.move.toggle.desc": "انتقال خودکار فایل های فشرده با رسیدن به آستانه",
+ "auto.move.threshold.name": "آستانهٔ انتقال خودکار (فایلها)",
+ "auto.move.threshold.desc": "تعداد فایلهای پوشهٔ 'Compressed' برای فعالکردن انتقال خودکار",
+ "auto.queueFull": "صف فشردهسازی خودکار پر است ({max})؛ فایلهای جدید نادیده گرفته شدند",
+ "background.starting": "فشردهسازی در پسزمینه شروع شد. تعداد تصاویر: {count}",
+ "background.finished": "فشردهسازی در پسزمینه پایان یافت. تصاویر فشردهشده: {count}",
+ "stats.uncompressed.name": "تصاویر غیر فشرده",
+ "stats.uncompressed.ready": "آمادهٔ فشردهسازی",
+ "stats.cache.name": "ورودی های کش",
+ "stats.cache.entries": "ورودیها",
+ "stats.cache.size": "اندازه",
+ "cache.corruptSaved": "حافظه پنهان خوانده نشد. یک کپی بازیابی ذخیره شد:",
+ "move.title": "انتقال فایل های فشرده",
+ "move.ready": "آمادهٔ انتقال",
+ "move.button": "انتقال",
+ "move.noCompressedFolder": "پوشه Compressed یافت نشد",
+ "move.noneToMove": "هیچ فایل فشرده ای برای جابجایی وجود ندارد",
+ "move.noneWithOriginals": "هیچ فایل فشردهای با فایل اصلی در پوشههای ریشهٔ مجاز وجود ندارد",
+ "move.backupCreated": "از فایلهای اصلی و فشرده نسخهٔ پشتیبان تهیه شد",
+ "move.backup.createdCount": "جفت فایلهای اصلی/فشردهٔ پشتیبانگیریشده: {count}",
+ "move.skip.ambiguousOriginal": "نام فایل اصلی مبهم",
+ "move.skip.originalMissingBeforeBackup": "قبل از پشتیبانگیری، اصل وجود ندارد",
+ "move.skip.compressedMissingBeforeBackup": "فایل فشرده پیش از پشتیبانگیری وجود ندارد",
+ "move.skip.originalModifiedDuringBackup": "اصل در حین تهیه نسخه پشتیبان تغییر کرد",
+ "move.skip.compressedModifiedDuringBackup": "فایل فشرده هنگام پشتیبانگیری تغییر کرد",
+ "move.skip.originalContentChangedDuringBackup": "محتوای فایل اصلی هنگام پشتیبانگیری تغییر کرد",
+ "move.skip.compressedContentChangedDuringBackup": "محتوای فایل فشرده هنگام پشتیبانگیری تغییر کرد",
+ "move.skip.contentChangedDuringCopy": "هنگام کپی کردن نسخه پشتیبان، محتوا تغییر کرد",
+ "move.skip.originalNotFoundAtMoveTime": "اصل در زمان جابجایی یافت نشد",
+ "move.skip.selfMove": "مسیر فایل فشرده با مسیر فایل اصلی مطابقت دارد",
+ "move.skip.externalModification": "هنگام انتقال، تغییر خارجی شناسایی شد",
+ "move.skip.invalidBackupTask": "وظیفه پشتیبان گیری نامعتبر است",
+ "move.skip.noOriginalCandidate": "فایل اصلی منطبق پیدا نشد",
+ "move.skip.unloading": "افزونه در حال غیرفعال شدن است",
+ "move.warning.externalModification": "فایل {name} خارج از افزونه تغییر کرده است؛ انتقال انجام نشد",
+ "backups.imagesFolder.name": "پوشهٔ نسخههای پشتیبان تصاویر",
+ "backups.imagesFolder.desc": "باز کردن پوشهٔ نسخههای پشتیبان تصاویر اصلی/فشرده",
+ "backups.imagesFolder.openButton": "باز کردن پوشهٔ نسخههای پشتیبان تصاویر",
+ "backups.imagesFolder.openError": "پوشه پشتیبانگیری تصویر باز نشد",
+ "backups.imagesFolder.clearName": "حذف نسخههای پشتیبان تصاویر",
+ "backups.imagesFolder.clearDesc": "حذف نسخههای پشتیبان تصاویر اصلی/فشردهٔ منتقلشده",
+ "backups.imagesFolder.clearButton": "حذف نسخههای پشتیبان تصاویر",
+ "backups.imagesFolder.notFound": "پوشه پشتیبانگیری پیدا نشد",
+ "backups.imagesFolder.noneToDelete": "بدون نسخه پشتیبان برای حذف",
+ "backups.imagesFolder.deletedCount": "مجموعههای حذفشدهٔ نسخههای پشتیبان تصاویر: {count}",
+ "backups.imagesFolder.clearError": "خطا هنگام پاک کردن نسخههای پشتیبان",
+ "backups.cache.title": "نسخههای پشتیبان کش",
+ "backups.cache.folder.name": "پوشهٔ نسخههای پشتیبان کش",
+ "backups.cache.folder.desc": "باز کردن پوشه با فایل های پشتیبان کش",
+ "backups.cache.folder.openButton": "پوشه پشتیبان گیری کش را باز کنید",
+ "backups.pathLabel": "مسیر",
+ "backups.foundLabel": "نسخه های پشتیبان پیدا شد",
+ "backups.cache.none": "هیچ پشتیبان کش موجود نیست",
+ "backups.cache.restore": "کش را از پشتیبان بازیابی کنید",
+ "backups.cache.available": "پشتیبان های موجود:",
+ "backups.cache.selectPlaceholder": "-- انتخاب پشتیبان --",
+ "common.add": "اضافه کنید",
+ "common.refresh": "تازه کردن",
+ "common.refreshing": "در حال تازهسازی...",
+ "common.processing": "در حال پردازش...",
+ "common.clearCache": "حافظه پنهان را پاک کنید",
+ "common.refreshCache": "حافظه پنهان را تازه کنید",
+ "common.clear": "پاک کردن",
+ "common.clearing": "در حال پاک کردن...",
+ "common.cancel": "لغو کنید",
+ "common.close": "بستن",
+ "units.kb": "کیلوبایت",
+ "instructions.usageTitle": "استفاده:",
+ "instructions.notesTitle": "یادداشت ها:",
+ "instructions.notes.saved": "فایلهای فشرده در این پوشه ذخیره میشوند:",
+ "instructions.notes.originalUnchanged": "فایل های اصلی اصلاح نمی شوند",
+ "instructions.notes.recompressionSkipped": "بهکمک کش، فشردهسازی مجدد انجام نمیشود",
+ "progress.start": "شروع...",
+ "progress.processing": "پردازش",
+ "progress.skippedAlready": "رد شده (از قبل فشرده شده است)",
+ "progress.skipped": "رد شد",
+ "progress.compressed": "فشرده شده",
+ "progress.error": "خطا",
+ "progress.cancelling": "در حال لغو...",
+ "progress.cancelled": "لغو شد",
+ "status.loading": "در حال بارگیری...",
+ "status.indexing": "نمایه سازی تصاویر...",
+ "progress.completed": "کامل شد",
+ "folders.noneInVault": "هیچ پوشهای در خزانه پیدا نشد",
+ "folderSelect.title": "پوشه ای را برای فشرده سازی تصویر انتخاب کنید",
+ "folderSelect.selectLabel": "پوشه",
+ "folderSelect.root": "پوشه ریشه",
+ "folderSelect.select": "انتخاب کنید",
+ "folderSelect.cancel": "لغو کنید",
+ "instructions.action.rightClick": "روی یک تصویر کلیک راست کنید →",
+ "instructions.action.commandPalette": "فرماندان →",
+ "savings.original": "اصل",
+ "savings.current": "فعلی",
+ "savings.saved": "صرفهجوییشده",
+ "tooltip.savings.header": "جزئیات صرفه جویی در فضا",
+ "tooltip.savings.original": "اندازه اصلی:",
+ "tooltip.savings.current": "اندازه فعلی:",
+ "tooltip.savings.saved": "فضای صرفهجوییشده:",
+ "tooltip.savings.filesProcessed": "فایل های پردازش شده:",
+ "tooltip.savings.estimated": "فایلهای تخمینی"
+}
diff --git a/src-ts/locales/fr.json b/src-ts/locales/fr.json
new file mode 100644
index 0000000..f16a789
--- /dev/null
+++ b/src-ts/locales/fr.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Paramètres",
+ "settings.loadFailed": "Les paramètres n'ont pas pu être chargés ; les valeurs par défaut ont été utilisées",
+ "init.failed": "L’initialisation du module a échoué. Rechargez le module après avoir corrigé l’erreur signalée.",
+ "migration.partialFailure": "La migration des anciennes données du module s’est terminée avec des erreurs. Consultez la console de développement.",
+ "i18n.externalLoadFailed": "Le fichier de langue externe n'a pas pu être chargé",
+ "warning.wasmInitFailed": "Les modules WebAssembly n’ont pas pu être initialisés. Rechargez le module ou signalez l’erreur.",
+ "command.compressInNote": "Compresser toutes les images dans la note",
+ "command.compressInFolder": "Compresser toutes les images du dossier",
+ "command.compressAll": "Compresser toutes les images du coffre",
+ "command.moveCompressed": "Déplacer les fichiers compressés",
+ "context.compressImage": "Compresser l'image",
+ "context.compressImagesInFolder": "Compresser les images dans un dossier",
+ "notice.cacheUpdated": "Cache mis à jour",
+ "notice.cacheCleared": "Cache vidé",
+ "notice.compressionDeferredDueToMove": "La compression est différée pendant qu'une opération de déplacement est en cours",
+ "notice.operationFailed": "L'opération a échoué. Vérifiez la console du développeur.",
+ "validation.pathNotAllowed": "Le chemin n'est pas autorisé dans les paramètres",
+ "validation.outputFolder": "Le fichier se trouve dans le dossier de sortie",
+ "validation.alreadyCompressed": "Le fichier est déjà compressé",
+ "validation.tooSmall": "Le fichier est trop petit",
+ "validation.bytes": "octets",
+ "compress.error.fileAccess": "Impossible d'accéder au fichier",
+ "compress.error.unknown": "erreur inconnue",
+ "compress.error.tooLarge": "L'image est trop grande pour être compressée en toute sécurité",
+ "compress.error.notSmaller": "Le fichier compressé n'est pas plus petit que l'original",
+ "compress.error.unsupportedFormat": "Format de fichier non pris en charge",
+ "compress.error.copyCompressed": "Impossible de copier le fichier compressé",
+ "compress.error.copyCompressedJpeg": "Impossible de copier le fichier JPEG compressé",
+ "compress.error.pngQuality": "L'encodeur PNG n'a pas pu respecter la plage de qualité configurée",
+ "section.quality": "Qualité de compression",
+ "section.paths": "Chemins",
+ "section.automation": "Automatisation",
+ "section.stats": "Statistiques et cache",
+ "section.move": "Déplacer les fichiers compressés",
+ "section.cacheBackups": "Sauvegardes du cache",
+ "section.instructions": "Instructions",
+ "quality.png.name": "Qualité PNG (min-max)",
+ "quality.png.desc": "Plage de qualité pour la compression PNG (65-80 par défaut)",
+ "quality.jpeg.name": "Qualité JPEG",
+ "quality.jpeg.desc": "Qualité de compression JPEG (1-95)",
+ "paths.allowedRoots.name": "Dossiers racines autorisés",
+ "paths.allowedRoots.desc": "Sélectionnez les dossiers où la compression est autorisée (vide = tous les chemins)",
+ "paths.allowedRoots.empty": "Non sélectionné (compresser partout)",
+ "paths.allowedRoots.pill.remove": "Cliquez pour supprimer",
+ "paths.allowedRoots.modal.placeholder": "Sélectionnez un dossier...",
+ "paths.allowedRoots.cannotAddRoot": "Laissez la liste vide pour autoriser tous les dossiers",
+ "paths.allowedRoots.clear": "Effacer la liste",
+ "paths.output.name": "Dossier de sortie",
+ "paths.output.desc": "Nom du dossier pour stocker les fichiers compressés",
+ "auto.newFiles.name": "Compresser automatiquement les nouveaux fichiers",
+ "auto.newFiles.desc": "Compresser automatiquement les nouvelles images lorsqu'elles sont ajoutées au coffre",
+ "auto.bg.name": "Compression automatique en arrière-plan",
+ "auto.bg.desc": "Compresser automatiquement en arrière-plan lorsque vous êtes inactif",
+ "auto.bg.threshold.name": "Seuil de compression en arrière-plan",
+ "auto.bg.threshold.desc": "Nombre d’images non compressées déclenchant le démarrage automatique",
+ "auto.bg.inactivity.name": "Seuil d'inactivité (minutes)",
+ "auto.bg.inactivity.desc": "Nombre de minutes sans activité utilisateur avant le démarrage de la compression en arrière-plan",
+ "guard.disabled": "{id} a été temporairement désactivé pendant la compression",
+ "guard.restored": "{id} a été réactivé après la compression",
+ "auto.retention.toggle.name": "Suppression automatique des sauvegardes d'images",
+ "auto.retention.toggle.desc": "Supprimer les sauvegardes d'images selon leur durée de conservation",
+ "auto.retention.days.name": "Conservation des sauvegardes (jours)",
+ "auto.retention.days.desc": "Supprimer les sauvegardes datant de plus du nombre de jours indiqué",
+ "auto.move.toggle.name": "Activer le déplacement automatique des fichiers compressés",
+ "auto.move.toggle.desc": "Déplacer automatiquement les fichiers compressés lorsque le seuil est atteint",
+ "auto.move.threshold.name": "Seuil de déplacement automatique (fichiers)",
+ "auto.move.threshold.desc": "Nombre de fichiers dans 'Compressed' nécessaire pour déclencher le déplacement automatique",
+ "auto.queueFull": "La file d'attente de compression automatique est pleine ({max}) ; les nouveaux fichiers ont été ignorés",
+ "background.starting": "Compression en arrière-plan démarrée. Images : {count}",
+ "background.finished": "Compression en arrière-plan terminée. Images compressées : {count}",
+ "stats.uncompressed.name": "Images non compressées",
+ "stats.uncompressed.ready": "Images prêtes à compresser",
+ "stats.cache.name": "Entrées du cache",
+ "stats.cache.entries": "Entrées",
+ "stats.cache.size": "taille",
+ "cache.corruptSaved": "Le cache n'a pas pu être lu. Une copie de récupération a été enregistrée :",
+ "move.title": "Déplacer les fichiers compressés",
+ "move.ready": "Fichiers prêts à déplacer",
+ "move.button": "Déplacer",
+ "move.noCompressedFolder": "Dossier Compressed introuvable",
+ "move.noneToMove": "Aucun fichier compressé à déplacer",
+ "move.noneWithOriginals": "Aucun fichier compressé dont l’original se trouve dans les dossiers racines autorisés",
+ "move.backupCreated": "Sauvegarde des fichiers originaux et compressés créée",
+ "move.backup.createdCount": "Paires de fichiers originaux/compressés sauvegardées : {count}",
+ "move.skip.ambiguousOriginal": "Nom de fichier original ambigu",
+ "move.skip.originalMissingBeforeBackup": "Original manquant avant la sauvegarde",
+ "move.skip.compressedMissingBeforeBackup": "Fichier compressé manquant avant la sauvegarde",
+ "move.skip.originalModifiedDuringBackup": "Original modifié lors de la sauvegarde",
+ "move.skip.compressedModifiedDuringBackup": "Fichier compressé modifié pendant la sauvegarde",
+ "move.skip.originalContentChangedDuringBackup": "Contenu du fichier original modifié pendant la sauvegarde",
+ "move.skip.compressedContentChangedDuringBackup": "Contenu du fichier compressé modifié pendant la sauvegarde",
+ "move.skip.contentChangedDuringCopy": "Le contenu a changé pendant la copie de la sauvegarde",
+ "move.skip.originalNotFoundAtMoveTime": "Original introuvable au moment du déplacement",
+ "move.skip.selfMove": "Le chemin du fichier compressé correspond au chemin du fichier d’origine",
+ "move.skip.externalModification": "Modification externe détectée lors du déplacement",
+ "move.skip.invalidBackupTask": "Tâche de sauvegarde invalide",
+ "move.skip.noOriginalCandidate": "Aucun fichier original candidat trouvé",
+ "move.skip.unloading": "Module en cours de déchargement",
+ "move.warning.externalModification": "Modification externe détectée lors du déplacement : {name}",
+ "backups.imagesFolder.name": "Dossier de sauvegardes d'images",
+ "backups.imagesFolder.desc": "Ouvrir le dossier avec les sauvegardes des images originales/compressées",
+ "backups.imagesFolder.openButton": "Ouvrir le dossier de sauvegardes d'images",
+ "backups.imagesFolder.openError": "Échec de l'ouverture du dossier de sauvegardes d'images",
+ "backups.imagesFolder.clearName": "Effacer les sauvegardes d'images",
+ "backups.imagesFolder.clearDesc": "Supprimer les sauvegardes des images originales/compressées déplacées",
+ "backups.imagesFolder.clearButton": "Effacer les sauvegardes d'images",
+ "backups.imagesFolder.notFound": "Dossier de sauvegarde introuvable",
+ "backups.imagesFolder.noneToDelete": "Aucune sauvegarde à supprimer",
+ "backups.imagesFolder.deletedCount": "Ensembles de sauvegardes d’images supprimés : {count}",
+ "backups.imagesFolder.clearError": "Erreur lors de la suppression des sauvegardes",
+ "backups.cache.title": "Sauvegardes du cache",
+ "backups.cache.folder.name": "Dossier de sauvegardes du cache",
+ "backups.cache.folder.desc": "Ouvrir le dossier avec les fichiers de sauvegarde du cache",
+ "backups.cache.folder.openButton": "Ouvrir le dossier des sauvegardes du cache",
+ "backups.pathLabel": "Chemin",
+ "backups.foundLabel": "Sauvegardes trouvées",
+ "backups.cache.none": "Aucune sauvegarde de cache disponible",
+ "backups.cache.restore": "Restaurer le cache à partir d'une sauvegarde",
+ "backups.cache.available": "Sauvegardes disponibles :",
+ "backups.cache.selectPlaceholder": "-- Sélectionnez la sauvegarde --",
+ "common.add": "Ajouter",
+ "common.refresh": "Actualiser",
+ "common.refreshing": "Actualisation...",
+ "common.processing": "Traitement...",
+ "common.clearCache": "Vider le cache",
+ "common.refreshCache": "Actualiser le cache",
+ "common.clear": "Effacer",
+ "common.clearing": "Effacement...",
+ "common.cancel": "Annuler",
+ "common.close": "Fermer",
+ "units.kb": "Ko",
+ "instructions.usageTitle": "Utilisation :",
+ "instructions.notesTitle": "Remarques :",
+ "instructions.notes.saved": "Les fichiers compressés sont enregistrés dans",
+ "instructions.notes.originalUnchanged": "Les fichiers originaux ne sont pas modifiés",
+ "instructions.notes.recompressionSkipped": "La recompression est ignorée grâce au cache",
+ "progress.start": "Démarrage...",
+ "progress.processing": "Traitement",
+ "progress.skippedAlready": "Ignoré (déjà compressé)",
+ "progress.skipped": "Ignoré",
+ "progress.compressed": "Compressé",
+ "progress.error": "Erreur",
+ "progress.cancelling": "Annulation...",
+ "progress.cancelled": "Annulé",
+ "status.loading": "Chargement...",
+ "status.indexing": "Indexation des images...",
+ "progress.completed": "Terminé",
+ "folders.noneInVault": "Aucun dossier trouvé dans le coffre",
+ "folderSelect.title": "Sélectionnez un dossier pour la compression d'image",
+ "folderSelect.selectLabel": "Dossier",
+ "folderSelect.root": "Dossier racine",
+ "folderSelect.select": "Sélectionner",
+ "folderSelect.cancel": "Annuler",
+ "instructions.action.rightClick": "Faites un clic droit sur une image →",
+ "instructions.action.commandPalette": "Palette de commandes →",
+ "savings.original": "Originale",
+ "savings.current": "Actuelle",
+ "savings.saved": "Économisé",
+ "tooltip.savings.header": "Détails sur les économies d'espace",
+ "tooltip.savings.original": "Taille originale :",
+ "tooltip.savings.current": "Taille actuelle :",
+ "tooltip.savings.saved": "Espace économisé :",
+ "tooltip.savings.filesProcessed": "Fichiers traités :",
+ "tooltip.savings.estimated": "Fichiers estimés"
+}
diff --git a/src-ts/locales/id.json b/src-ts/locales/id.json
new file mode 100644
index 0000000..c78f571
--- /dev/null
+++ b/src-ts/locales/id.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Pengaturan",
+ "settings.loadFailed": "Pengaturan tidak dapat dimuat; nilai bawaan digunakan",
+ "init.failed": "Inisialisasi plugin gagal. Muat ulang plugin setelah memperbaiki kesalahan yang dilaporkan.",
+ "migration.partialFailure": "Migrasi data plugin lama selesai dengan kesalahan. Periksa konsol pengembang.",
+ "i18n.externalLoadFailed": "File bahasa eksternal tidak dapat dimuat",
+ "warning.wasmInitFailed": "Modul WebAssembly gagal diinisialisasi. Silakan muat ulang plugin atau laporkan bug.",
+ "command.compressInNote": "Kompres semua gambar di catatan",
+ "command.compressInFolder": "Kompres semua gambar dalam folder",
+ "command.compressAll": "Kompres semua gambar di brankas",
+ "command.moveCompressed": "Pindahkan file terkompresi",
+ "context.compressImage": "Kompres gambar",
+ "context.compressImagesInFolder": "Kompres gambar dalam folder",
+ "notice.cacheUpdated": "Cache diperbarui",
+ "notice.cacheCleared": "Cache dibersihkan",
+ "notice.compressionDeferredDueToMove": "Kompresi ditunda saat operasi pemindahan sedang berlangsung",
+ "notice.operationFailed": "Operasi gagal. Periksa konsol pengembang.",
+ "validation.pathNotAllowed": "Jalur tidak diperbolehkan dalam pengaturan",
+ "validation.outputFolder": "File ada di dalam folder keluaran",
+ "validation.alreadyCompressed": "File sudah dikompresi",
+ "validation.tooSmall": "File terlalu kecil",
+ "validation.bytes": "byte",
+ "compress.error.fileAccess": "Tidak dapat mengakses file",
+ "compress.error.unknown": "kesalahan yang tidak diketahui",
+ "compress.error.tooLarge": "Gambar terlalu besar untuk dikompres dengan aman",
+ "compress.error.notSmaller": "File terkompresi tidak lebih kecil dari aslinya",
+ "compress.error.unsupportedFormat": "Format file tidak didukung",
+ "compress.error.copyCompressed": "Tidak dapat menyalin file terkompresi",
+ "compress.error.copyCompressedJpeg": "Tidak dapat menyalin file JPEG terkompresi",
+ "compress.error.pngQuality": "Encoder PNG tidak dapat memenuhi rentang kualitas yang dikonfigurasi",
+ "section.quality": "Kualitas kompresi",
+ "section.paths": "Jalur",
+ "section.automation": "Otomatisasi",
+ "section.stats": "Statistik & cache",
+ "section.move": "Pindahkan file terkompresi",
+ "section.cacheBackups": "Cadangan cache",
+ "section.instructions": "Instruksi",
+ "quality.png.name": "Kualitas PNG (min-maks)",
+ "quality.png.desc": "Kisaran kualitas untuk kompresi PNG (65-80 secara default)",
+ "quality.jpeg.name": "Kualitas JPEG",
+ "quality.jpeg.desc": "Kualitas kompresi JPEG (1-95)",
+ "paths.allowedRoots.name": "Folder root yang diizinkan",
+ "paths.allowedRoots.desc": "Pilih folder di mana kompresi diperbolehkan (kosong = semua jalur)",
+ "paths.allowedRoots.empty": "Tidak dipilih (kompres di mana-mana)",
+ "paths.allowedRoots.pill.remove": "Klik untuk menghapus",
+ "paths.allowedRoots.modal.placeholder": "Pilih folder...",
+ "paths.allowedRoots.cannotAddRoot": "Biarkan daftar kosong untuk mengizinkan semua folder",
+ "paths.allowedRoots.clear": "Hapus daftar",
+ "paths.output.name": "Folder keluaran",
+ "paths.output.desc": "Nama folder untuk menyimpan file terkompresi",
+ "auto.newFiles.name": "Kompres file baru secara otomatis",
+ "auto.newFiles.desc": "Kompres gambar baru secara otomatis saat ditambahkan ke brankas",
+ "auto.bg.name": "Kompresi otomatis di latar belakang",
+ "auto.bg.desc": "Kompres otomatis di latar belakang saat tidak aktif",
+ "auto.bg.threshold.name": "Ambang kompresi di latar belakang",
+ "auto.bg.threshold.desc": "Jumlah gambar yang belum dikompresi yang memicu kompresi otomatis",
+ "auto.bg.inactivity.name": "Ambang batas ketidakaktifan (menit)",
+ "auto.bg.inactivity.desc": "Jumlah menit tanpa aktivitas pengguna sebelum kompresi di latar belakang dimulai",
+ "guard.disabled": "{id} dinonaktifkan sementara selama kompresi",
+ "guard.restored": "{id} diaktifkan kembali setelah kompresi",
+ "auto.retention.toggle.name": "Hapus otomatis cadangan gambar",
+ "auto.retention.toggle.desc": "Hapus cadangan gambar berdasarkan periode penyimpanan",
+ "auto.retention.days.name": "Retensi cadangan (hari)",
+ "auto.retention.days.desc": "Hapus cadangan yang lebih lama dari jumlah hari yang ditentukan",
+ "auto.move.toggle.name": "Aktifkan pemindahan otomatis file terkompresi",
+ "auto.move.toggle.desc": "Secara otomatis memindahkan file terkompresi ketika ambang batas tercapai",
+ "auto.move.threshold.name": "Ambang perpindahan otomatis (file)",
+ "auto.move.threshold.desc": "Jumlah file dalam 'Compressed' yang diperlukan untuk memicu perpindahan otomatis",
+ "auto.queueFull": "Antrian kompres otomatis penuh ({max}); file baru dilewati",
+ "background.starting": "Kompresi di latar belakang dimulai. Jumlah gambar: {count}",
+ "background.finished": "Kompresi di latar belakang selesai. Gambar terkompresi: {count}",
+ "stats.uncompressed.name": "Gambar tidak terkompresi",
+ "stats.uncompressed.ready": "Gambar siap dikompresi",
+ "stats.cache.name": "Entri cache",
+ "stats.cache.entries": "Entri",
+ "stats.cache.size": "ukuran",
+ "cache.corruptSaved": "Cache tidak dapat dibaca. Salinan pemulihan telah disimpan:",
+ "move.title": "Pindahkan file terkompresi",
+ "move.ready": "File siap dipindahkan",
+ "move.button": "Pindahkan",
+ "move.noCompressedFolder": "Folder Compressed tidak ditemukan",
+ "move.noneToMove": "Tidak ada file terkompresi untuk dipindahkan",
+ "move.noneWithOriginals": "Tidak ada file terkompresi dengan file asli di folder root yang diizinkan",
+ "move.backupCreated": "Cadangan file asli dan terkompresi telah dibuat",
+ "move.backup.createdCount": "Pasangan file asli/terkompresi yang dicadangkan: {count}",
+ "move.skip.ambiguousOriginal": "Nama file asli yang ambigu",
+ "move.skip.originalMissingBeforeBackup": "File asli tidak ditemukan sebelum pencadangan",
+ "move.skip.compressedMissingBeforeBackup": "File terkompresi tidak ditemukan sebelum pencadangan",
+ "move.skip.originalModifiedDuringBackup": "File asli berubah selama pencadangan",
+ "move.skip.compressedModifiedDuringBackup": "File terkompresi berubah selama pencadangan",
+ "move.skip.originalContentChangedDuringBackup": "Isi file asli berubah selama pencadangan",
+ "move.skip.compressedContentChangedDuringBackup": "Isi file terkompresi berubah selama pencadangan",
+ "move.skip.contentChangedDuringCopy": "Konten diubah saat cadangan sedang disalin",
+ "move.skip.originalNotFoundAtMoveTime": "File asli tidak ditemukan saat pemindahan",
+ "move.skip.selfMove": "Jalur file terkompresi cocok dengan jalur file asli",
+ "move.skip.externalModification": "Modifikasi eksternal terdeteksi selama perpindahan",
+ "move.skip.invalidBackupTask": "Tugas pencadangan tidak valid",
+ "move.skip.noOriginalCandidate": "File asli yang sesuai tidak ditemukan",
+ "move.skip.unloading": "Plugin sedang dinonaktifkan",
+ "move.warning.externalModification": "Modifikasi eksternal terdeteksi selama pemindahan: {name}",
+ "backups.imagesFolder.name": "Folder cadangan gambar",
+ "backups.imagesFolder.desc": "Buka folder dengan cadangan gambar asli/terkompresi",
+ "backups.imagesFolder.openButton": "Buka folder cadangan gambar",
+ "backups.imagesFolder.openError": "Gagal membuka folder cadangan gambar",
+ "backups.imagesFolder.clearName": "Hapus cadangan gambar",
+ "backups.imagesFolder.clearDesc": "Hapus cadangan gambar asli/terkompresi yang dipindahkan",
+ "backups.imagesFolder.clearButton": "Hapus cadangan gambar",
+ "backups.imagesFolder.notFound": "Folder cadangan tidak ditemukan",
+ "backups.imagesFolder.noneToDelete": "Tidak ada cadangan untuk dihapus",
+ "backups.imagesFolder.deletedCount": "Kumpulan cadangan gambar yang dihapus: {count}",
+ "backups.imagesFolder.clearError": "Terjadi kesalahan saat menghapus cadangan",
+ "backups.cache.title": "Cadangan cache",
+ "backups.cache.folder.name": "Folder cadangan cache",
+ "backups.cache.folder.desc": "Buka folder dengan file cadangan cache",
+ "backups.cache.folder.openButton": "Buka folder cadangan cache",
+ "backups.pathLabel": "Jalur",
+ "backups.foundLabel": "Cadangan ditemukan",
+ "backups.cache.none": "Tidak ada cadangan cache yang tersedia",
+ "backups.cache.restore": "Pulihkan cache dari cadangan",
+ "backups.cache.available": "Cadangan yang tersedia:",
+ "backups.cache.selectPlaceholder": "-- Pilih cadangan --",
+ "common.add": "Tambah",
+ "common.refresh": "Segarkan",
+ "common.refreshing": "Memperbarui...",
+ "common.processing": "Memproses...",
+ "common.clearCache": "Hapus cache",
+ "common.refreshCache": "Segarkan cache",
+ "common.clear": "Hapus",
+ "common.clearing": "Menghapus...",
+ "common.cancel": "Batal",
+ "common.close": "Tutup",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Penggunaan:",
+ "instructions.notesTitle": "Catatan:",
+ "instructions.notes.saved": "File terkompresi disimpan di",
+ "instructions.notes.originalUnchanged": "File asli tidak diubah",
+ "instructions.notes.recompressionSkipped": "Kompresi ulang dilewati berkat cache",
+ "progress.start": "Memulai...",
+ "progress.processing": "Sedang memproses",
+ "progress.skippedAlready": "Dilewati (sudah dikompresi)",
+ "progress.skipped": "Dilewati",
+ "progress.compressed": "Dikompresi",
+ "progress.error": "Kesalahan",
+ "progress.cancelling": "Membatalkan...",
+ "progress.cancelled": "Dibatalkan",
+ "status.loading": "Memuat...",
+ "status.indexing": "Mengindeks gambar...",
+ "progress.completed": "Selesai",
+ "folders.noneInVault": "Tidak ada folder yang ditemukan di brankas",
+ "folderSelect.title": "Pilih folder untuk kompresi gambar",
+ "folderSelect.selectLabel": "Folder",
+ "folderSelect.root": "Folder akar",
+ "folderSelect.select": "Pilih",
+ "folderSelect.cancel": "Batal",
+ "instructions.action.rightClick": "Klik kanan gambar →",
+ "instructions.action.commandPalette": "Palet perintah →",
+ "savings.original": "Asli",
+ "savings.current": "Saat ini",
+ "savings.saved": "Dihemat",
+ "tooltip.savings.header": "Detail penghematan ruang",
+ "tooltip.savings.original": "Ukuran asli:",
+ "tooltip.savings.current": "Ukuran saat ini:",
+ "tooltip.savings.saved": "Ruang yang dihemat:",
+ "tooltip.savings.filesProcessed": "File yang diproses:",
+ "tooltip.savings.estimated": "Perkiraan file"
+}
diff --git a/src-ts/locales/index.ts b/src-ts/locales/index.ts
new file mode 100644
index 0000000..d28f514
--- /dev/null
+++ b/src-ts/locales/index.ts
@@ -0,0 +1,45 @@
+import ar from "./ar.json";
+import de from "./de.json";
+import en from "./en.json";
+import es from "./es.json";
+import fa from "./fa.json";
+import fr from "./fr.json";
+import id from "./id.json";
+import it from "./it.json";
+import ja from "./ja.json";
+import ko from "./ko.json";
+import nl from "./nl.json";
+import pl from "./pl.json";
+import ptBr from "./pt-br.json";
+import pt from "./pt.json";
+import ru from "./ru.json";
+import th from "./th.json";
+import tr from "./tr.json";
+import uk from "./uk.json";
+import vi from "./vi.json";
+import zhCn from "./zh-cn.json";
+import zhTw from "./zh-tw.json";
+
+export const BUILTIN_I18N: Record> = {
+ ar,
+ de,
+ en,
+ es,
+ fa,
+ fr,
+ id,
+ it,
+ ja,
+ ko,
+ nl,
+ pl,
+ pt,
+ "pt-br": ptBr,
+ ru,
+ th,
+ tr,
+ uk,
+ vi,
+ "zh-cn": zhCn,
+ "zh-tw": zhTw
+};
diff --git a/src-ts/locales/it.json b/src-ts/locales/it.json
new file mode 100644
index 0000000..235d685
--- /dev/null
+++ b/src-ts/locales/it.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Impostazioni",
+ "settings.loadFailed": "Impossibile caricare le impostazioni; sono state utilizzate le impostazioni predefinite",
+ "init.failed": "Inizializzazione del plugin non riuscita. Ricaricare il plugin dopo aver corretto l'errore segnalato.",
+ "migration.partialFailure": "La migrazione dei dati della versione precedente del plugin è stata completata con errori. Controlla la console per sviluppatori.",
+ "i18n.externalLoadFailed": "Impossibile caricare il file della lingua esterna",
+ "warning.wasmInitFailed": "Impossibile inizializzare i moduli WebAssembly. Ricarica il plugin o segnala un bug.",
+ "command.compressInNote": "Comprimi tutte le immagini nella nota",
+ "command.compressInFolder": "Comprimi tutte le immagini nella cartella",
+ "command.compressAll": "Comprimi tutte le immagini nel vault",
+ "command.moveCompressed": "Sposta file compressi",
+ "context.compressImage": "Comprimi l'immagine",
+ "context.compressImagesInFolder": "Comprimi le immagini nella cartella",
+ "notice.cacheUpdated": "Cache aggiornata",
+ "notice.cacheCleared": "Cache svuotata",
+ "notice.compressionDeferredDueToMove": "La compressione viene posticipata mentre è in corso un'operazione di spostamento",
+ "notice.operationFailed": "Operazione fallita. Controlla la console per sviluppatori.",
+ "validation.pathNotAllowed": "Il percorso non è consentito nelle impostazioni",
+ "validation.outputFolder": "Il file si trova all'interno della cartella di output",
+ "validation.alreadyCompressed": "Il file è già compresso",
+ "validation.tooSmall": "Il file è troppo piccolo",
+ "validation.bytes": "byte",
+ "compress.error.fileAccess": "Impossibile accedere al file",
+ "compress.error.unknown": "errore sconosciuto",
+ "compress.error.tooLarge": "L'immagine è troppo grande per essere compressa in modo sicuro",
+ "compress.error.notSmaller": "Il file compresso non è più piccolo dell'originale",
+ "compress.error.unsupportedFormat": "Formato file non supportato",
+ "compress.error.copyCompressed": "Impossibile copiare il file compresso",
+ "compress.error.copyCompressedJpeg": "Impossibile copiare il file JPEG compresso",
+ "compress.error.pngQuality": "Il codificatore PNG non è riuscito a soddisfare l'intervallo di qualità configurato",
+ "section.quality": "Qualità di compressione",
+ "section.paths": "Percorsi",
+ "section.automation": "Automazione",
+ "section.stats": "Statistiche e cache",
+ "section.move": "Sposta file compressi",
+ "section.cacheBackups": "Backup della cache",
+ "section.instructions": "Istruzioni",
+ "quality.png.name": "Qualità PNG (min-max)",
+ "quality.png.desc": "Intervallo di qualità per la compressione PNG (65-80 per impostazione predefinita)",
+ "quality.jpeg.name": "Qualità JPEG",
+ "quality.jpeg.desc": "Qualità di compressione JPEG (1-95)",
+ "paths.allowedRoots.name": "Cartelle radice consentite",
+ "paths.allowedRoots.desc": "Vengono elaborate solo le cartelle selezionate e le relative sottocartelle (elenco vuoto = tutti i percorsi)",
+ "paths.allowedRoots.empty": "Non selezionato (comprimi ovunque)",
+ "paths.allowedRoots.pill.remove": "Fare clic per rimuovere",
+ "paths.allowedRoots.modal.placeholder": "Seleziona una cartella...",
+ "paths.allowedRoots.cannotAddRoot": "Impossibile aggiungere la cartella principale come radice consentita. Lascia vuoto l'elenco per consentire tutte le cartelle.",
+ "paths.allowedRoots.clear": "Cancella elenco",
+ "paths.output.name": "Cartella di output",
+ "paths.output.desc": "Nome della cartella in cui archiviare i file compressi",
+ "auto.newFiles.name": "Comprimi automaticamente i nuovi file",
+ "auto.newFiles.desc": "Comprimi automaticamente le nuove immagini quando vengono aggiunte al vault",
+ "auto.bg.name": "Compressione automatica in background",
+ "auto.bg.desc": "Comprimi automaticamente in background quando sei inattivo",
+ "auto.bg.threshold.name": "Soglia di compressione in background",
+ "auto.bg.threshold.desc": "Numero di immagini non compresse necessario per avviare automaticamente la compressione in background",
+ "auto.bg.inactivity.name": "Soglia di inattività (minuti)",
+ "auto.bg.inactivity.desc": "Minuti senza attività dell'utente prima dell'avvio della compressione in background",
+ "guard.disabled": "{id} è stato temporaneamente disabilitato durante la compressione",
+ "guard.restored": "{id} è stato riattivato dopo la compressione",
+ "auto.retention.toggle.name": "Elimina automaticamente i backup delle immagini",
+ "auto.retention.toggle.desc": "Elimina i backup delle immagini in base al periodo di conservazione",
+ "auto.retention.days.name": "Conservazione dei backup (giorni)",
+ "auto.retention.days.desc": "Elimina i backup più vecchi del numero di giorni specificato",
+ "auto.move.toggle.name": "Abilita lo spostamento automatico dei file compressi",
+ "auto.move.toggle.desc": "Sposta automaticamente i file compressi quando viene raggiunta la soglia",
+ "auto.move.threshold.name": "Soglia di spostamento automatico (file)",
+ "auto.move.threshold.desc": "Numero di file in 'Compressed' necessario per attivare lo spostamento automatico",
+ "auto.queueFull": "La coda di compressione automatica è piena ({max}); i nuovi file sono stati saltati",
+ "background.starting": "Compressione in background avviata. Immagini: {count}",
+ "background.finished": "Compressione in background completata. Immagini compresse: {count}",
+ "stats.uncompressed.name": "Immagini non compresse",
+ "stats.uncompressed.ready": "Immagini pronte per la compressione",
+ "stats.cache.name": "Voci della cache",
+ "stats.cache.entries": "Voci",
+ "stats.cache.size": "dimensione",
+ "cache.corruptSaved": "Impossibile leggere la cache. È stata salvata una copia di ripristino:",
+ "move.title": "Sposta file compressi",
+ "move.ready": "File pronti da spostare",
+ "move.button": "Sposta",
+ "move.noCompressedFolder": "Cartella Compressed non trovata",
+ "move.noneToMove": "Nessun file compresso da spostare",
+ "move.noneWithOriginals": "Nessun file compresso il cui originale si trovi nelle cartelle radice consentite",
+ "move.backupCreated": "È stato creato un backup dei file originali e compressi",
+ "move.backup.createdCount": "Coppie di file originali/compressi incluse nel backup: {count}",
+ "move.skip.ambiguousOriginal": "Nome file originale ambiguo",
+ "move.skip.originalMissingBeforeBackup": "Originale mancante prima del backup",
+ "move.skip.compressedMissingBeforeBackup": "File compresso mancante prima del backup",
+ "move.skip.originalModifiedDuringBackup": "Originale modificato durante il backup",
+ "move.skip.compressedModifiedDuringBackup": "File compresso modificato durante il backup",
+ "move.skip.originalContentChangedDuringBackup": "Contenuto del file originale modificato durante il backup",
+ "move.skip.compressedContentChangedDuringBackup": "Contenuto del file compresso modificato durante il backup",
+ "move.skip.contentChangedDuringCopy": "Il contenuto è cambiato durante la copia del backup",
+ "move.skip.originalNotFoundAtMoveTime": "Originale non trovato al momento dello spostamento",
+ "move.skip.selfMove": "Il percorso del file compresso corrisponde al percorso del file originale",
+ "move.skip.externalModification": "Rilevata modifica esterna durante lo spostamento",
+ "move.skip.invalidBackupTask": "Attività di backup non valida",
+ "move.skip.noOriginalCandidate": "Nessun file originale corrispondente trovato",
+ "move.skip.unloading": "Plugin in fase di disattivazione",
+ "move.warning.externalModification": "Modifica esterna rilevata durante lo spostamento: {name}",
+ "backups.imagesFolder.name": "Cartella dei backup delle immagini",
+ "backups.imagesFolder.desc": "Apri la cartella con i backup delle immagini originali/compresse",
+ "backups.imagesFolder.openButton": "Apri la cartella dei backup delle immagini",
+ "backups.imagesFolder.openError": "Impossibile aprire la cartella dei backup delle immagini",
+ "backups.imagesFolder.clearName": "Cancella i backup delle immagini",
+ "backups.imagesFolder.clearDesc": "Elimina i backup delle immagini originali/compresse spostate",
+ "backups.imagesFolder.clearButton": "Cancella i backup delle immagini",
+ "backups.imagesFolder.notFound": "Cartella dei backup non trovata",
+ "backups.imagesFolder.noneToDelete": "Nessun backup da eliminare",
+ "backups.imagesFolder.deletedCount": "Set di backup delle immagini eliminati: {count}",
+ "backups.imagesFolder.clearError": "Errore durante la cancellazione dei backup",
+ "backups.cache.title": "Backup della cache",
+ "backups.cache.folder.name": "Cartella dei backup della cache",
+ "backups.cache.folder.desc": "Apri la cartella con i file di backup della cache",
+ "backups.cache.folder.openButton": "Apri la cartella dei backup della cache",
+ "backups.pathLabel": "Percorso",
+ "backups.foundLabel": "Backup trovati",
+ "backups.cache.none": "Nessun backup della cache disponibile",
+ "backups.cache.restore": "Ripristina la cache dal backup",
+ "backups.cache.available": "Backup disponibili:",
+ "backups.cache.selectPlaceholder": "-- Seleziona backup --",
+ "common.add": "Aggiungi",
+ "common.refresh": "Aggiorna",
+ "common.refreshing": "Aggiornamento...",
+ "common.processing": "Elaborazione...",
+ "common.clearCache": "Svuota cache",
+ "common.refreshCache": "Aggiorna la cache",
+ "common.clear": "Cancella",
+ "common.clearing": "Cancellazione...",
+ "common.cancel": "Annulla",
+ "common.close": "Chiudi",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Utilizzo:",
+ "instructions.notesTitle": "Note:",
+ "instructions.notes.saved": "I file compressi vengono salvati in",
+ "instructions.notes.originalUnchanged": "I file originali non vengono modificati",
+ "instructions.notes.recompressionSkipped": "La ricompressione viene saltata grazie alla cache",
+ "progress.start": "Avvio...",
+ "progress.processing": "Elaborazione",
+ "progress.skippedAlready": "Saltato (già compresso)",
+ "progress.skipped": "Saltato",
+ "progress.compressed": "Compresso",
+ "progress.error": "Errore",
+ "progress.cancelling": "Annullamento...",
+ "progress.cancelled": "Annullato",
+ "status.loading": "Caricamento...",
+ "status.indexing": "Indicizzazione delle immagini...",
+ "progress.completed": "Completato",
+ "folders.noneInVault": "Nessuna cartella trovata nel vault",
+ "folderSelect.title": "Seleziona una cartella per la compressione delle immagini",
+ "folderSelect.selectLabel": "Cartella",
+ "folderSelect.root": "Cartella radice",
+ "folderSelect.select": "Seleziona",
+ "folderSelect.cancel": "Annulla",
+ "instructions.action.rightClick": "Fare clic con il pulsante destro del mouse su un'immagine →",
+ "instructions.action.commandPalette": "Riquadro comandi →",
+ "savings.original": "Originale",
+ "savings.current": "Attuale",
+ "savings.saved": "Risparmiato",
+ "tooltip.savings.header": "Dettagli sul risparmio di spazio",
+ "tooltip.savings.original": "Dimensione originale:",
+ "tooltip.savings.current": "Dimensione attuale:",
+ "tooltip.savings.saved": "Spazio risparmiato:",
+ "tooltip.savings.filesProcessed": "File elaborati:",
+ "tooltip.savings.estimated": "File stimati"
+}
diff --git a/src-ts/locales/ja.json b/src-ts/locales/ja.json
new file mode 100644
index 0000000..2c9c013
--- /dev/null
+++ b/src-ts/locales/ja.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "設定",
+ "settings.loadFailed": "設定をロードできませんでした。デフォルトが使用されました",
+ "init.failed": "プラグインの初期化に失敗しました。報告されたエラーを修正した後、プラグインをリロードしてください。",
+ "migration.partialFailure": "従来のプラグイン データの移行はエラーで完了しました。開発者コンソールを確認してください。",
+ "i18n.externalLoadFailed": "外部言語ファイルをロードできませんでした",
+ "warning.wasmInitFailed": "WebAssembly モジュールの初期化に失敗しました。プラグインをリロードするか、バグを報告してください。",
+ "command.compressInNote": "ノート内のすべての画像を圧縮する",
+ "command.compressInFolder": "フォルダ内のすべての画像を圧縮する",
+ "command.compressAll": "保管庫内のすべての画像を圧縮する",
+ "command.moveCompressed": "圧縮ファイルを移動する",
+ "context.compressImage": "画像を圧縮する",
+ "context.compressImagesInFolder": "フォルダ内の画像を圧縮する",
+ "notice.cacheUpdated": "キャッシュが更新されました",
+ "notice.cacheCleared": "キャッシュがクリアされました",
+ "notice.compressionDeferredDueToMove": "ファイルの移動中のため、圧縮を延期しました",
+ "notice.operationFailed": "操作が失敗しました。開発者コンソールを確認してください。",
+ "validation.pathNotAllowed": "パスは設定で許可されていません",
+ "validation.outputFolder": "ファイルは出力フォルダ内にあります",
+ "validation.alreadyCompressed": "ファイルはすでに圧縮されています",
+ "validation.tooSmall": "ファイルが小さすぎます",
+ "validation.bytes": "バイト",
+ "compress.error.fileAccess": "ファイルにアクセスできません",
+ "compress.error.unknown": "不明なエラー",
+ "compress.error.tooLarge": "画像が大きすぎて安全に圧縮できません",
+ "compress.error.notSmaller": "圧縮ファイルは元のファイルより小さくありません",
+ "compress.error.unsupportedFormat": "サポートされていないファイル形式です",
+ "compress.error.copyCompressed": "圧縮ファイルをコピーできませんでした",
+ "compress.error.copyCompressedJpeg": "圧縮JPEGファイルをコピーできませんでした",
+ "compress.error.pngQuality": "PNG エンコーダは設定された品質範囲を満たせませんでした",
+ "section.quality": "圧縮品質",
+ "section.paths": "パス",
+ "section.automation": "自動化",
+ "section.stats": "統計とキャッシュ",
+ "section.move": "圧縮ファイルを移動する",
+ "section.cacheBackups": "キャッシュバックアップ",
+ "section.instructions": "使用方法",
+ "quality.png.name": "PNG 品質 (最小-最大)",
+ "quality.png.desc": "PNG 圧縮の品質範囲 (デフォルトでは 65 ~ 80)",
+ "quality.jpeg.name": "JPEG画質",
+ "quality.jpeg.desc": "JPEG圧縮品質(1-95)",
+ "paths.allowedRoots.name": "許可されたルートフォルダ",
+ "paths.allowedRoots.desc": "圧縮が許可されているフォルダを選択します (空 = すべてのパス)",
+ "paths.allowedRoots.empty": "選択されていません (どこでも圧縮)",
+ "paths.allowedRoots.pill.remove": "クリックして削除します",
+ "paths.allowedRoots.modal.placeholder": "フォルダを選択してください...",
+ "paths.allowedRoots.cannotAddRoot": "すべてのフォルダを許可するには、リストを空のままにしておきます",
+ "paths.allowedRoots.clear": "リストをクリアする",
+ "paths.output.name": "出力フォルダ",
+ "paths.output.desc": "圧縮ファイルを保存するフォルダ名",
+ "auto.newFiles.name": "新しいファイルを自動圧縮する",
+ "auto.newFiles.desc": "新しい画像が保管庫に追加されたときに自動的に圧縮します",
+ "auto.bg.name": "自動バックグラウンド圧縮",
+ "auto.bg.desc": "非アクティブなときにバックグラウンドで自動的に圧縮します",
+ "auto.bg.threshold.name": "バックグラウンド圧縮しきい値",
+ "auto.bg.threshold.desc": "バックグラウンド圧縮を自動開始する未圧縮画像数",
+ "auto.bg.inactivity.name": "非アクティブのしきい値 (分)",
+ "auto.bg.inactivity.desc": "バックグラウンド圧縮を開始するまでの無操作時間(分)",
+ "guard.disabled": "{id} は圧縮中に一時的に無効になりました",
+ "guard.restored": "{id} は圧縮後に再度有効化されました",
+ "auto.retention.toggle.name": "画像バックアップの自動削除",
+ "auto.retention.toggle.desc": "保存期間に基づいて画像バックアップを削除する",
+ "auto.retention.days.name": "バックアップの保存期間 (日)",
+ "auto.retention.days.desc": "指定した日数より古いバックアップを削除します",
+ "auto.move.toggle.name": "圧縮ファイルの自動移動を有効にする",
+ "auto.move.toggle.desc": "しきい値に達した場合に圧縮ファイルを自動的に移動する",
+ "auto.move.threshold.name": "自動移動のしきい値(ファイル数)",
+ "auto.move.threshold.desc": "自動移動をトリガーする 'Compressed' 内のファイルの数",
+ "auto.queueFull": "自動圧縮キューがいっぱいです ({max})。新しいファイルはスキップされました",
+ "background.starting": "バックグラウンド圧縮を開始しました。画像数: {count}",
+ "background.finished": "バックグラウンド圧縮が完了しました。圧縮済み画像数: {count}",
+ "stats.uncompressed.name": "非圧縮画像",
+ "stats.uncompressed.ready": "圧縮準備完了",
+ "stats.cache.name": "キャッシュエントリ",
+ "stats.cache.entries": "エントリ",
+ "stats.cache.size": "サイズ",
+ "cache.corruptSaved": "キャッシュを読み取れませんでした。リカバリ コピーが保存されました:",
+ "move.title": "圧縮ファイルを移動する",
+ "move.ready": "移動準備完了",
+ "move.button": "移動",
+ "move.noCompressedFolder": "Compressed フォルダが見つかりません",
+ "move.noneToMove": "移動する圧縮ファイルがありません",
+ "move.noneWithOriginals": "許可されたルート内に元ファイルがある圧縮ファイルはありません",
+ "move.backupCreated": "元ファイルと圧縮ファイルのバックアップを作成しました",
+ "move.backup.createdCount": "バックアップした元/圧縮ファイルの組: {count}",
+ "move.skip.ambiguousOriginal": "あいまいな元のファイル名",
+ "move.skip.originalMissingBeforeBackup": "バックアップ前にオリジナルが見つからない",
+ "move.skip.compressedMissingBeforeBackup": "バックアップ前に圧縮ファイルがありません",
+ "move.skip.originalModifiedDuringBackup": "バックアップ中にオリジナルが変更されました",
+ "move.skip.compressedModifiedDuringBackup": "バックアップ中に圧縮ファイルが変更されました",
+ "move.skip.originalContentChangedDuringBackup": "バックアップ中に元ファイルの内容が変更されました",
+ "move.skip.compressedContentChangedDuringBackup": "バックアップ中に圧縮ファイルの内容が変更されました",
+ "move.skip.contentChangedDuringCopy": "バックアップのコピー中にコンテンツが変更されました",
+ "move.skip.originalNotFoundAtMoveTime": "移動時にオリジナルが見つかりません",
+ "move.skip.selfMove": "圧縮ファイルのパスが元のファイルのパスと一致します",
+ "move.skip.externalModification": "移動中に外部変更が検出されました",
+ "move.skip.invalidBackupTask": "無効なバックアップタスク",
+ "move.skip.noOriginalCandidate": "元ファイルの候補が見つかりません",
+ "move.skip.unloading": "プラグインをアンロード中",
+ "move.warning.externalModification": "移動中に外部変更が検出されました: {name}",
+ "backups.imagesFolder.name": "画像バックアップフォルダ",
+ "backups.imagesFolder.desc": "オリジナル/圧縮画像のバックアップが含まれるフォルダを開く",
+ "backups.imagesFolder.openButton": "画像バックアップフォルダを開く",
+ "backups.imagesFolder.openError": "画像バックアップフォルダを開けませんでした",
+ "backups.imagesFolder.clearName": "画像バックアップをクリアする",
+ "backups.imagesFolder.clearDesc": "移動したオリジナル/圧縮画像のバックアップを削除する",
+ "backups.imagesFolder.clearButton": "画像バックアップをクリアする",
+ "backups.imagesFolder.notFound": "バックアップフォルダが見つかりません",
+ "backups.imagesFolder.noneToDelete": "削除するバックアップはありません",
+ "backups.imagesFolder.deletedCount": "削除した画像バックアップセット数: {count}",
+ "backups.imagesFolder.clearError": "バックアップのクリア中にエラーが発生しました",
+ "backups.cache.title": "キャッシュバックアップ",
+ "backups.cache.folder.name": "キャッシュバックアップフォルダ",
+ "backups.cache.folder.desc": "キャッシュバックアップファイルのあるフォルダを開く",
+ "backups.cache.folder.openButton": "キャッシュバックアップフォルダを開く",
+ "backups.pathLabel": "パス",
+ "backups.foundLabel": "バックアップが見つかりました",
+ "backups.cache.none": "利用可能なキャッシュ バックアップはありません",
+ "backups.cache.restore": "バックアップからキャッシュを復元する",
+ "backups.cache.available": "利用可能なバックアップ:",
+ "backups.cache.selectPlaceholder": "-- バックアップを選択 --",
+ "common.add": "追加",
+ "common.refresh": "リフレッシュ",
+ "common.refreshing": "更新中...",
+ "common.processing": "処理中...",
+ "common.clearCache": "キャッシュをクリアする",
+ "common.refreshCache": "キャッシュを更新する",
+ "common.clear": "クリア",
+ "common.clearing": "クリア中...",
+ "common.cancel": "キャンセル",
+ "common.close": "閉じる",
+ "units.kb": "KB",
+ "instructions.usageTitle": "使用法:",
+ "instructions.notesTitle": "注:",
+ "instructions.notes.saved": "圧縮ファイルは次の場所に保存されます",
+ "instructions.notes.originalUnchanged": "元のファイルは変更されません",
+ "instructions.notes.recompressionSkipped": "キャッシュのおかげで再圧縮はスキップされます",
+ "progress.start": "開始中...",
+ "progress.processing": "処理中",
+ "progress.skippedAlready": "スキップされました (すでに圧縮されています)",
+ "progress.skipped": "スキップされました",
+ "progress.compressed": "圧縮済み",
+ "progress.error": "エラー",
+ "progress.cancelling": "キャンセル中...",
+ "progress.cancelled": "キャンセルされました",
+ "status.loading": "読み込み中...",
+ "status.indexing": "画像のインデックスを作成中...",
+ "progress.completed": "完了",
+ "folders.noneInVault": "保管庫内にフォルダが見つかりません",
+ "folderSelect.title": "画像圧縮用のフォルダを選択します",
+ "folderSelect.selectLabel": "フォルダ",
+ "folderSelect.root": "ルートフォルダ",
+ "folderSelect.select": "選択",
+ "folderSelect.cancel": "キャンセル",
+ "instructions.action.rightClick": "画像を右クリック →",
+ "instructions.action.commandPalette": "コマンドパレット→",
+ "savings.original": "オリジナル",
+ "savings.current": "現在",
+ "savings.saved": "削減量",
+ "tooltip.savings.header": "容量削減の詳細",
+ "tooltip.savings.original": "元のサイズ:",
+ "tooltip.savings.current": "現在のサイズ:",
+ "tooltip.savings.saved": "削減容量:",
+ "tooltip.savings.filesProcessed": "処理されたファイル:",
+ "tooltip.savings.estimated": "推定ファイル数"
+}
diff --git a/src-ts/locales/ko.json b/src-ts/locales/ko.json
new file mode 100644
index 0000000..1f45f5b
--- /dev/null
+++ b/src-ts/locales/ko.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "설정",
+ "settings.loadFailed": "설정을 로드할 수 없습니다. 기본값이 사용되었습니다",
+ "init.failed": "플러그인 초기화에 실패했습니다. 보고된 오류를 수정한 후 플러그인을 다시 로드하세요.",
+ "migration.partialFailure": "기존 플러그인 데이터 마이그레이션이 오류와 함께 완료되었습니다. 개발자 콘솔을 확인하세요.",
+ "i18n.externalLoadFailed": "외부 언어 파일을 로드할 수 없습니다.",
+ "warning.wasmInitFailed": "WebAssembly 모듈을 초기화하지 못했습니다. 플러그인을 다시 로드하거나 버그를 신고해 주세요.",
+ "command.compressInNote": "노트의 모든 이미지 압축",
+ "command.compressInFolder": "폴더의 모든 이미지 압축",
+ "command.compressAll": "보관함의 모든 이미지 압축",
+ "command.moveCompressed": "압축 파일 이동",
+ "context.compressImage": "이미지 압축",
+ "context.compressImagesInFolder": "폴더의 이미지 압축",
+ "notice.cacheUpdated": "캐시가 업데이트되었습니다.",
+ "notice.cacheCleared": "캐시가 지워졌습니다.",
+ "notice.compressionDeferredDueToMove": "이동 작업이 진행되는 동안 압축이 지연됩니다.",
+ "notice.operationFailed": "작업이 실패했습니다. 개발자 콘솔을 확인하세요.",
+ "validation.pathNotAllowed": "설정에서 경로가 허용되지 않습니다.",
+ "validation.outputFolder": "파일이 출력 폴더 안에 있습니다.",
+ "validation.alreadyCompressed": "파일이 이미 압축되어 있습니다.",
+ "validation.tooSmall": "파일이 너무 작습니다",
+ "validation.bytes": "바이트",
+ "compress.error.fileAccess": "파일에 액세스할 수 없습니다.",
+ "compress.error.unknown": "알 수 없는 오류",
+ "compress.error.tooLarge": "이미지가 너무 커서 안전하게 압축할 수 없습니다.",
+ "compress.error.notSmaller": "압축된 파일은 원본보다 작지 않습니다.",
+ "compress.error.unsupportedFormat": "지원되지 않는 파일 형식",
+ "compress.error.copyCompressed": "압축파일을 복사할 수 없습니다",
+ "compress.error.copyCompressedJpeg": "압축된 JPEG 파일을 복사할 수 없습니다.",
+ "compress.error.pngQuality": "PNG 인코더가 구성된 품질 범위를 충족할 수 없습니다.",
+ "section.quality": "압축 품질",
+ "section.paths": "경로",
+ "section.automation": "자동화",
+ "section.stats": "통계 및 캐시",
+ "section.move": "압축 파일 이동",
+ "section.cacheBackups": "캐시 백업",
+ "section.instructions": "지침",
+ "quality.png.name": "PNG 품질(최소-최대)",
+ "quality.png.desc": "PNG 압축의 품질 범위(기본적으로 65-80)",
+ "quality.jpeg.name": "JPEG 품질",
+ "quality.jpeg.desc": "JPEG 압축 품질(1-95)",
+ "paths.allowedRoots.name": "허용된 루트 폴더",
+ "paths.allowedRoots.desc": "압축이 허용되는 폴더 선택(비어 있음 = 모든 경로)",
+ "paths.allowedRoots.empty": "선택되지 않음(모든 위치에서 압축)",
+ "paths.allowedRoots.pill.remove": "제거하려면 클릭하세요.",
+ "paths.allowedRoots.modal.placeholder": "폴더를 선택하세요...",
+ "paths.allowedRoots.cannotAddRoot": "모든 폴더를 허용하려면 목록을 비워 두세요.",
+ "paths.allowedRoots.clear": "목록 지우기",
+ "paths.output.name": "출력 폴더",
+ "paths.output.desc": "압축 파일을 저장할 폴더 이름",
+ "auto.newFiles.name": "새 파일 자동 압축",
+ "auto.newFiles.desc": "보관함에 추가되면 새 이미지를 자동으로 압축합니다.",
+ "auto.bg.name": "자동 백그라운드 압축",
+ "auto.bg.desc": "비활성 상태일 때 백그라운드에서 자동으로 압축",
+ "auto.bg.threshold.name": "백그라운드 압축 임계값",
+ "auto.bg.threshold.desc": "백그라운드 압축을 자동으로 시작할 미압축 이미지 수",
+ "auto.bg.inactivity.name": "비활성 임계값(분)",
+ "auto.bg.inactivity.desc": "백그라운드 압축을 시작하기 전 사용자 활동이 없는 시간(분)",
+ "guard.disabled": "압축 중에 {id}이 일시적으로 비활성화되었습니다.",
+ "guard.restored": "{id} 플러그인이 압축 후 다시 활성화되었습니다.",
+ "auto.retention.toggle.name": "이미지 백업 자동 삭제",
+ "auto.retention.toggle.desc": "보존 기간별 이미지 백업 삭제",
+ "auto.retention.days.name": "백업 보존(일)",
+ "auto.retention.days.desc": "지정된 일수보다 오래된 백업 삭제",
+ "auto.move.toggle.name": "압축 파일 자동 이동 활성화",
+ "auto.move.toggle.desc": "임계값에 도달하면 압축 파일을 자동으로 이동합니다.",
+ "auto.move.threshold.name": "자동 이동 임계값(파일 수)",
+ "auto.move.threshold.desc": "자동 이동을 트리거할 'Compressed'의 파일 수",
+ "auto.queueFull": "자동 압축 대기열이 가득 찼습니다({max}). 새 파일을 건너뛰었습니다.",
+ "background.starting": "백그라운드 압축을 시작했습니다. 이미지 수: {count}",
+ "background.finished": "백그라운드 압축이 완료되었습니다. 압축된 이미지 수: {count}",
+ "stats.uncompressed.name": "압축되지 않은 이미지",
+ "stats.uncompressed.ready": "압축 준비 완료",
+ "stats.cache.name": "캐시 항목",
+ "stats.cache.entries": "항목",
+ "stats.cache.size": "크기",
+ "cache.corruptSaved": "캐시를 읽을 수 없습니다. 복구 복사본이 저장되었습니다:",
+ "move.title": "압축 파일 이동",
+ "move.ready": "이동 준비 완료",
+ "move.button": "이동",
+ "move.noCompressedFolder": "Compressed 폴더를 찾을 수 없습니다",
+ "move.noneToMove": "이동할 압축 파일이 없습니다.",
+ "move.noneWithOriginals": "허용된 루트 폴더에 원본 파일이 있는 압축 파일이 없습니다.",
+ "move.backupCreated": "원본 및 압축 파일의 백업을 생성했습니다.",
+ "move.backup.createdCount": "백업한 원본/압축 파일 쌍: {count}",
+ "move.skip.ambiguousOriginal": "모호한 원본 파일 이름",
+ "move.skip.originalMissingBeforeBackup": "백업 전 원본 누락",
+ "move.skip.compressedMissingBeforeBackup": "백업 전에 압축 파일이 없습니다.",
+ "move.skip.originalModifiedDuringBackup": "백업 중 원본이 변경됨",
+ "move.skip.compressedModifiedDuringBackup": "백업 중 압축 파일이 변경되었습니다.",
+ "move.skip.originalContentChangedDuringBackup": "백업 중 원본 파일 내용이 변경되었습니다.",
+ "move.skip.compressedContentChangedDuringBackup": "백업 중 압축 파일 내용이 변경되었습니다.",
+ "move.skip.contentChangedDuringCopy": "백업이 복사되는 동안 콘텐츠가 변경되었습니다.",
+ "move.skip.originalNotFoundAtMoveTime": "이동 시 원본을 찾을 수 없음",
+ "move.skip.selfMove": "압축 파일 경로가 원본 파일 경로와 같습니다.",
+ "move.skip.externalModification": "이동 중 외부 수정이 감지되었습니다.",
+ "move.skip.invalidBackupTask": "잘못된 백업 작업",
+ "move.skip.noOriginalCandidate": "원본 파일 후보를 찾을 수 없습니다.",
+ "move.skip.unloading": "플러그인 언로드 중",
+ "move.warning.externalModification": "이동 중 외부 수정 감지: {name}",
+ "backups.imagesFolder.name": "이미지 백업 폴더",
+ "backups.imagesFolder.desc": "원본/압축 이미지 백업이 포함된 폴더 열기",
+ "backups.imagesFolder.openButton": "이미지 백업 폴더 열기",
+ "backups.imagesFolder.openError": "이미지 백업 폴더를 열지 못했습니다.",
+ "backups.imagesFolder.clearName": "이미지 백업 지우기",
+ "backups.imagesFolder.clearDesc": "이동된 원본/압축 이미지 백업 삭제",
+ "backups.imagesFolder.clearButton": "이미지 백업 지우기",
+ "backups.imagesFolder.notFound": "백업 폴더를 찾을 수 없습니다",
+ "backups.imagesFolder.noneToDelete": "삭제할 백업이 없습니다.",
+ "backups.imagesFolder.deletedCount": "삭제한 이미지 백업 세트: {count}",
+ "backups.imagesFolder.clearError": "백업을 지우는 중 오류가 발생했습니다.",
+ "backups.cache.title": "캐시 백업",
+ "backups.cache.folder.name": "캐시 백업 폴더",
+ "backups.cache.folder.desc": "캐시 백업 파일이 있는 폴더 열기",
+ "backups.cache.folder.openButton": "캐시 백업 폴더 열기",
+ "backups.pathLabel": "경로",
+ "backups.foundLabel": "찾은 백업",
+ "backups.cache.none": "사용 가능한 캐시 백업이 없습니다.",
+ "backups.cache.restore": "백업에서 캐시 복원",
+ "backups.cache.available": "사용 가능한 백업:",
+ "backups.cache.selectPlaceholder": "-- 백업 선택 --",
+ "common.add": "추가",
+ "common.refresh": "새로고침",
+ "common.refreshing": "새로고침 중...",
+ "common.processing": "처리 중...",
+ "common.clearCache": "캐시 지우기",
+ "common.refreshCache": "캐시 새로 고침",
+ "common.clear": "지우기",
+ "common.clearing": "지우는 중...",
+ "common.cancel": "취소",
+ "common.close": "닫기",
+ "units.kb": "KB",
+ "instructions.usageTitle": "사용법:",
+ "instructions.notesTitle": "참고:",
+ "instructions.notes.saved": "압축 파일 저장 위치:",
+ "instructions.notes.originalUnchanged": "원본 파일은 수정되지 않습니다",
+ "instructions.notes.recompressionSkipped": "캐시 덕분에 재압축을 건너뜁니다.",
+ "progress.start": "시작 중...",
+ "progress.processing": "처리 중",
+ "progress.skippedAlready": "건너뜀(이미 압축됨)",
+ "progress.skipped": "건너뜀",
+ "progress.compressed": "압축됨",
+ "progress.error": "오류",
+ "progress.cancelling": "취소 중...",
+ "progress.cancelled": "취소됨",
+ "status.loading": "로드 중...",
+ "status.indexing": "이미지 인덱싱 중...",
+ "progress.completed": "완료",
+ "folders.noneInVault": "보관함에 폴더가 없습니다.",
+ "folderSelect.title": "이미지 압축을 위한 폴더 선택",
+ "folderSelect.selectLabel": "폴더",
+ "folderSelect.root": "루트 폴더",
+ "folderSelect.select": "선택",
+ "folderSelect.cancel": "취소",
+ "instructions.action.rightClick": "이미지를 마우스 오른쪽 버튼으로 클릭 →",
+ "instructions.action.commandPalette": "명령어 팔레트 →",
+ "savings.original": "원본",
+ "savings.current": "현재",
+ "savings.saved": "절감량",
+ "tooltip.savings.header": "공간 절약 세부정보",
+ "tooltip.savings.original": "원본 크기:",
+ "tooltip.savings.current": "현재 크기:",
+ "tooltip.savings.saved": "절약된 공간:",
+ "tooltip.savings.filesProcessed": "처리된 파일:",
+ "tooltip.savings.estimated": "추정 파일"
+}
diff --git a/src-ts/locales/nl.json b/src-ts/locales/nl.json
new file mode 100644
index 0000000..e69a562
--- /dev/null
+++ b/src-ts/locales/nl.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Instellingen",
+ "settings.loadFailed": "Instellingen konden niet worden geladen; standaardinstellingen werden gebruikt",
+ "init.failed": "Initialisatie van de plug-in is mislukt. Laad de plug-in opnieuw nadat u de gerapporteerde fout heeft verholpen.",
+ "migration.partialFailure": "Migratie van verouderde plug-ingegevens voltooid met fouten. Controleer de ontwikkelaarsconsole.",
+ "i18n.externalLoadFailed": "Het externe taalbestand kan niet worden geladen",
+ "warning.wasmInitFailed": "WebAssembly-modules konden niet worden geïnitialiseerd. Laad de plug-in opnieuw of rapporteer een bug.",
+ "command.compressInNote": "Comprimeer alle afbeeldingen in de notitie",
+ "command.compressInFolder": "Comprimeer alle afbeeldingen in de map",
+ "command.compressAll": "Comprimeer alle afbeeldingen in de kluis",
+ "command.moveCompressed": "Verplaats gecomprimeerde bestanden",
+ "context.compressImage": "Afbeelding comprimeren",
+ "context.compressImagesInFolder": "Comprimeer afbeeldingen in de map",
+ "notice.cacheUpdated": "Cache bijgewerkt",
+ "notice.cacheCleared": "Cache gewist",
+ "notice.compressionDeferredDueToMove": "De compressie wordt uitgesteld terwijl er een verplaatsingsoperatie gaande is",
+ "notice.operationFailed": "Operatie mislukt. Controleer de ontwikkelaarsconsole.",
+ "validation.pathNotAllowed": "Pad is niet toegestaan in de instellingen",
+ "validation.outputFolder": "Het bestand bevindt zich in de uitvoermap",
+ "validation.alreadyCompressed": "Bestand is al gecomprimeerd",
+ "validation.tooSmall": "Bestand is te klein",
+ "validation.bytes": "bytes",
+ "compress.error.fileAccess": "Kan geen toegang krijgen tot het bestand",
+ "compress.error.unknown": "onbekende fout",
+ "compress.error.tooLarge": "Afbeelding is te groot om veilig te comprimeren",
+ "compress.error.notSmaller": "Gecomprimeerd bestand is niet kleiner dan origineel",
+ "compress.error.unsupportedFormat": "Niet-ondersteunde bestandsindeling",
+ "compress.error.copyCompressed": "Kon het gecomprimeerde bestand niet kopiëren",
+ "compress.error.copyCompressedJpeg": "Kon het gecomprimeerde JPEG-bestand niet kopiëren",
+ "compress.error.pngQuality": "De PNG-encoder kon niet voldoen aan de minimale kwaliteitsinstelling",
+ "section.quality": "Compressiekwaliteit",
+ "section.paths": "Paden",
+ "section.automation": "Automatisering",
+ "section.stats": "Statistieken en cache",
+ "section.move": "Verplaats gecomprimeerde bestanden",
+ "section.cacheBackups": "Cacheback-ups",
+ "section.instructions": "Instructies",
+ "quality.png.name": "PNG-kwaliteit (min-max)",
+ "quality.png.desc": "Kwaliteitsbereik voor PNG-compressie (standaard 65-80)",
+ "quality.jpeg.name": "JPEG-kwaliteit",
+ "quality.jpeg.desc": "JPEG-compressiekwaliteit (1-95)",
+ "paths.allowedRoots.name": "Toegestane hoofdmappen",
+ "paths.allowedRoots.desc": "Selecteer mappen waar compressie is toegestaan (leeg = alle paden)",
+ "paths.allowedRoots.empty": "Niet geselecteerd (overal comprimeren)",
+ "paths.allowedRoots.pill.remove": "Klik om te verwijderen",
+ "paths.allowedRoots.modal.placeholder": "Selecteer een map...",
+ "paths.allowedRoots.cannotAddRoot": "Laat de lijst leeg om alle mappen toe te staan",
+ "paths.allowedRoots.clear": "Lijst wissen",
+ "paths.output.name": "Uitvoermap",
+ "paths.output.desc": "Mapnaam om gecomprimeerde bestanden op te slaan",
+ "auto.newFiles.name": "Nieuwe bestanden automatisch comprimeren",
+ "auto.newFiles.desc": "Comprimeer automatisch nieuwe afbeeldingen wanneer ze aan de kluis worden toegevoegd",
+ "auto.bg.name": "Automatische achtergrondcompressie",
+ "auto.bg.desc": "Automatisch comprimeren op de achtergrond wanneer u inactief bent",
+ "auto.bg.threshold.name": "Achtergrondcompressiedrempel",
+ "auto.bg.threshold.desc": "Aantal ongecomprimeerde afbeeldingen waarbij de achtergrondcompressie automatisch start",
+ "auto.bg.inactivity.name": "Inactiviteitsdrempel (minuten)",
+ "auto.bg.inactivity.desc": "Minuten zonder gebruikersactiviteit voordat achtergrondcompressie begint",
+ "guard.disabled": "{id} is tijdelijk uitgeschakeld tijdens compressie",
+ "guard.restored": "{id} is na compressie opnieuw ingeschakeld",
+ "auto.retention.toggle.name": "Back-ups van afbeeldingen automatisch verwijderen",
+ "auto.retention.toggle.desc": "Afbeeldingsback-ups verwijderen op basis van de bewaartermijn",
+ "auto.retention.days.name": "Bewaring van back-ups (dagen)",
+ "auto.retention.days.desc": "Verwijder back-ups die ouder zijn dan het opgegeven aantal dagen",
+ "auto.move.toggle.name": "Schakel het automatisch verplaatsen van gecomprimeerde bestanden in",
+ "auto.move.toggle.desc": "Verplaats gecomprimeerde bestanden automatisch wanneer de drempel wordt bereikt",
+ "auto.move.threshold.name": "Drempel voor automatisch verplaatsen (bestanden)",
+ "auto.move.threshold.desc": "Aantal bestanden in 'Compressed' waarbij automatisch verplaatsen wordt gestart",
+ "auto.queueFull": "Wachtrij voor automatisch comprimeren is vol ({max}); nieuwe bestanden zijn overgeslagen",
+ "background.starting": "Achtergrondcompressie gestart. Aantal afbeeldingen: {count}",
+ "background.finished": "Achtergrondcompressie voltooid. Gecomprimeerde afbeeldingen: {count}",
+ "stats.uncompressed.name": "Ongecomprimeerde afbeeldingen",
+ "stats.uncompressed.ready": "Klaar om te comprimeren",
+ "stats.cache.name": "Cache-items",
+ "stats.cache.entries": "Items",
+ "stats.cache.size": "grootte",
+ "cache.corruptSaved": "Cache kon niet worden gelezen. Er is een herstelkopie opgeslagen:",
+ "move.title": "Verplaats gecomprimeerde bestanden",
+ "move.ready": "Klaar om te verplaatsen",
+ "move.button": "Verplaatsen",
+ "move.noCompressedFolder": "Compressed map niet gevonden",
+ "move.noneToMove": "Geen gecomprimeerde bestanden om te verplaatsen",
+ "move.noneWithOriginals": "Geen gecomprimeerde bestanden met originelen in de toegestane hoofdmappen",
+ "move.backupCreated": "Back-up gemaakt van originele en gecomprimeerde bestanden",
+ "move.backup.createdCount": "Back-upparen van originele/gecomprimeerde bestanden: {count}",
+ "move.skip.ambiguousOriginal": "Dubbelzinnige originele bestandsnaam",
+ "move.skip.originalMissingBeforeBackup": "Origineel ontbreekt vóór back-up",
+ "move.skip.compressedMissingBeforeBackup": "Gecomprimeerd bestand ontbreekt vóór de back-up",
+ "move.skip.originalModifiedDuringBackup": "Origineel gewijzigd tijdens back-up",
+ "move.skip.compressedModifiedDuringBackup": "Gecomprimeerd bestand gewijzigd tijdens de back-up",
+ "move.skip.originalContentChangedDuringBackup": "Inhoud van het originele bestand gewijzigd tijdens de back-up",
+ "move.skip.compressedContentChangedDuringBackup": "Inhoud van het gecomprimeerde bestand gewijzigd tijdens de back-up",
+ "move.skip.contentChangedDuringCopy": "De inhoud is gewijzigd terwijl de back-up werd gekopieerd",
+ "move.skip.originalNotFoundAtMoveTime": "Origineel niet gevonden tijdens het verplaatsen",
+ "move.skip.selfMove": "Het pad van het gecomprimeerde bestand komt overeen met het pad van het originele bestand",
+ "move.skip.externalModification": "Externe wijziging gedetecteerd tijdens het verplaatsen",
+ "move.skip.invalidBackupTask": "Ongeldige back-uptaak",
+ "move.skip.noOriginalCandidate": "Kandidaat voor het originele bestand niet gevonden",
+ "move.skip.unloading": "Plug-in wordt uitgeschakeld",
+ "move.warning.externalModification": "Externe wijziging gedetecteerd tijdens verplaatsing: {name}",
+ "backups.imagesFolder.name": "Map met back-ups van afbeeldingen",
+ "backups.imagesFolder.desc": "Open map met back-ups van originele/gecomprimeerde afbeeldingen",
+ "backups.imagesFolder.openButton": "Map met afbeeldingsback-ups openen",
+ "backups.imagesFolder.openError": "Kan de map met afbeeldingsback-ups niet openen",
+ "backups.imagesFolder.clearName": "Afbeeldingsback-ups wissen",
+ "backups.imagesFolder.clearDesc": "Verwijder back-ups van verplaatste originele/gecomprimeerde afbeeldingen",
+ "backups.imagesFolder.clearButton": "Afbeeldingsback-ups wissen",
+ "backups.imagesFolder.notFound": "Map met back-ups niet gevonden",
+ "backups.imagesFolder.noneToDelete": "Geen back-ups om te verwijderen",
+ "backups.imagesFolder.deletedCount": "Verwijderde sets met afbeeldingsback-ups: {count}",
+ "backups.imagesFolder.clearError": "Fout bij het wissen van back-ups",
+ "backups.cache.title": "Cacheback-ups",
+ "backups.cache.folder.name": "Map met cacheback-ups",
+ "backups.cache.folder.desc": "Open map met cacheback-upbestanden",
+ "backups.cache.folder.openButton": "Open de map met cacheback-ups",
+ "backups.pathLabel": "Pad",
+ "backups.foundLabel": "Back-ups gevonden",
+ "backups.cache.none": "Er zijn geen cacheback-ups beschikbaar",
+ "backups.cache.restore": "Cache herstellen vanaf back-up",
+ "backups.cache.available": "Beschikbare back-ups:",
+ "backups.cache.selectPlaceholder": "-- Selecteer back-up --",
+ "common.add": "Toevoegen",
+ "common.refresh": "Vernieuwen",
+ "common.refreshing": "Bezig met vernieuwen...",
+ "common.processing": "Verwerken...",
+ "common.clearCache": "Cache wissen",
+ "common.refreshCache": "Cache vernieuwen",
+ "common.clear": "Wissen",
+ "common.clearing": "Bezig met wissen...",
+ "common.cancel": "Annuleren",
+ "common.close": "Sluiten",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Gebruik:",
+ "instructions.notesTitle": "Opmerkingen:",
+ "instructions.notes.saved": "Gecomprimeerde bestanden worden opgeslagen in",
+ "instructions.notes.originalUnchanged": "Originele bestanden worden niet gewijzigd",
+ "instructions.notes.recompressionSkipped": "Hercompressie wordt dankzij de cache overgeslagen",
+ "progress.start": "Wordt gestart...",
+ "progress.processing": "Verwerking",
+ "progress.skippedAlready": "Overgeslagen (al gecomprimeerd)",
+ "progress.skipped": "Overgeslagen",
+ "progress.compressed": "Gecomprimeerd",
+ "progress.error": "Fout",
+ "progress.cancelling": "Annuleren...",
+ "progress.cancelled": "Geannuleerd",
+ "status.loading": "Laden...",
+ "status.indexing": "Afbeeldingen worden geïndexeerd...",
+ "progress.completed": "Voltooid",
+ "folders.noneInVault": "Geen mappen gevonden in de kluis",
+ "folderSelect.title": "Selecteer een map voor beeldcompressie",
+ "folderSelect.selectLabel": "Map",
+ "folderSelect.root": "Hoofdmap",
+ "folderSelect.select": "Selecteer",
+ "folderSelect.cancel": "Annuleren",
+ "instructions.action.rightClick": "Klik met de rechtermuisknop op een afbeelding →",
+ "instructions.action.commandPalette": "Opdrachtenpaneel →",
+ "savings.original": "Origineel",
+ "savings.current": "Huidig",
+ "savings.saved": "Bespaard",
+ "tooltip.savings.header": "Details over ruimtebesparing",
+ "tooltip.savings.original": "Oorspronkelijke grootte:",
+ "tooltip.savings.current": "Huidige grootte:",
+ "tooltip.savings.saved": "Ruimte bespaard:",
+ "tooltip.savings.filesProcessed": "Bestanden verwerkt:",
+ "tooltip.savings.estimated": "Geschatte bestanden"
+}
diff --git a/src-ts/locales/pl.json b/src-ts/locales/pl.json
new file mode 100644
index 0000000..7f5dd77
--- /dev/null
+++ b/src-ts/locales/pl.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Ustawienia",
+ "settings.loadFailed": "Nie można załadować ustawień; zastosowano wartości domyślne",
+ "init.failed": "Inicjalizacja wtyczki nie powiodła się. Załaduj ponownie wtyczkę po naprawieniu zgłoszonego błędu.",
+ "migration.partialFailure": "Migracja danych starszej wtyczki została zakończona z błędami. Sprawdź konsolę programisty.",
+ "i18n.externalLoadFailed": "Nie można załadować zewnętrznego pliku językowego",
+ "warning.wasmInitFailed": "Nie udało się zainicjować modułów WebAssembly. Załaduj ponownie wtyczkę lub zgłoś błąd.",
+ "command.compressInNote": "Skompresuj wszystkie obrazy w notatce",
+ "command.compressInFolder": "Skompresuj wszystkie obrazy w folderze",
+ "command.compressAll": "Skompresuj wszystkie obrazy w sejfie",
+ "command.moveCompressed": "Przenieś skompresowane pliki",
+ "context.compressImage": "Kompresuj obraz",
+ "context.compressImagesInFolder": "Kompresuj obrazy w folderze",
+ "notice.cacheUpdated": "Pamięć podręczna zaktualizowana",
+ "notice.cacheCleared": "Pamięć podręczna wyczyszczona",
+ "notice.compressionDeferredDueToMove": "Kompresja jest opóźniana na czas trwania operacji przenoszenia",
+ "notice.operationFailed": "Operacja nie powiodła się. Sprawdź konsolę programisty.",
+ "validation.pathNotAllowed": "Ścieżka nie jest dozwolona w ustawieniach",
+ "validation.outputFolder": "Plik znajduje się w folderze wyjściowym",
+ "validation.alreadyCompressed": "Plik jest już skompresowany",
+ "validation.tooSmall": "Plik jest za mały",
+ "validation.bytes": "B",
+ "compress.error.fileAccess": "Nie można uzyskać dostępu do pliku",
+ "compress.error.unknown": "nieznany błąd",
+ "compress.error.tooLarge": "Obraz jest zbyt duży, aby bezpiecznie go skompresować",
+ "compress.error.notSmaller": "Skompresowany plik nie jest mniejszy niż oryginał",
+ "compress.error.unsupportedFormat": "Nieobsługiwany format pliku",
+ "compress.error.copyCompressed": "Nie można skopiować skompresowanego pliku",
+ "compress.error.copyCompressedJpeg": "Nie można skopiować skompresowanego pliku JPEG",
+ "compress.error.pngQuality": "Koder PNG nie mógł osiągnąć skonfigurowanego zakresu jakości",
+ "section.quality": "Jakość kompresji",
+ "section.paths": "Ścieżki",
+ "section.automation": "Automatyzacja",
+ "section.stats": "Statystyki i pamięć podręczna",
+ "section.move": "Przenieś skompresowane pliki",
+ "section.cacheBackups": "Kopie zapasowe pamięci podręcznej",
+ "section.instructions": "Instrukcje",
+ "quality.png.name": "Jakość PNG (min.-maks.)",
+ "quality.png.desc": "Zakres jakości kompresji PNG (domyślnie 65–80)",
+ "quality.jpeg.name": "Jakość JPEG",
+ "quality.jpeg.desc": "Jakość kompresji JPEG (1-95)",
+ "paths.allowedRoots.name": "Dozwolone foldery główne",
+ "paths.allowedRoots.desc": "Wybierz foldery, w których kompresja jest dozwolona (pusta lista = wszystkie ścieżki)",
+ "paths.allowedRoots.empty": "Nie wybrano (kompresuj wszędzie)",
+ "paths.allowedRoots.pill.remove": "Kliknij, aby usunąć",
+ "paths.allowedRoots.modal.placeholder": "Wybierz folder...",
+ "paths.allowedRoots.cannotAddRoot": "Pozostaw listę pustą, aby zezwolić na wszystkie foldery",
+ "paths.allowedRoots.clear": "Wyczyść listę",
+ "paths.output.name": "Folder wyjściowy",
+ "paths.output.desc": "Nazwa folderu do przechowywania skompresowanych plików",
+ "auto.newFiles.name": "Automatycznie kompresuj nowe pliki",
+ "auto.newFiles.desc": "Automatycznie kompresuj nowe obrazy po dodaniu do sejfu",
+ "auto.bg.name": "Automatyczna kompresja w tle",
+ "auto.bg.desc": "Automatycznie kompresuj w tle, gdy jesteś nieaktywny",
+ "auto.bg.threshold.name": "Próg kompresji w tle",
+ "auto.bg.threshold.desc": "Liczba nieskompresowanych obrazów, przy której kompresja w tle uruchamia się automatycznie",
+ "auto.bg.inactivity.name": "Próg braku aktywności (minuty)",
+ "auto.bg.inactivity.desc": "Liczba minut bez aktywności użytkownika przed uruchomieniem kompresji w tle",
+ "guard.disabled": "{id} został tymczasowo wyłączony podczas kompresji",
+ "guard.restored": "{id} został ponownie włączony po kompresji",
+ "auto.retention.toggle.name": "Automatyczne usuwanie kopii zapasowych obrazów",
+ "auto.retention.toggle.desc": "Usuwaj kopie zapasowe obrazów zgodnie z okresem przechowywania",
+ "auto.retention.days.name": "Okres przechowywania kopii zapasowych (dni)",
+ "auto.retention.days.desc": "Usuwaj kopie zapasowe starsze niż podana liczba dni",
+ "auto.move.toggle.name": "Włącz automatyczne przenoszenie skompresowanych plików",
+ "auto.move.toggle.desc": "Automatycznie przenoś skompresowane pliki po osiągnięciu progu",
+ "auto.move.threshold.name": "Próg automatycznego przenoszenia (pliki)",
+ "auto.move.threshold.desc": "Liczba plików w folderze 'Compressed' wymagana do uruchomienia automatycznego przenoszenia",
+ "auto.queueFull": "Kolejka automatycznej kompresji jest pełna ({max}); nowe pliki zostały pominięte",
+ "background.starting": "Uruchomiono kompresję w tle. Liczba obrazów: {count}",
+ "background.finished": "Kompresja w tle zakończona. Skompresowane obrazy: {count}",
+ "stats.uncompressed.name": "Nieskompresowane obrazy",
+ "stats.uncompressed.ready": "Gotowe do kompresji",
+ "stats.cache.name": "Wpisy w pamięci podręcznej",
+ "stats.cache.entries": "Wpisy",
+ "stats.cache.size": "rozmiar",
+ "cache.corruptSaved": "Nie można odczytać pamięci podręcznej. Zapisano kopię odzyskiwania:",
+ "move.title": "Przenieś skompresowane pliki",
+ "move.ready": "Gotowe do przeniesienia",
+ "move.button": "Przenieś",
+ "move.noCompressedFolder": "Nie znaleziono folderu Compressed",
+ "move.noneToMove": "Brak skompresowanych plików do przeniesienia",
+ "move.noneWithOriginals": "Brak skompresowanych plików z oryginałami w dozwolonych katalogach głównych",
+ "move.backupCreated": "Utworzono kopię zapasową plików oryginalnych i skompresowanych",
+ "move.backup.createdCount": "Utworzone pary kopii plików oryginalnych i skompresowanych: {count}",
+ "move.skip.ambiguousOriginal": "Niejednoznaczna nazwa pliku oryginalnego",
+ "move.skip.originalMissingBeforeBackup": "Brak oryginału przed wykonaniem kopii zapasowej",
+ "move.skip.compressedMissingBeforeBackup": "Brak skompresowanego pliku przed utworzeniem kopii zapasowej",
+ "move.skip.originalModifiedDuringBackup": "Oryginał zmieniony podczas tworzenia kopii zapasowej",
+ "move.skip.compressedModifiedDuringBackup": "Skompresowany plik zmienił się podczas tworzenia kopii zapasowej",
+ "move.skip.originalContentChangedDuringBackup": "Zawartość pliku oryginalnego zmieniła się podczas tworzenia kopii zapasowej",
+ "move.skip.compressedContentChangedDuringBackup": "Zawartość skompresowanego pliku zmieniła się podczas tworzenia kopii zapasowej",
+ "move.skip.contentChangedDuringCopy": "Treść została zmieniona podczas kopiowania kopii zapasowej",
+ "move.skip.originalNotFoundAtMoveTime": "Oryginału nie znaleziono w momencie przenoszenia",
+ "move.skip.selfMove": "Ścieżka skompresowanego pliku jest taka sama jak ścieżka oryginału",
+ "move.skip.externalModification": "Podczas przenoszenia wykryto zewnętrzną modyfikację",
+ "move.skip.invalidBackupTask": "Nieprawidłowe zadanie kopii zapasowej",
+ "move.skip.noOriginalCandidate": "Nie znaleziono pasującego oryginału",
+ "move.skip.unloading": "Wtyczka jest wyłączana",
+ "move.warning.externalModification": "Zewnętrzna modyfikacja wykryta podczas przenoszenia: {name}",
+ "backups.imagesFolder.name": "Folder kopii zapasowych obrazów",
+ "backups.imagesFolder.desc": "Otwórz folder z kopiami zapasowymi oryginalnych/skompresowanych obrazów",
+ "backups.imagesFolder.openButton": "Otwórz folder kopii zapasowych obrazów",
+ "backups.imagesFolder.openError": "Nie udało się otworzyć folderu kopii zapasowych obrazów",
+ "backups.imagesFolder.clearName": "Wyczyść kopie zapasowe obrazów",
+ "backups.imagesFolder.clearDesc": "Usuń kopie zapasowe przeniesionych oryginalnych/skompresowanych obrazów",
+ "backups.imagesFolder.clearButton": "Wyczyść kopie zapasowe obrazów",
+ "backups.imagesFolder.notFound": "Nie znaleziono folderu kopii zapasowych",
+ "backups.imagesFolder.noneToDelete": "Brak kopii zapasowych do usunięcia",
+ "backups.imagesFolder.deletedCount": "Usunięte zestawy kopii zapasowych obrazów: {count}",
+ "backups.imagesFolder.clearError": "Błąd podczas czyszczenia kopii zapasowych",
+ "backups.cache.title": "Kopie zapasowe pamięci podręcznej",
+ "backups.cache.folder.name": "Folder kopii zapasowych pamięci podręcznej",
+ "backups.cache.folder.desc": "Otwórz folder z plikami kopii zapasowych pamięci podręcznej",
+ "backups.cache.folder.openButton": "Otwórz folder kopii zapasowych pamięci podręcznej",
+ "backups.pathLabel": "Ścieżka",
+ "backups.foundLabel": "Znaleziono kopie zapasowe",
+ "backups.cache.none": "Brak dostępnych kopii zapasowych pamięci podręcznej",
+ "backups.cache.restore": "Przywróć pamięć podręczną z kopii zapasowej",
+ "backups.cache.available": "Dostępne kopie zapasowe:",
+ "backups.cache.selectPlaceholder": "-- Wybierz kopię zapasową --",
+ "common.add": "Dodaj",
+ "common.refresh": "Odśwież",
+ "common.refreshing": "Odświeżanie...",
+ "common.processing": "Przetwarzanie...",
+ "common.clearCache": "Wyczyść pamięć podręczną",
+ "common.refreshCache": "Odśwież pamięć podręczną",
+ "common.clear": "Wyczyść",
+ "common.clearing": "Czyszczenie...",
+ "common.cancel": "Anuluj",
+ "common.close": "Zamknij",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Sposób użycia:",
+ "instructions.notesTitle": "Uwagi:",
+ "instructions.notes.saved": "Skompresowane pliki są zapisywane w folderze",
+ "instructions.notes.originalUnchanged": "Oryginalne pliki nie są modyfikowane",
+ "instructions.notes.recompressionSkipped": "Ponowna kompresja jest pomijana dzięki pamięci podręcznej",
+ "progress.start": "Uruchamianie...",
+ "progress.processing": "Przetwarzanie",
+ "progress.skippedAlready": "Pominięto (plik już skompresowany)",
+ "progress.skipped": "Pominięto",
+ "progress.compressed": "Skompresowano",
+ "progress.error": "Błąd",
+ "progress.cancelling": "Anulowanie...",
+ "progress.cancelled": "Anulowano",
+ "status.loading": "Wczytywanie...",
+ "status.indexing": "Indeksowanie obrazów...",
+ "progress.completed": "Zakończono",
+ "folders.noneInVault": "W sejfie nie znaleziono folderów",
+ "folderSelect.title": "Wybierz folder do kompresji obrazów",
+ "folderSelect.selectLabel": "Folder",
+ "folderSelect.root": "Folder główny",
+ "folderSelect.select": "Wybierz",
+ "folderSelect.cancel": "Anuluj",
+ "instructions.action.rightClick": "Kliknij obraz prawym przyciskiem myszy →",
+ "instructions.action.commandPalette": "Lista poleceń →",
+ "savings.original": "Oryginał",
+ "savings.current": "Aktualny",
+ "savings.saved": "Zaoszczędzono",
+ "tooltip.savings.header": "Szczegóły dotyczące oszczędności miejsca",
+ "tooltip.savings.original": "Rozmiar oryginalny:",
+ "tooltip.savings.current": "Aktualny rozmiar:",
+ "tooltip.savings.saved": "Zaoszczędzone miejsce:",
+ "tooltip.savings.filesProcessed": "Przetworzone pliki:",
+ "tooltip.savings.estimated": "Szacowane pliki"
+}
diff --git a/src-ts/locales/pt-br.json b/src-ts/locales/pt-br.json
new file mode 100644
index 0000000..d9b3c6b
--- /dev/null
+++ b/src-ts/locales/pt-br.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Configurações",
+ "settings.loadFailed": "Não foi possível carregar as configurações; foram usados os valores padrão",
+ "init.failed": "Falha na inicialização do plug-in. Recarregue o plugin após corrigir o erro relatado.",
+ "migration.partialFailure": "A migração de dados do plug-in legado foi concluída com erros. Verifique o console do desenvolvedor.",
+ "i18n.externalLoadFailed": "Não foi possível carregar o arquivo de idioma externo",
+ "warning.wasmInitFailed": "Não foi possível inicializar os módulos WebAssembly. Recarregue o plugin ou informe um erro.",
+ "command.compressInNote": "Comprimir todas as imagens na nota",
+ "command.compressInFolder": "Comprimir todas as imagens na pasta",
+ "command.compressAll": "Comprimir todas as imagens no cofre",
+ "command.moveCompressed": "Mover arquivos comprimidos",
+ "context.compressImage": "Comprimir imagem",
+ "context.compressImagesInFolder": "Comprimir imagens na pasta",
+ "notice.cacheUpdated": "Cache atualizado",
+ "notice.cacheCleared": "Cache limpo",
+ "notice.compressionDeferredDueToMove": "A compressão é adiada enquanto uma operação de movimentação está em andamento",
+ "notice.operationFailed": "A operação falhou. Verifique o console do desenvolvedor.",
+ "validation.pathNotAllowed": "O caminho não é permitido pelas raízes selecionadas",
+ "validation.outputFolder": "O arquivo está dentro da pasta de saída",
+ "validation.alreadyCompressed": "O arquivo já está comprimido",
+ "validation.tooSmall": "O arquivo é muito pequeno",
+ "validation.bytes": "bytes",
+ "compress.error.fileAccess": "Não é possível acessar o arquivo",
+ "compress.error.unknown": "erro desconhecido",
+ "compress.error.tooLarge": "A imagem é muito grande para ser comprimida com segurança",
+ "compress.error.notSmaller": "O arquivo comprimido não é menor que o original",
+ "compress.error.unsupportedFormat": "Formato de arquivo não suportado",
+ "compress.error.copyCompressed": "Não foi possível copiar o arquivo comprimido",
+ "compress.error.copyCompressedJpeg": "Não foi possível copiar o arquivo JPEG comprimido",
+ "compress.error.pngQuality": "O codificador PNG não pôde atender à faixa de qualidade configurada",
+ "section.quality": "Qualidade de compressão",
+ "section.paths": "Caminhos",
+ "section.automation": "Automação",
+ "section.stats": "Estatísticas e cache",
+ "section.move": "Mover arquivos comprimidos",
+ "section.cacheBackups": "Backups de cache",
+ "section.instructions": "Instruções",
+ "quality.png.name": "Qualidade PNG (mín-máx)",
+ "quality.png.desc": "Faixa de qualidade para compressão PNG (65-80 por padrão)",
+ "quality.jpeg.name": "Qualidade JPEG",
+ "quality.jpeg.desc": "Qualidade de compressão JPEG (1-95)",
+ "paths.allowedRoots.name": "Pastas raiz permitidas",
+ "paths.allowedRoots.desc": "Somente as pastas selecionadas e suas subpastas são processadas (lista vazia = todos os caminhos)",
+ "paths.allowedRoots.empty": "Não selecionado (comprimir em todos os lugares)",
+ "paths.allowedRoots.pill.remove": "Clique para remover",
+ "paths.allowedRoots.modal.placeholder": "Selecione uma pasta...",
+ "paths.allowedRoots.cannotAddRoot": "Deixe a lista vazia para permitir todas as pastas",
+ "paths.allowedRoots.clear": "Limpar lista",
+ "paths.output.name": "Pasta de saída",
+ "paths.output.desc": "Nome da pasta para armazenar arquivos comprimidos",
+ "auto.newFiles.name": "Comprimir automaticamente novos arquivos",
+ "auto.newFiles.desc": "Comprimir automaticamente novas imagens quando adicionadas ao cofre",
+ "auto.bg.name": "Compressão automática em segundo plano",
+ "auto.bg.desc": "Comprimir automaticamente em segundo plano quando você estiver inativo",
+ "auto.bg.threshold.name": "Limite de compressão em segundo plano",
+ "auto.bg.threshold.desc": "Número de imagens não comprimidas necessário para iniciar automaticamente a compressão em segundo plano",
+ "auto.bg.inactivity.name": "Limite de inatividade (minutos)",
+ "auto.bg.inactivity.desc": "Minutos sem atividade do usuário antes do início da compressão em segundo plano",
+ "guard.disabled": "{id} foi temporariamente desativado durante a compressão",
+ "guard.restored": "{id} foi reativado após a compressão",
+ "auto.retention.toggle.name": "Excluir automaticamente backups de imagens",
+ "auto.retention.toggle.desc": "Excluir backups de imagens por período de retenção",
+ "auto.retention.days.name": "Retenção de backups (dias)",
+ "auto.retention.days.desc": "Exclua backups anteriores ao número de dias especificado",
+ "auto.move.toggle.name": "Habilitar a movimentação automática de arquivos comprimidos",
+ "auto.move.toggle.desc": "Mover arquivos comprimidos automaticamente quando o limite for atingido",
+ "auto.move.threshold.name": "Limite de movimentação automática (arquivos)",
+ "auto.move.threshold.desc": "Número de arquivos em 'Compressed' necessário para iniciar a movimentação automática",
+ "auto.queueFull": "A fila de compressão automática está cheia ({max}); novos arquivos foram ignorados",
+ "background.starting": "Compressão em segundo plano iniciada. Imagens: {count}",
+ "background.finished": "Compressão em segundo plano concluída. Imagens comprimidas: {count}",
+ "stats.uncompressed.name": "Imagens não comprimidas",
+ "stats.uncompressed.ready": "Imagens prontas para comprimir",
+ "stats.cache.name": "Entradas de cache",
+ "stats.cache.entries": "Entradas",
+ "stats.cache.size": "tamanho",
+ "cache.corruptSaved": "O cache não pôde ser lido. Uma cópia de recuperação foi salva:",
+ "move.title": "Mover arquivos comprimidos",
+ "move.ready": "Arquivos prontos para mover",
+ "move.button": "Mover",
+ "move.noCompressedFolder": "Pasta Compressed não encontrada",
+ "move.noneToMove": "Nenhum arquivo comprimido para mover",
+ "move.noneWithOriginals": "Nenhum arquivo comprimido cujo original esteja em uma pasta raiz permitida",
+ "move.backupCreated": "Foi criado um backup dos arquivos originais e comprimidos",
+ "move.backup.createdCount": "Conjuntos de arquivos originais/comprimidos incluídos no backup: {count}",
+ "move.skip.ambiguousOriginal": "Nome de arquivo original ambíguo",
+ "move.skip.originalMissingBeforeBackup": "Original faltando antes do backup",
+ "move.skip.compressedMissingBeforeBackup": "Arquivo comprimido ausente antes do backup",
+ "move.skip.originalModifiedDuringBackup": "Original alterado durante o backup",
+ "move.skip.compressedModifiedDuringBackup": "Arquivo comprimido alterado durante o backup",
+ "move.skip.originalContentChangedDuringBackup": "Conteúdo do arquivo original alterado durante o backup",
+ "move.skip.compressedContentChangedDuringBackup": "Conteúdo do arquivo comprimido alterado durante o backup",
+ "move.skip.contentChangedDuringCopy": "Conteúdo alterado enquanto o backup estava sendo copiado",
+ "move.skip.originalNotFoundAtMoveTime": "Original não encontrado no momento da movimentação",
+ "move.skip.selfMove": "O caminho do arquivo comprimido corresponde ao caminho do arquivo original",
+ "move.skip.externalModification": "Modificação externa detectada durante a movimentação",
+ "move.skip.invalidBackupTask": "Tarefa de backup inválida",
+ "move.skip.noOriginalCandidate": "Nenhum arquivo original correspondente foi encontrado",
+ "move.skip.unloading": "Plugin sendo desativado",
+ "move.warning.externalModification": "Modificação externa detectada durante a movimentação: {name}",
+ "backups.imagesFolder.name": "Pasta de backups de imagens",
+ "backups.imagesFolder.desc": "Abra a pasta com backups de imagens originais/comprimidas",
+ "backups.imagesFolder.openButton": "Abra a pasta de backups de imagens",
+ "backups.imagesFolder.openError": "Falha ao abrir a pasta de backups de imagens",
+ "backups.imagesFolder.clearName": "Limpar backups de imagens",
+ "backups.imagesFolder.clearDesc": "Exclua backups de imagens originais/comprimidas movidas",
+ "backups.imagesFolder.clearButton": "Limpar backups de imagens",
+ "backups.imagesFolder.notFound": "Pasta de backups não encontrada",
+ "backups.imagesFolder.noneToDelete": "Nenhum backup para excluir",
+ "backups.imagesFolder.deletedCount": "Conjuntos de backups de imagens excluídos: {count}",
+ "backups.imagesFolder.clearError": "Erro ao limpar backups",
+ "backups.cache.title": "Backups de cache",
+ "backups.cache.folder.name": "Pasta de backups de cache",
+ "backups.cache.folder.desc": "Pasta que contém os arquivos de backup do cache",
+ "backups.cache.folder.openButton": "Abra a pasta de backups de cache",
+ "backups.pathLabel": "Caminho",
+ "backups.foundLabel": "Backups encontrados",
+ "backups.cache.none": "Nenhum backup de cache disponível",
+ "backups.cache.restore": "Restaurar cache do backup",
+ "backups.cache.available": "Backups disponíveis:",
+ "backups.cache.selectPlaceholder": "-- Selecione backup --",
+ "common.add": "Adicionar",
+ "common.refresh": "Atualizar",
+ "common.refreshing": "Atualizando...",
+ "common.processing": "Processando...",
+ "common.clearCache": "Limpar cache",
+ "common.refreshCache": "Atualizar cache",
+ "common.clear": "Limpar",
+ "common.clearing": "Limpando...",
+ "common.cancel": "Cancelar",
+ "common.close": "Fechar",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Uso:",
+ "instructions.notesTitle": "Notas:",
+ "instructions.notes.saved": "Os arquivos comprimidos são salvos em",
+ "instructions.notes.originalUnchanged": "Os arquivos originais não são modificados",
+ "instructions.notes.recompressionSkipped": "A recompressão é ignorada graças ao cache",
+ "progress.start": "Começando...",
+ "progress.processing": "Processamento",
+ "progress.skippedAlready": "Ignorado (já comprimido)",
+ "progress.skipped": "Ignorado",
+ "progress.compressed": "Comprimido",
+ "progress.error": "Erro",
+ "progress.cancelling": "Cancelando...",
+ "progress.cancelled": "Cancelado",
+ "status.loading": "Carregando...",
+ "status.indexing": "Indexando imagens...",
+ "progress.completed": "Concluído",
+ "folders.noneInVault": "Nenhuma pasta encontrada no cofre",
+ "folderSelect.title": "Selecione uma pasta para compressão de imagens",
+ "folderSelect.selectLabel": "Pasta",
+ "folderSelect.root": "Pasta raiz",
+ "folderSelect.select": "Selecione",
+ "folderSelect.cancel": "Cancelar",
+ "instructions.action.rightClick": "Clique com o botão direito em uma imagem →",
+ "instructions.action.commandPalette": "Paleta de comandos →",
+ "savings.original": "Original",
+ "savings.current": "Atual",
+ "savings.saved": "Economizado",
+ "tooltip.savings.header": "Detalhes de economia de espaço",
+ "tooltip.savings.original": "Tamanho original:",
+ "tooltip.savings.current": "Tamanho atual:",
+ "tooltip.savings.saved": "Espaço economizado:",
+ "tooltip.savings.filesProcessed": "Arquivos processados:",
+ "tooltip.savings.estimated": "Arquivos estimados"
+}
diff --git a/src-ts/locales/pt.json b/src-ts/locales/pt.json
new file mode 100644
index 0000000..ad5d58d
--- /dev/null
+++ b/src-ts/locales/pt.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Definições",
+ "settings.loadFailed": "Não foi possível carregar as definições; foram usados os valores predefinidos",
+ "init.failed": "Falha na inicialização do plug-in. Recarregue o plugin após corrigir o erro reportado.",
+ "migration.partialFailure": "A migração de dados antigos do plugin foi concluída com erros. Consulte a consola do programador.",
+ "i18n.externalLoadFailed": "Não foi possível carregar o ficheiro de idioma externo",
+ "warning.wasmInitFailed": "Não foi possível inicializar os módulos WebAssembly. Recarregue o plugin ou comunique um erro.",
+ "command.compressInNote": "Comprimir todas as imagens na nota",
+ "command.compressInFolder": "Comprimir todas as imagens na pasta",
+ "command.compressAll": "Comprimir todas as imagens no cofre",
+ "command.moveCompressed": "Mover ficheiros comprimidos",
+ "context.compressImage": "Comprimir imagem",
+ "context.compressImagesInFolder": "Comprimir imagens na pasta",
+ "notice.cacheUpdated": "Cache atualizada",
+ "notice.cacheCleared": "Cache limpa",
+ "notice.compressionDeferredDueToMove": "A compressão é adiada enquanto uma operação de movimentação está em curso",
+ "notice.operationFailed": "A operação falhou. Consulte a consola do programador.",
+ "validation.pathNotAllowed": "O caminho não é permitido pelas raízes selecionadas",
+ "validation.outputFolder": "O ficheiro está dentro da pasta de saída",
+ "validation.alreadyCompressed": "O ficheiro já está comprimido",
+ "validation.tooSmall": "O ficheiro é muito pequeno",
+ "validation.bytes": "bytes",
+ "compress.error.fileAccess": "Não é possível aceder ao ficheiro",
+ "compress.error.unknown": "erro desconhecido",
+ "compress.error.tooLarge": "A imagem é demasiado grande para ser comprimida com segurança",
+ "compress.error.notSmaller": "O ficheiro comprimido não é menor que o original",
+ "compress.error.unsupportedFormat": "Formato de ficheiro não suportado",
+ "compress.error.copyCompressed": "Não foi possível copiar o ficheiro comprimido",
+ "compress.error.copyCompressedJpeg": "Não foi possível copiar o ficheiro JPEG comprimido",
+ "compress.error.pngQuality": "O codificador PNG não conseguiu cumprir a gama de qualidade configurada",
+ "section.quality": "Qualidade de compressão",
+ "section.paths": "Caminhos",
+ "section.automation": "Automação",
+ "section.stats": "Estatísticas e cache",
+ "section.move": "Mover ficheiros comprimidos",
+ "section.cacheBackups": "Backups de cache",
+ "section.instructions": "Instruções",
+ "quality.png.name": "Qualidade PNG (mín-máx)",
+ "quality.png.desc": "Gama de qualidade para compressão PNG (65-80 por defeito)",
+ "quality.jpeg.name": "Qualidade JPEG",
+ "quality.jpeg.desc": "Qualidade de compressão JPEG (1-95)",
+ "paths.allowedRoots.name": "Pastas raiz permitidas",
+ "paths.allowedRoots.desc": "Apenas as pastas selecionadas e as respetivas subpastas são processadas (lista vazia = todos os caminhos)",
+ "paths.allowedRoots.empty": "Não selecionado (comprimir em todo o lado)",
+ "paths.allowedRoots.pill.remove": "Clique para remover",
+ "paths.allowedRoots.modal.placeholder": "Selecione uma pasta...",
+ "paths.allowedRoots.cannotAddRoot": "Não é possível adicionar a pasta do cofre como raiz permitida. Deixe a lista vazia para permitir todas as pastas.",
+ "paths.allowedRoots.clear": "Limpar lista",
+ "paths.output.name": "Pasta de saída",
+ "paths.output.desc": "Nome da pasta para armazenar ficheiros comprimidos",
+ "auto.newFiles.name": "Comprimir automaticamente novos ficheiros",
+ "auto.newFiles.desc": "Comprimir automaticamente novas imagens quando adicionadas ao Vault",
+ "auto.bg.name": "Compressão automática em segundo plano",
+ "auto.bg.desc": "Comprimir automaticamente em segundo plano quando estiver inativo",
+ "auto.bg.threshold.name": "Limite de compressão em segundo plano",
+ "auto.bg.threshold.desc": "Número de imagens não comprimidas necessário para iniciar automaticamente a compressão em segundo plano",
+ "auto.bg.inactivity.name": "Limite de inatividade (minutos)",
+ "auto.bg.inactivity.desc": "Minutos sem atividade do utilizador antes do início da compressão em segundo plano",
+ "guard.disabled": "{id} foi temporariamente desativado durante a compressão",
+ "guard.restored": "{id} foi reativado após a compressão",
+ "auto.retention.toggle.name": "Apagar automaticamente backups de imagens",
+ "auto.retention.toggle.desc": "Apagar backups de imagens por período de retenção",
+ "auto.retention.days.name": "Retenção de cópias de segurança (dias)",
+ "auto.retention.days.desc": "Apague os backups anteriores ao número de dias especificado",
+ "auto.move.toggle.name": "Ative a movimentação automática de ficheiros comprimidos",
+ "auto.move.toggle.desc": "Mover ficheiros comprimidos automaticamente quando o limite for atingido",
+ "auto.move.threshold.name": "Limite de movimentação automática (ficheiros)",
+ "auto.move.threshold.desc": "Número de ficheiros em 'Compressed' necessário para iniciar a movimentação automática",
+ "auto.queueFull": "A fila de compressão automática está cheia ({max}); novos ficheiros foram ignorados",
+ "background.starting": "Compressão em segundo plano iniciada. Imagens: {count}",
+ "background.finished": "Compressão em segundo plano concluída. Imagens comprimidas: {count}",
+ "stats.uncompressed.name": "Imagens não comprimidas",
+ "stats.uncompressed.ready": "Imagens prontas para comprimir",
+ "stats.cache.name": "Entradas de cache",
+ "stats.cache.entries": "Entradas",
+ "stats.cache.size": "tamanho",
+ "cache.corruptSaved": "A cache não pôde ser lida. Foi guardada uma cópia de recuperação:",
+ "move.title": "Mover ficheiros comprimidos",
+ "move.ready": "Ficheiros prontos para mover",
+ "move.button": "Mover",
+ "move.noCompressedFolder": "Pasta Compressed não encontrada",
+ "move.noneToMove": "Nenhum ficheiro comprimido para mover",
+ "move.noneWithOriginals": "Nenhum ficheiro comprimido com originais em raízes permitidas",
+ "move.backupCreated": "Foi criada uma cópia de segurança dos ficheiros originais e comprimidos",
+ "move.backup.createdCount": "Conjuntos de ficheiros originais/comprimidos incluídos na cópia de segurança: {count}",
+ "move.skip.ambiguousOriginal": "Nome de ficheiro original ambíguo",
+ "move.skip.originalMissingBeforeBackup": "Original em falta antes do backup",
+ "move.skip.compressedMissingBeforeBackup": "Ficheiro comprimido em falta antes da cópia de segurança",
+ "move.skip.originalModifiedDuringBackup": "Original alterado durante o backup",
+ "move.skip.compressedModifiedDuringBackup": "Ficheiro comprimido alterado durante a cópia de segurança",
+ "move.skip.originalContentChangedDuringBackup": "Conteúdo do ficheiro original alterado durante a cópia de segurança",
+ "move.skip.compressedContentChangedDuringBackup": "Conteúdo do ficheiro comprimido alterado durante a cópia de segurança",
+ "move.skip.contentChangedDuringCopy": "Conteúdo alterado enquanto o backup estava a ser copiado",
+ "move.skip.originalNotFoundAtMoveTime": "Original não encontrado no momento da movimentação",
+ "move.skip.selfMove": "O caminho do ficheiro comprimido corresponde ao caminho do ficheiro original",
+ "move.skip.externalModification": "Modificação externa detetada durante a movimentação",
+ "move.skip.invalidBackupTask": "Tarefa de cópia de segurança inválida",
+ "move.skip.noOriginalCandidate": "Não foi encontrado um ficheiro original correspondente",
+ "move.skip.unloading": "Plugin a ser desativado",
+ "move.warning.externalModification": "Modificação externa detetada durante a movimentação: {name}",
+ "backups.imagesFolder.name": "Pasta de cópias de segurança de imagens",
+ "backups.imagesFolder.desc": "Abra a pasta com cópias de segurança de imagens originais/comprimidas",
+ "backups.imagesFolder.openButton": "Abra a pasta de cópias de segurança de imagens",
+ "backups.imagesFolder.openError": "Falha ao abrir a pasta de cópias de segurança de imagens",
+ "backups.imagesFolder.clearName": "Limpar cópias de segurança de imagens",
+ "backups.imagesFolder.clearDesc": "Apagar backups de imagens originais/comprimidas movidas",
+ "backups.imagesFolder.clearButton": "Limpar cópias de segurança de imagens",
+ "backups.imagesFolder.notFound": "Pasta de backups não encontrada",
+ "backups.imagesFolder.noneToDelete": "Não há cópias de segurança para eliminar",
+ "backups.imagesFolder.deletedCount": "Conjuntos de cópias de segurança de imagens eliminados: {count}",
+ "backups.imagesFolder.clearError": "Erro ao limpar backups",
+ "backups.cache.title": "Backups de cache",
+ "backups.cache.folder.name": "Pasta de backups de cache",
+ "backups.cache.folder.desc": "Pasta que contém os ficheiros de cópia de segurança da cache",
+ "backups.cache.folder.openButton": "Abra a pasta de backups de cache",
+ "backups.pathLabel": "Caminho",
+ "backups.foundLabel": "Backups encontrados",
+ "backups.cache.none": "Nenhum backup de cache disponível",
+ "backups.cache.restore": "Restaurar cache a partir do backup",
+ "backups.cache.available": "Backups disponíveis:",
+ "backups.cache.selectPlaceholder": "-- Selecione cópia de segurança --",
+ "common.add": "Adicionar",
+ "common.refresh": "Atualizar",
+ "common.refreshing": "A atualizar...",
+ "common.processing": "Processamento...",
+ "common.clearCache": "Limpar cache",
+ "common.refreshCache": "Atualizar cache",
+ "common.clear": "Limpar",
+ "common.clearing": "A limpar...",
+ "common.cancel": "Cancelar",
+ "common.close": "Fechar",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Uso:",
+ "instructions.notesTitle": "Notas:",
+ "instructions.notes.saved": "Os ficheiros comprimidos são guardados em",
+ "instructions.notes.originalUnchanged": "Os ficheiros originais não são modificados",
+ "instructions.notes.recompressionSkipped": "A recompressão é ignorada graças à cache",
+ "progress.start": "A iniciar...",
+ "progress.processing": "Processamento",
+ "progress.skippedAlready": "Ignorado (já comprimido)",
+ "progress.skipped": "Ignorado",
+ "progress.compressed": "Comprimido",
+ "progress.error": "Erro",
+ "progress.cancelling": "A cancelar...",
+ "progress.cancelled": "Cancelado",
+ "status.loading": "A carregar...",
+ "status.indexing": "A indexar imagens...",
+ "progress.completed": "Concluído",
+ "folders.noneInVault": "Nenhuma pasta encontrada no cofre",
+ "folderSelect.title": "Selecione uma pasta para compressão de imagens",
+ "folderSelect.selectLabel": "Pasta",
+ "folderSelect.root": "Pasta raiz",
+ "folderSelect.select": "Selecione",
+ "folderSelect.cancel": "Cancelar",
+ "instructions.action.rightClick": "Clique com o botão direito do rato numa imagem →",
+ "instructions.action.commandPalette": "Paleta de comando →",
+ "savings.original": "Original",
+ "savings.current": "Atual",
+ "savings.saved": "Poupado",
+ "tooltip.savings.header": "Detalhes de poupança de espaço",
+ "tooltip.savings.original": "Tamanho original:",
+ "tooltip.savings.current": "Tamanho atual:",
+ "tooltip.savings.saved": "Espaço poupado:",
+ "tooltip.savings.filesProcessed": "Ficheiros processados:",
+ "tooltip.savings.estimated": "Ficheiros estimados"
+}
diff --git a/src-ts/locales/ru.json b/src-ts/locales/ru.json
new file mode 100644
index 0000000..10e1efd
--- /dev/null
+++ b/src-ts/locales/ru.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Настройки",
+ "settings.loadFailed": "Не удалось загрузить настройки; использованы значения по умолчанию",
+ "init.failed": "Не удалось инициализировать плагин. Перезагрузите плагин после исправления ошибки.",
+ "migration.partialFailure": "Перенос данных старой версии завершился с ошибками. Проверьте консоль разработчика.",
+ "i18n.externalLoadFailed": "Не удалось загрузить внешний языковой файл",
+ "warning.wasmInitFailed": "Не удалось инициализировать модули WebAssembly. Перезагрузите плагин или сообщите об ошибке.",
+ "command.compressInNote": "Сжать все изображения в заметке",
+ "command.compressInFolder": "Сжать все изображения в папке",
+ "command.compressAll": "Сжать все изображения в хранилище",
+ "command.moveCompressed": "Переместить сжатые файлы",
+ "context.compressImage": "Сжать изображение",
+ "context.compressImagesInFolder": "Сжать изображения в папке",
+ "notice.cacheUpdated": "Кэш обновлен",
+ "notice.cacheCleared": "Кэш очищен",
+ "notice.compressionDeferredDueToMove": "Сжатие отложено, пока выполняется перенос файлов",
+ "notice.operationFailed": "Операция не выполнена. Проверьте консоль разработчика.",
+ "validation.pathNotAllowed": "Путь не разрешён в настройках",
+ "validation.outputFolder": "Файл находится в папке для сжатых файлов",
+ "validation.alreadyCompressed": "Файл уже сжат",
+ "validation.tooSmall": "Файл слишком маленький",
+ "validation.bytes": "байт",
+ "compress.error.fileAccess": "Невозможно получить доступ к файлу",
+ "compress.error.unknown": "неизвестная ошибка",
+ "compress.error.tooLarge": "Изображение слишком большое для безопасного сжатия",
+ "compress.error.notSmaller": "Сжатый файл не меньше оригинала",
+ "compress.error.unsupportedFormat": "Неподдерживаемый формат файла",
+ "compress.error.copyCompressed": "Не удалось скопировать сжатый файл",
+ "compress.error.copyCompressedJpeg": "Не удалось скопировать сжатый JPEG-файл",
+ "compress.error.pngQuality": "PNG-кодек не смог обеспечить заданный диапазон качества",
+ "section.quality": "Качество сжатия",
+ "section.paths": "Пути",
+ "section.automation": "Автоматизация",
+ "section.stats": "Статистика и кэш",
+ "section.move": "Перемещение сжатых файлов",
+ "section.cacheBackups": "Бэкапы кэша",
+ "section.instructions": "Инструкции",
+ "quality.png.name": "Качество PNG (мин-макс)",
+ "quality.png.desc": "Диапазон качества сжатия PNG-файлов (по умолчанию 65–80)",
+ "quality.jpeg.name": "Качество JPEG",
+ "quality.jpeg.desc": "Качество сжатия JPEG-файлов (1–95)",
+ "paths.allowedRoots.name": "Разрешённые корневые папки",
+ "paths.allowedRoots.desc": "Выберите папки, где разрешено сжатие (пусто = все пути)",
+ "paths.allowedRoots.empty": "Не выбрано (сжимаем везде)",
+ "paths.allowedRoots.pill.remove": "Нажмите, чтобы удалить",
+ "paths.allowedRoots.modal.placeholder": "Выберите папку...",
+ "paths.allowedRoots.cannotAddRoot": "Оставьте список пустым, чтобы разрешить все папки",
+ "paths.allowedRoots.clear": "Очистить список",
+ "paths.output.name": "Выходная папка",
+ "paths.output.desc": "Имя папки для сохранения сжатых файлов",
+ "auto.newFiles.name": "Автосжатие новых файлов",
+ "auto.newFiles.desc": "Автоматически сжимать новые изображения при добавлении в хранилище",
+ "auto.bg.name": "Автоматическое фоновое сжатие",
+ "auto.bg.desc": "Автоматически сжимать в фоне при отсутствии активности пользователя",
+ "auto.bg.threshold.name": "Порог для фонового сжатия",
+ "auto.bg.threshold.desc": "Количество несжатых изображений, при котором автоматически запускается фоновое сжатие",
+ "guard.disabled": "{id} временно отключён на время сжатия",
+ "guard.restored": "{id} снова включён после сжатия",
+ "auto.retention.toggle.name": "Автоудаление бэкапов изображений",
+ "auto.retention.toggle.desc": "Удалять бэкапы изображений по сроку хранения",
+ "auto.retention.days.name": "Срок хранения бэкапов (дней)",
+ "auto.retention.days.desc": "Удалять бэкапы, которым больше указанного количества дней",
+ "auto.move.toggle.name": "Автоперемещение сжатых файлов",
+ "auto.move.toggle.desc": "Автоматически перемещать сжатые файлы при достижении порога",
+ "auto.move.threshold.name": "Порог автоперемещения (файлов)",
+ "auto.move.threshold.desc": "Сколько сжатых файлов в 'Compressed' нужно для автоперемещения",
+ "auto.queueFull": "Очередь автосжатия заполнена ({max}); новые файлы пропущены",
+ "background.starting": "Запущено фоновое сжатие. Изображений: {count}",
+ "background.finished": "Фоновое сжатие завершено. Сжато изображений: {count}",
+ "auto.bg.inactivity.name": "Порог бездействия (минуты)",
+ "auto.bg.inactivity.desc": "Сколько минут без действий пользователя ждать перед запуском фонового сжатия",
+ "stats.uncompressed.name": "Несжатые изображения",
+ "stats.uncompressed.ready": "Готово к сжатию",
+ "stats.cache.name": "Записи в кэше",
+ "stats.cache.entries": "Записи",
+ "stats.cache.size": "размер",
+ "cache.corruptSaved": "Кэш не удалось прочитать. Сохранена копия для восстановления:",
+ "move.title": "Переместить сжатые файлы",
+ "move.ready": "Готово к перемещению",
+ "move.button": "Переместить",
+ "move.noCompressedFolder": "Папка Compressed не найдена",
+ "move.noneToMove": "Нет сжатых файлов для перемещения",
+ "move.noneWithOriginals": "Нет сжатых файлов, оригиналы которых находятся в разрешённых корневых папках",
+ "move.backupCreated": "Создан бэкап оригинальных и сжатых файлов",
+ "move.backup.createdCount": "Создано комплектов бэкапов оригинала и сжатого файла: {count}",
+ "move.skip.ambiguousOriginal": "Неоднозначное имя оригинала",
+ "move.skip.originalMissingBeforeBackup": "Оригинал отсутствует перед бэкапом",
+ "move.skip.compressedMissingBeforeBackup": "Сжатый файл отсутствует перед бэкапом",
+ "move.skip.originalModifiedDuringBackup": "Оригинал изменился во время бэкапа",
+ "move.skip.compressedModifiedDuringBackup": "Сжатый файл изменился во время бэкапа",
+ "move.skip.originalContentChangedDuringBackup": "Содержимое оригинала изменилось во время бэкапа",
+ "move.skip.compressedContentChangedDuringBackup": "Содержимое сжатого файла изменилось во время бэкапа",
+ "move.skip.contentChangedDuringCopy": "Содержимое изменилось во время копирования бэкапа",
+ "move.skip.originalNotFoundAtMoveTime": "Оригинал не найден во время перемещения",
+ "move.skip.selfMove": "Путь к сжатому файлу совпадает с путём к оригиналу",
+ "move.skip.externalModification": "Во время перемещения обнаружено внешнее изменение",
+ "move.skip.invalidBackupTask": "Некорректная задача бэкапа",
+ "move.skip.noOriginalCandidate": "Подходящий оригинал не найден",
+ "move.skip.unloading": "Плагин выгружается",
+ "move.warning.externalModification": "Обнаружено внешнее изменение во время перемещения: {name}",
+ "backups.imagesFolder.name": "Папка с бэкапами изображений",
+ "backups.imagesFolder.desc": "Открыть папку с бэкапами оригинальных/сжатых изображений",
+ "backups.imagesFolder.openButton": "Открыть папку бэкапов изображений",
+ "backups.imagesFolder.openError": "Не удалось открыть папку бэкапов изображений",
+ "backups.imagesFolder.clearName": "Очистить бэкапы изображений",
+ "backups.imagesFolder.clearDesc": "Удалить бэкапы перемещённых оригинальных/сжатых изображений",
+ "backups.imagesFolder.clearButton": "Очистить бэкапы изображений",
+ "backups.imagesFolder.notFound": "Папка бэкапов не найдена",
+ "backups.imagesFolder.noneToDelete": "Нет бэкапов для удаления",
+ "backups.imagesFolder.deletedCount": "Удалено наборов бэкапов изображений: {count}",
+ "backups.imagesFolder.clearError": "Ошибка при очистке бэкапов",
+ "backups.cache.title": "Бэкапы кэша",
+ "backups.cache.folder.name": "Папка бэкапов кэша",
+ "backups.cache.folder.desc": "Открыть папку с файлами бэкапов кэша",
+ "backups.cache.folder.openButton": "Открыть папку бэкапов кэша",
+ "backups.pathLabel": "Путь",
+ "backups.foundLabel": "Найдено бэкапов",
+ "backups.cache.none": "Бэкапы кэша не найдены",
+ "backups.cache.restore": "Восстановить кэш из бэкапа",
+ "backups.cache.available": "Доступно бэкапов:",
+ "backups.cache.selectPlaceholder": "-- Выберите бэкап --",
+ "common.add": "Добавить",
+ "common.refresh": "Обновить",
+ "common.refreshing": "Обновление...",
+ "common.processing": "Обработка...",
+ "common.clearCache": "Очистить кэш",
+ "common.refreshCache": "Обновить кэш",
+ "common.clear": "Очистить",
+ "common.clearing": "Очистка...",
+ "common.cancel": "Отмена",
+ "common.close": "Закрыть",
+ "units.kb": "КБ",
+ "instructions.usageTitle": "Использование:",
+ "instructions.notesTitle": "Примечания:",
+ "instructions.notes.saved": "Сжатые файлы сохраняются в папку",
+ "instructions.notes.originalUnchanged": "Оригинальные файлы не изменяются",
+ "instructions.notes.recompressionSkipped": "Повторное сжатие пропускается благодаря кэшу",
+ "progress.start": "Начинаем обработку...",
+ "progress.processing": "Обработка",
+ "progress.skippedAlready": "Пропущен (уже сжат)",
+ "progress.skipped": "Пропущен",
+ "progress.compressed": "Сжат",
+ "progress.error": "Ошибка",
+ "progress.cancelling": "Отмена...",
+ "progress.cancelled": "Отменено",
+ "status.loading": "Загрузка...",
+ "status.indexing": "Индексация изображений...",
+ "progress.completed": "Завершено",
+ "folders.noneInVault": "В хранилище нет папок",
+ "folderSelect.title": "Выберите папку для сжатия изображений",
+ "folderSelect.selectLabel": "Папка",
+ "folderSelect.root": "Корневая папка",
+ "folderSelect.select": "Выбрать",
+ "folderSelect.cancel": "Отмена",
+ "instructions.action.rightClick": "Щёлкните изображение правой кнопкой мыши →",
+ "instructions.action.commandPalette": "Палитра команд →",
+ "savings.original": "Оригинал",
+ "savings.current": "Текущий",
+ "savings.saved": "Сэкономлено",
+ "tooltip.savings.header": "Детали экономии места",
+ "tooltip.savings.original": "Исходный размер:",
+ "tooltip.savings.current": "Текущий размер:",
+ "tooltip.savings.saved": "Экономия места:",
+ "tooltip.savings.filesProcessed": "Обработано файлов:",
+ "tooltip.savings.estimated": "Оценено файлов"
+}
diff --git a/src-ts/locales/th.json b/src-ts/locales/th.json
new file mode 100644
index 0000000..a327da6
--- /dev/null
+++ b/src-ts/locales/th.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "การตั้งค่า",
+ "settings.loadFailed": "ไม่สามารถโหลดการตั้งค่าได้ มีการใช้ค่าเริ่มต้น",
+ "init.failed": "การเริ่มต้นปลั๊กอินล้มเหลว โหลดปลั๊กอินใหม่หลังจากแก้ไขข้อผิดพลาดที่รายงานแล้ว",
+ "migration.partialFailure": "การย้ายข้อมูลปลั๊กอินแบบเดิมเสร็จสมบูรณ์โดยมีข้อผิดพลาด ตรวจสอบคอนโซลนักพัฒนาซอฟต์แวร์",
+ "i18n.externalLoadFailed": "ไม่สามารถโหลดไฟล์ภาษาภายนอกได้",
+ "warning.wasmInitFailed": "โมดูล WebAssembly ไม่สามารถเริ่มต้นได้ โปรดโหลดปลั๊กอินซ้ำหรือรายงานข้อผิดพลาด",
+ "command.compressInNote": "บีบอัดรูปภาพทั้งหมดในโน้ต",
+ "command.compressInFolder": "บีบอัดรูปภาพทั้งหมดในโฟลเดอร์",
+ "command.compressAll": "บีบอัดภาพทั้งหมดในห้องนิรภัย",
+ "command.moveCompressed": "ย้ายไฟล์บีบอัด",
+ "context.compressImage": "บีบอัดรูปภาพ",
+ "context.compressImagesInFolder": "บีบอัดรูปภาพในโฟลเดอร์",
+ "notice.cacheUpdated": "อัปเดตแคชแล้ว",
+ "notice.cacheCleared": "ล้างแคชแล้ว",
+ "notice.compressionDeferredDueToMove": "การบีบอัดจะถูกเลื่อนออกไปในขณะที่กำลังดำเนินการย้าย",
+ "notice.operationFailed": "การดำเนินการล้มเหลว ตรวจสอบคอนโซลนักพัฒนาซอฟต์แวร์",
+ "validation.pathNotAllowed": "เส้นทางนี้ไม่ได้รับอนุญาตตามการตั้งค่า",
+ "validation.outputFolder": "ไฟล์อยู่ในโฟลเดอร์เอาต์พุต",
+ "validation.alreadyCompressed": "ไฟล์ถูกบีบอัดแล้ว",
+ "validation.tooSmall": "ไฟล์มีขนาดเล็กเกินไป",
+ "validation.bytes": "ไบต์",
+ "compress.error.fileAccess": "ไม่สามารถเข้าถึงไฟล์ได้",
+ "compress.error.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
+ "compress.error.tooLarge": "รูปภาพมีขนาดใหญ่เกินกว่าจะบีบอัดอย่างปลอดภัย",
+ "compress.error.notSmaller": "ไฟล์บีบอัดไม่เล็กกว่าเดิม",
+ "compress.error.unsupportedFormat": "รูปแบบไฟล์ที่ไม่รองรับ",
+ "compress.error.copyCompressed": "ไม่สามารถคัดลอกไฟล์บีบอัดได้",
+ "compress.error.copyCompressedJpeg": "ไม่สามารถคัดลอกไฟล์ JPEG ที่บีบอัดได้",
+ "compress.error.pngQuality": "ตัวเข้ารหัส PNG ไม่ตรงตามช่วงคุณภาพที่กำหนดไว้",
+ "section.quality": "คุณภาพการบีบอัด",
+ "section.paths": "เส้นทาง",
+ "section.automation": "ระบบอัตโนมัติ",
+ "section.stats": "สถิติและแคช",
+ "section.move": "ย้ายไฟล์บีบอัด",
+ "section.cacheBackups": "การสำรองข้อมูลแคช",
+ "section.instructions": "คำแนะนำ",
+ "quality.png.name": "คุณภาพ PNG (ต่ำสุด-สูงสุด)",
+ "quality.png.desc": "ช่วงคุณภาพสำหรับการบีบอัด PNG (65-80 โดยค่าเริ่มต้น)",
+ "quality.jpeg.name": "คุณภาพ JPEG",
+ "quality.jpeg.desc": "คุณภาพการบีบอัด JPEG (1-95)",
+ "paths.allowedRoots.name": "โฟลเดอร์รากที่อนุญาต",
+ "paths.allowedRoots.desc": "เลือกโฟลเดอร์ที่อนุญาตให้มีการบีบอัด (ว่าง = เส้นทางทั้งหมด)",
+ "paths.allowedRoots.empty": "ไม่ได้เลือก (บีบอัดทุกที่)",
+ "paths.allowedRoots.pill.remove": "คลิกเพื่อลบ",
+ "paths.allowedRoots.modal.placeholder": "เลือกโฟลเดอร์...",
+ "paths.allowedRoots.cannotAddRoot": "ปล่อยรายการว่างไว้เพื่ออนุญาตโฟลเดอร์ทั้งหมด",
+ "paths.allowedRoots.clear": "ล้างรายการ",
+ "paths.output.name": "โฟลเดอร์เอาท์พุต",
+ "paths.output.desc": "ชื่อโฟลเดอร์สำหรับจัดเก็บไฟล์บีบอัด",
+ "auto.newFiles.name": "บีบอัดไฟล์ใหม่อัตโนมัติ",
+ "auto.newFiles.desc": "บีบอัดรูปภาพใหม่โดยอัตโนมัติเมื่อเพิ่มลงในห้องนิรภัย",
+ "auto.bg.name": "การบีบอัดอัตโนมัติในเบื้องหลัง",
+ "auto.bg.desc": "บีบอัดอัตโนมัติในเบื้องหลังเมื่อไม่มีการใช้งาน",
+ "auto.bg.threshold.name": "เกณฑ์การบีบอัดในเบื้องหลัง",
+ "auto.bg.threshold.desc": "จำนวนรูปภาพที่ยังไม่บีบอัดซึ่งจะเริ่มการบีบอัดอัตโนมัติ",
+ "auto.bg.inactivity.name": "เกณฑ์การไม่ใช้งาน (นาที)",
+ "auto.bg.inactivity.desc": "จำนวนนาทีที่ไม่มีการใช้งานก่อนเริ่มการบีบอัดในเบื้องหลัง",
+ "guard.disabled": "{id} ถูกปิดการใช้งานชั่วคราวระหว่างการบีบอัด",
+ "guard.restored": "เปิดใช้งาน {id} อีกครั้งหลังการบีบอัด",
+ "auto.retention.toggle.name": "ลบการสำรองข้อมูลรูปภาพอัตโนมัติ",
+ "auto.retention.toggle.desc": "ลบการสำรองข้อมูลรูปภาพตามระยะเวลาการเก็บรักษา",
+ "auto.retention.days.name": "การเก็บรักษาข้อมูลสำรอง (วัน)",
+ "auto.retention.days.desc": "ลบข้อมูลสำรองที่เก่ากว่าจำนวนวันที่ระบุ",
+ "auto.move.toggle.name": "เปิดใช้งานการย้ายไฟล์บีบอัดอัตโนมัติ",
+ "auto.move.toggle.desc": "ย้ายไฟล์บีบอัดโดยอัตโนมัติเมื่อถึงเกณฑ์",
+ "auto.move.threshold.name": "เกณฑ์การย้ายอัตโนมัติ (ไฟล์)",
+ "auto.move.threshold.desc": "จำนวนไฟล์ใน 'Compressed' ที่จะทริกเกอร์การย้ายอัตโนมัติ",
+ "auto.queueFull": "คิวการบีบอัดอัตโนมัติเต็ม ({max}); ไฟล์ใหม่ถูกข้ามไป",
+ "background.starting": "เริ่มการบีบอัดในเบื้องหลังแล้ว จำนวนรูปภาพ: {count}",
+ "background.finished": "การบีบอัดในเบื้องหลังเสร็จสิ้น รูปภาพที่บีบอัด: {count}",
+ "stats.uncompressed.name": "ภาพที่ไม่มีการบีบอัด",
+ "stats.uncompressed.ready": "รูปภาพพร้อมบีบอัด",
+ "stats.cache.name": "รายการแคช",
+ "stats.cache.entries": "รายการ",
+ "stats.cache.size": "ขนาด",
+ "cache.corruptSaved": "ไม่สามารถอ่านแคชได้ บันทึกสำเนาการกู้คืนแล้ว:",
+ "move.title": "ย้ายไฟล์บีบอัด",
+ "move.ready": "ไฟล์พร้อมย้าย",
+ "move.button": "ย้าย",
+ "move.noCompressedFolder": "ไม่พบโฟลเดอร์ Compressed",
+ "move.noneToMove": "ไม่มีไฟล์บีบอัดที่จะย้าย",
+ "move.noneWithOriginals": "ไม่มีไฟล์บีบอัดที่มีไฟล์ต้นฉบับอยู่ในโฟลเดอร์รากที่อนุญาต",
+ "move.backupCreated": "สร้างข้อมูลสำรองของไฟล์ต้นฉบับและไฟล์บีบอัดแล้ว",
+ "move.backup.createdCount": "คู่ไฟล์ต้นฉบับ/ไฟล์บีบอัดที่สำรองไว้: {count}",
+ "move.skip.ambiguousOriginal": "ชื่อไฟล์ต้นฉบับที่ไม่ชัดเจน",
+ "move.skip.originalMissingBeforeBackup": "ต้นฉบับหายไปก่อนการสำรองข้อมูล",
+ "move.skip.compressedMissingBeforeBackup": "ไม่พบไฟล์บีบอัดก่อนสำรองข้อมูล",
+ "move.skip.originalModifiedDuringBackup": "ต้นฉบับมีการเปลี่ยนแปลงระหว่างการสำรองข้อมูล",
+ "move.skip.compressedModifiedDuringBackup": "ไฟล์บีบอัดเปลี่ยนแปลงระหว่างการสำรองข้อมูล",
+ "move.skip.originalContentChangedDuringBackup": "เนื้อหาไฟล์ต้นฉบับเปลี่ยนแปลงระหว่างการสำรองข้อมูล",
+ "move.skip.compressedContentChangedDuringBackup": "เนื้อหาไฟล์บีบอัดเปลี่ยนแปลงระหว่างการสำรองข้อมูล",
+ "move.skip.contentChangedDuringCopy": "เนื้อหามีการเปลี่ยนแปลงในขณะที่กำลังคัดลอกข้อมูลสำรอง",
+ "move.skip.originalNotFoundAtMoveTime": "ไม่พบต้นฉบับในขณะที่ย้าย",
+ "move.skip.selfMove": "เส้นทางไฟล์บีบอัดตรงกับเส้นทางไฟล์ต้นฉบับ",
+ "move.skip.externalModification": "ตรวจพบการแก้ไขจากภายนอกระหว่างการย้าย",
+ "move.skip.invalidBackupTask": "งานสำรองข้อมูลไม่ถูกต้อง",
+ "move.skip.noOriginalCandidate": "ไม่พบไฟล์ต้นฉบับที่ตรงกัน",
+ "move.skip.unloading": "กำลังยกเลิกการโหลดปลั๊กอิน",
+ "move.warning.externalModification": "ตรวจพบการแก้ไขภายนอกระหว่างการย้าย: {name}",
+ "backups.imagesFolder.name": "โฟลเดอร์สำรองรูปภาพ",
+ "backups.imagesFolder.desc": "เปิดโฟลเดอร์ที่มีข้อมูลสำรองของรูปภาพต้นฉบับ/รูปภาพบีบอัด",
+ "backups.imagesFolder.openButton": "เปิดโฟลเดอร์สำรองรูปภาพ",
+ "backups.imagesFolder.openError": "ไม่สามารถเปิดโฟลเดอร์สำรองรูปภาพได้",
+ "backups.imagesFolder.clearName": "ล้างข้อมูลสำรองรูปภาพ",
+ "backups.imagesFolder.clearDesc": "ลบข้อมูลสำรองของภาพต้นฉบับ/ภาพบีบอัดที่ถูกย้าย",
+ "backups.imagesFolder.clearButton": "ล้างข้อมูลสำรองรูปภาพ",
+ "backups.imagesFolder.notFound": "ไม่พบโฟลเดอร์ข้อมูลสำรอง",
+ "backups.imagesFolder.noneToDelete": "ไม่มีข้อมูลสำรองที่จะลบ",
+ "backups.imagesFolder.deletedCount": "ชุดข้อมูลสำรองรูปภาพที่ลบแล้ว: {count}",
+ "backups.imagesFolder.clearError": "เกิดข้อผิดพลาดขณะล้างข้อมูลสำรอง",
+ "backups.cache.title": "การสำรองข้อมูลแคช",
+ "backups.cache.folder.name": "โฟลเดอร์สำรองแคช",
+ "backups.cache.folder.desc": "เปิดโฟลเดอร์ที่มีไฟล์สำรองแคช",
+ "backups.cache.folder.openButton": "เปิดโฟลเดอร์สำรองแคช",
+ "backups.pathLabel": "เส้นทาง",
+ "backups.foundLabel": "พบข้อมูลสำรอง",
+ "backups.cache.none": "ไม่มีการสำรองข้อมูลแคช",
+ "backups.cache.restore": "กู้คืนแคชจากข้อมูลสำรอง",
+ "backups.cache.available": "ข้อมูลสำรองที่มีอยู่:",
+ "backups.cache.selectPlaceholder": "-- เลือกข้อมูลสำรอง --",
+ "common.add": "เพิ่ม",
+ "common.refresh": "รีเฟรช",
+ "common.refreshing": "กำลังรีเฟรช...",
+ "common.processing": "กำลังประมวลผล...",
+ "common.clearCache": "ล้างแคช",
+ "common.refreshCache": "รีเฟรชแคช",
+ "common.clear": "ล้าง",
+ "common.clearing": "กำลังล้างข้อมูล...",
+ "common.cancel": "ยกเลิก",
+ "common.close": "ปิด",
+ "units.kb": "KB",
+ "instructions.usageTitle": "การใช้งาน:",
+ "instructions.notesTitle": "หมายเหตุ:",
+ "instructions.notes.saved": "ไฟล์บีบอัดจะถูกบันทึกไว้ใน",
+ "instructions.notes.originalUnchanged": "ไฟล์ต้นฉบับไม่ได้รับการแก้ไข",
+ "instructions.notes.recompressionSkipped": "การบีบอัดซ้ำถูกข้ามไปเนื่องจากแคช",
+ "progress.start": "กำลังเริ่ม...",
+ "progress.processing": "กำลังประมวลผล",
+ "progress.skippedAlready": "ข้าม (บีบอัดแล้ว)",
+ "progress.skipped": "ข้ามไป",
+ "progress.compressed": "บีบอัดแล้ว",
+ "progress.error": "เกิดข้อผิดพลาด",
+ "progress.cancelling": "กำลังยกเลิก...",
+ "progress.cancelled": "ยกเลิกแล้ว",
+ "status.loading": "กำลังโหลด...",
+ "status.indexing": "กำลังจัดทำดัชนีรูปภาพ...",
+ "progress.completed": "เสร็จสิ้น",
+ "folders.noneInVault": "ไม่พบโฟลเดอร์ในห้องนิรภัย",
+ "folderSelect.title": "เลือกโฟลเดอร์สำหรับการบีบอัดรูปภาพ",
+ "folderSelect.selectLabel": "โฟลเดอร์",
+ "folderSelect.root": "โฟลเดอร์รูท",
+ "folderSelect.select": "เลือก",
+ "folderSelect.cancel": "ยกเลิก",
+ "instructions.action.rightClick": "คลิกขวาที่รูปภาพ →",
+ "instructions.action.commandPalette": "กระดานคำสั่ง →",
+ "savings.original": "ต้นฉบับ",
+ "savings.current": "ปัจจุบัน",
+ "savings.saved": "ประหยัดแล้ว",
+ "tooltip.savings.header": "รายละเอียดการประหยัดพื้นที่",
+ "tooltip.savings.original": "ขนาดต้นฉบับ:",
+ "tooltip.savings.current": "ขนาดปัจจุบัน:",
+ "tooltip.savings.saved": "พื้นที่ที่ประหยัดได้:",
+ "tooltip.savings.filesProcessed": "ไฟล์ที่ประมวลผล:",
+ "tooltip.savings.estimated": "ไฟล์โดยประมาณ"
+}
diff --git a/src-ts/locales/tr.json b/src-ts/locales/tr.json
new file mode 100644
index 0000000..b2625ad
--- /dev/null
+++ b/src-ts/locales/tr.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Ayarlar",
+ "settings.loadFailed": "Ayarlar yüklenemedi; varsayılanlar kullanıldı",
+ "init.failed": "Eklentinin başlatılması başarısız oldu. Bildirilen hatayı düzelttikten sonra eklentiyi yeniden yükleyin.",
+ "migration.partialFailure": "Eski eklenti verilerinin taşınması hatalarla tamamlandı. Geliştirici konsolunu kontrol edin.",
+ "i18n.externalLoadFailed": "Harici dil dosyası yüklenemedi",
+ "warning.wasmInitFailed": "WebAssembly modülleri başlatılamadı. Lütfen eklentiyi yeniden yükleyin veya bir hata bildirin.",
+ "command.compressInNote": "Nottaki tüm resimleri sıkıştır",
+ "command.compressInFolder": "Klasördeki tüm görüntüleri sıkıştır",
+ "command.compressAll": "Kasadaki tüm görüntüleri sıkıştır",
+ "command.moveCompressed": "Sıkıştırılmış dosyaları taşı",
+ "context.compressImage": "Resmi sıkıştır",
+ "context.compressImagesInFolder": "Klasördeki görüntüleri sıkıştır",
+ "notice.cacheUpdated": "Önbellek güncellendi",
+ "notice.cacheCleared": "Önbellek temizlendi",
+ "notice.compressionDeferredDueToMove": "Bir taşıma işlemi devam ederken sıkıştırma ertelenir",
+ "notice.operationFailed": "İşlem başarısız oldu. Geliştirici konsolunu kontrol edin.",
+ "validation.pathNotAllowed": "Ayarlarda yola izin verilmiyor",
+ "validation.outputFolder": "Dosya çıktı klasörünün içinde",
+ "validation.alreadyCompressed": "Dosya zaten sıkıştırılmış",
+ "validation.tooSmall": "Dosya çok küçük",
+ "validation.bytes": "bayt",
+ "compress.error.fileAccess": "Dosyaya erişilemiyor",
+ "compress.error.unknown": "bilinmeyen hata",
+ "compress.error.tooLarge": "Resim güvenli bir şekilde sıkıştırılamayacak kadar büyük",
+ "compress.error.notSmaller": "Sıkıştırılmış dosya orijinalinden daha küçük değil",
+ "compress.error.unsupportedFormat": "Desteklenmeyen dosya biçimi",
+ "compress.error.copyCompressed": "Sıkıştırılmış dosya kopyalanamadı",
+ "compress.error.copyCompressedJpeg": "Sıkıştırılmış JPEG dosyası kopyalanamadı",
+ "compress.error.pngQuality": "PNG kodlayıcı yapılandırılan kalite aralığını karşılayamadı",
+ "section.quality": "Sıkıştırma kalitesi",
+ "section.paths": "Yollar",
+ "section.automation": "Otomasyon",
+ "section.stats": "İstatistikler ve önbellek",
+ "section.move": "Sıkıştırılmış dosyaları taşıma",
+ "section.cacheBackups": "Önbellek yedeklemeleri",
+ "section.instructions": "Talimatlar",
+ "quality.png.name": "PNG kalitesi (min-maks)",
+ "quality.png.desc": "PNG sıkıştırması için kalite aralığı (varsayılan olarak 65-80)",
+ "quality.jpeg.name": "JPEG kalitesi",
+ "quality.jpeg.desc": "JPEG sıkıştırma kalitesi (1-95)",
+ "paths.allowedRoots.name": "İzin verilen kök klasörler",
+ "paths.allowedRoots.desc": "Sıkıştırmaya izin verilen klasörleri seçin (boş = tüm yollar)",
+ "paths.allowedRoots.empty": "Seçilmedi (her yeri sıkıştır)",
+ "paths.allowedRoots.pill.remove": "Kaldırmak için tıklayın",
+ "paths.allowedRoots.modal.placeholder": "Bir klasör seçin...",
+ "paths.allowedRoots.cannotAddRoot": "Tüm klasörlere izin vermek için listeyi boş bırakın",
+ "paths.allowedRoots.clear": "Listeyi temizle",
+ "paths.output.name": "Çıkış klasörü",
+ "paths.output.desc": "Sıkıştırılmış dosyaları depolamak için klasör adı",
+ "auto.newFiles.name": "Yeni dosyaları otomatik sıkıştır",
+ "auto.newFiles.desc": "Kasaya eklenen yeni görüntüleri otomatik olarak sıkıştır",
+ "auto.bg.name": "Arka planda otomatik sıkıştırma",
+ "auto.bg.desc": "Etkin olmadığınızda arka planda otomatik olarak sıkıştırır",
+ "auto.bg.threshold.name": "Arka planda sıkıştırma eşiği",
+ "auto.bg.threshold.desc": "Arka planda sıkıştırmayı otomatik başlatacak sıkıştırılmamış görüntü sayısı",
+ "auto.bg.inactivity.name": "Hareketsizlik eşiği (dakika)",
+ "auto.bg.inactivity.desc": "Arka plan sıkıştırması başlamadan önce kullanıcı etkinliği olmadan geçmesi gereken süre (dakika)",
+ "guard.disabled": "{id} sıkıştırma sırasında geçici olarak devre dışı bırakıldı",
+ "guard.restored": "{id} sıkıştırmadan sonra yeniden etkinleştirildi",
+ "auto.retention.toggle.name": "Görüntü yedeklerini otomatik sil",
+ "auto.retention.toggle.desc": "Saklama süresine göre görüntü yedeklerini silin",
+ "auto.retention.days.name": "Yedeklerin saklama süresi (gün)",
+ "auto.retention.days.desc": "Belirtilen gün sayısından daha eski olan yedekleri silin",
+ "auto.move.toggle.name": "Sıkıştırılmış dosyaların otomatik taşınmasını etkinleştir",
+ "auto.move.toggle.desc": "Eşiğe ulaşıldığında sıkıştırılmış dosyaları otomatik olarak taşı",
+ "auto.move.threshold.name": "Otomatik taşıma eşiği (dosya)",
+ "auto.move.threshold.desc": "Otomatik taşımayı tetiklemek için 'Compressed' klasöründe bulunması gereken dosya sayısı",
+ "auto.queueFull": "Otomatik sıkıştırma kuyruğu dolu ({max}); yeni dosyalar atlandı",
+ "background.starting": "Arka plan sıkıştırması başlatıldı. Görüntü sayısı: {count}",
+ "background.finished": "Arka plan sıkıştırması tamamlandı. Sıkıştırılan görüntüler: {count}",
+ "stats.uncompressed.name": "Sıkıştırılmamış görüntüler",
+ "stats.uncompressed.ready": "Sıkıştırmaya hazır",
+ "stats.cache.name": "Önbellek girişleri",
+ "stats.cache.entries": "Girişler",
+ "stats.cache.size": "boyut",
+ "cache.corruptSaved": "Önbellek okunamadı. Bir kurtarma kopyası kaydedildi:",
+ "move.title": "Sıkıştırılmış dosyaları taşıma",
+ "move.ready": "Taşınmaya hazır",
+ "move.button": "Taşı",
+ "move.noCompressedFolder": "Compressed klasörü bulunamadı",
+ "move.noneToMove": "Taşınacak sıkıştırılmış dosya yok",
+ "move.noneWithOriginals": "İzin verilen kök klasörlerde orijinal dosyası bulunan sıkıştırılmış dosya yok",
+ "move.backupCreated": "Orijinal ve sıkıştırılmış dosyaların yedeği oluşturuldu",
+ "move.backup.createdCount": "Yedeklenen orijinal/sıkıştırılmış dosya çiftleri: {count}",
+ "move.skip.ambiguousOriginal": "Belirsiz orijinal dosya adı",
+ "move.skip.originalMissingBeforeBackup": "Yedeklemeden önce orijinal eksik",
+ "move.skip.compressedMissingBeforeBackup": "Yedeklemeden önce sıkıştırılmış çıktı eksik",
+ "move.skip.originalModifiedDuringBackup": "Orijinal yedekleme sırasında değiştirildi",
+ "move.skip.compressedModifiedDuringBackup": "Yedekleme sırasında sıkıştırılmış çıktı değişti",
+ "move.skip.originalContentChangedDuringBackup": "Yedekleme sırasında orijinal içerik değiştirildi",
+ "move.skip.compressedContentChangedDuringBackup": "Yedekleme sırasında sıkıştırılmış çıktı içeriği değişti",
+ "move.skip.contentChangedDuringCopy": "Yedekleme kopyalanırken içerik değişti",
+ "move.skip.originalNotFoundAtMoveTime": "Orijinali taşıma sırasında bulunamadı",
+ "move.skip.selfMove": "Sıkıştırılmış dosyanın yolu orijinal dosyanın yoluyla aynı",
+ "move.skip.externalModification": "Taşıma sırasında harici değişiklik algılandı",
+ "move.skip.invalidBackupTask": "Geçersiz yedekleme görevi",
+ "move.skip.noOriginalCandidate": "Eşleşen orijinal dosya bulunamadı",
+ "move.skip.unloading": "Eklenti devre dışı bırakılıyor",
+ "move.warning.externalModification": "Taşıma sırasında harici değişiklik algılandı: {name}",
+ "backups.imagesFolder.name": "Görüntü yedekleme klasörü",
+ "backups.imagesFolder.desc": "Orijinal/sıkıştırılmış görüntülerin yedeklerini içeren klasörü açın",
+ "backups.imagesFolder.openButton": "Görüntü yedekleme klasörünü aç",
+ "backups.imagesFolder.openError": "Görüntü yedekleme klasörü açılamadı",
+ "backups.imagesFolder.clearName": "Görüntü yedeklerini temizle",
+ "backups.imagesFolder.clearDesc": "Taşınan orijinal/sıkıştırılmış görüntülerin yedeklerini silin",
+ "backups.imagesFolder.clearButton": "Görüntü yedeklerini temizle",
+ "backups.imagesFolder.notFound": "Yedeklemeler klasörü bulunamadı",
+ "backups.imagesFolder.noneToDelete": "Silinecek yedek yok",
+ "backups.imagesFolder.deletedCount": "Silinen görüntü yedekleme kümeleri: {count}",
+ "backups.imagesFolder.clearError": "Yedeklemeler temizlenirken hata oluştu",
+ "backups.cache.title": "Önbellek yedeklemeleri",
+ "backups.cache.folder.name": "Önbellek yedekleri klasörü",
+ "backups.cache.folder.desc": "Önbellek yedekleme dosyalarının bulunduğu klasörü açın",
+ "backups.cache.folder.openButton": "Önbellek yedeklemeleri klasörünü aç",
+ "backups.pathLabel": "Yol",
+ "backups.foundLabel": "Yedeklemeler bulundu",
+ "backups.cache.none": "Önbellek yedeği yok",
+ "backups.cache.restore": "Önbelleği yedekten geri yükle",
+ "backups.cache.available": "Mevcut yedeklemeler:",
+ "backups.cache.selectPlaceholder": "-- Yedeklemeyi seçin --",
+ "common.add": "Ekle",
+ "common.refresh": "Yenile",
+ "common.refreshing": "Yenileniyor...",
+ "common.processing": "İşleniyor...",
+ "common.clearCache": "Önbelleği temizle",
+ "common.refreshCache": "Önbelleği yenile",
+ "common.clear": "Temizle",
+ "common.clearing": "Temizleniyor...",
+ "common.cancel": "İptal",
+ "common.close": "Kapat",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Kullanım:",
+ "instructions.notesTitle": "Notlar:",
+ "instructions.notes.saved": "Sıkıştırılmış dosyalar şuraya kaydedilir:",
+ "instructions.notes.originalUnchanged": "Orijinal dosyalar değiştirilmez",
+ "instructions.notes.recompressionSkipped": "Önbellek sayesinde yeniden sıkıştırma atlanır",
+ "progress.start": "Başlıyor...",
+ "progress.processing": "İşleniyor",
+ "progress.skippedAlready": "Atlandı (zaten sıkıştırılmış)",
+ "progress.skipped": "Atlandı",
+ "progress.compressed": "Sıkıştırıldı",
+ "progress.error": "Hata",
+ "progress.cancelling": "İptal ediliyor...",
+ "progress.cancelled": "İptal edildi",
+ "status.loading": "Yükleniyor...",
+ "status.indexing": "Resimler indeksleniyor...",
+ "progress.completed": "Tamamlandı",
+ "folders.noneInVault": "Kasada klasör bulunamadı",
+ "folderSelect.title": "Görüntüleri sıkıştırmak için bir klasör seçin",
+ "folderSelect.selectLabel": "Klasör",
+ "folderSelect.root": "Kök klasör",
+ "folderSelect.select": "Seç",
+ "folderSelect.cancel": "İptal",
+ "instructions.action.rightClick": "Bir resme sağ tıklayın →",
+ "instructions.action.commandPalette": "Komut paleti →",
+ "savings.original": "Orijinal",
+ "savings.current": "Mevcut",
+ "savings.saved": "Tasarruf",
+ "tooltip.savings.header": "Yer tasarrufu ayrıntıları",
+ "tooltip.savings.original": "Orijinal boyut:",
+ "tooltip.savings.current": "Mevcut boyut:",
+ "tooltip.savings.saved": "Kazanılan alan:",
+ "tooltip.savings.filesProcessed": "İşlenen dosyalar:",
+ "tooltip.savings.estimated": "Tahmini dosyalar"
+}
diff --git a/src-ts/locales/uk.json b/src-ts/locales/uk.json
new file mode 100644
index 0000000..491b8a3
--- /dev/null
+++ b/src-ts/locales/uk.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Налаштування",
+ "settings.loadFailed": "Не вдалося завантажити налаштування; використано типові значення",
+ "init.failed": "Не вдалося ініціалізувати додаток. Після виправлення зазначеної помилки перезавантажте додаток.",
+ "migration.partialFailure": "Перенесення даних старої версії завершилося з помилками. Перевірте консоль розробника.",
+ "i18n.externalLoadFailed": "Не вдалося завантажити зовнішній мовний файл",
+ "warning.wasmInitFailed": "Не вдалося ініціалізувати модулі WebAssembly. Перезавантажте додаток або повідомте про помилку.",
+ "command.compressInNote": "Стиснути всі зображення в нотатці",
+ "command.compressInFolder": "Стиснути всі зображення у теці",
+ "command.compressAll": "Стиснути всі зображення у сховищі",
+ "command.moveCompressed": "Перемістити стиснені файли",
+ "context.compressImage": "Стиснути зображення",
+ "context.compressImagesInFolder": "Стиснути зображення в теці",
+ "notice.cacheUpdated": "Кеш оновлено",
+ "notice.cacheCleared": "Кеш очищено",
+ "notice.compressionDeferredDueToMove": "Стиснення відкладено, доки триває перенесення файлів",
+ "notice.operationFailed": "Не вдалося виконати операцію. Перевірте консоль розробника.",
+ "validation.pathNotAllowed": "Шлях не дозволено в налаштуваннях",
+ "validation.outputFolder": "Файл розташований у вихідній теці",
+ "validation.alreadyCompressed": "Файл уже стиснено",
+ "validation.tooSmall": "Файл занадто малий",
+ "validation.bytes": "байт",
+ "compress.error.fileAccess": "Неможливо отримати доступ до файлу",
+ "compress.error.unknown": "невідома помилка",
+ "compress.error.tooLarge": "Зображення завелике для безпечного стиснення",
+ "compress.error.notSmaller": "Стиснений файл не менший за оригінал",
+ "compress.error.unsupportedFormat": "Непідтримуваний формат файлу",
+ "compress.error.copyCompressed": "Не вдалося скопіювати стиснений файл",
+ "compress.error.copyCompressedJpeg": "Не вдалося скопіювати стиснений JPEG-файл",
+ "compress.error.pngQuality": "PNG-кодек не зміг забезпечити заданий діапазон якості",
+ "section.quality": "Якість стиснення",
+ "section.paths": "Шляхи",
+ "section.automation": "Автоматизація",
+ "section.stats": "Статистика та кеш",
+ "section.move": "Переміщення стиснених файлів",
+ "section.cacheBackups": "Бекапи кешу",
+ "section.instructions": "Інструкції",
+ "quality.png.name": "Якість PNG (мін-макс)",
+ "quality.png.desc": "Діапазон якості для стиснення PNG (65-80 за замовчуванням)",
+ "quality.jpeg.name": "Якість JPEG",
+ "quality.jpeg.desc": "Якість стиснення JPEG (1-95)",
+ "paths.allowedRoots.name": "Дозволені кореневі теки",
+ "paths.allowedRoots.desc": "Виберіть теки, у яких дозволено стиснення (порожній список = усі шляхи)",
+ "paths.allowedRoots.empty": "Не вибрано (стискаємо всюди)",
+ "paths.allowedRoots.pill.remove": "Натисніть, щоб видалити",
+ "paths.allowedRoots.modal.placeholder": "Виберіть теку...",
+ "paths.allowedRoots.cannotAddRoot": "Не можна додати сховище як дозволену кореневу теку. Залиште список порожнім, щоб дозволити всі теки.",
+ "paths.allowedRoots.clear": "Очистити список",
+ "paths.output.name": "Вихідна тека",
+ "paths.output.desc": "Назва теки для стиснених файлів",
+ "auto.newFiles.name": "Автостиснення нових файлів",
+ "auto.newFiles.desc": "Автоматично стискати нові зображення під час додавання до сховища",
+ "auto.bg.name": "Автоматичне фонове стиснення",
+ "auto.bg.desc": "Автоматично стискати у фоні за відсутності активності користувача",
+ "auto.bg.threshold.name": "Поріг для фонового стиснення",
+ "auto.bg.threshold.desc": "Кількість нестиснених зображень, за якої автоматично запускається фонове стиснення",
+ "guard.disabled": "{id} тимчасово вимкнено під час стиснення",
+ "guard.restored": "{id} знову ввімкнено після стиснення",
+ "auto.retention.toggle.name": "Автовидалення бекапів зображень",
+ "auto.retention.toggle.desc": "Видаляти бекапи зображень за строком зберігання",
+ "auto.retention.days.name": "Строк зберігання бекапів (днів)",
+ "auto.retention.days.desc": "Видаляти бекапи, старші за вказану кількість днів",
+ "auto.move.toggle.name": "Увімкнути автопереміщення стиснених файлів",
+ "auto.move.toggle.desc": "Автоматично переміщувати стиснені файли при досягненні порогу",
+ "auto.move.threshold.name": "Поріг автопереміщення (файлів)",
+ "auto.move.threshold.desc": "Скільки файлів у 'Compressed' потрібно для автопереміщення",
+ "auto.queueFull": "Чергу автостиснення заповнено ({max}); нові файли пропущено",
+ "background.starting": "Запущено фонове стиснення. Зображень: {count}",
+ "background.finished": "Фонове стиснення завершено. Стиснено зображень: {count}",
+ "auto.bg.inactivity.name": "Поріг неактивності (хвилини)",
+ "auto.bg.inactivity.desc": "Скільки хвилин без дій користувача чекати перед запуском фонового стиснення",
+ "stats.uncompressed.name": "Нестиснені зображення",
+ "stats.uncompressed.ready": "Готово до стиснення",
+ "stats.cache.name": "Записи в кеші",
+ "stats.cache.entries": "Записи",
+ "stats.cache.size": "розмір",
+ "cache.corruptSaved": "Кеш не вдалося прочитати. Копію для відновлення збережено:",
+ "move.title": "Перемістити стиснені файли",
+ "move.ready": "Готово до переміщення",
+ "move.button": "Перемістити",
+ "move.noCompressedFolder": "Теку Compressed не знайдено",
+ "move.noneToMove": "Немає стиснених файлів для переміщення",
+ "move.noneWithOriginals": "Немає стиснених файлів, оригінали яких розташовані в дозволених кореневих теках",
+ "move.backupCreated": "Створено бекап оригінальних і стиснених файлів",
+ "move.backup.createdCount": "Створено комплектів бекапів оригіналу та стисненого файлу: {count}",
+ "move.skip.ambiguousOriginal": "Неоднозначна назва оригіналу",
+ "move.skip.originalMissingBeforeBackup": "Оригінал відсутній перед бекапом",
+ "move.skip.compressedMissingBeforeBackup": "Стиснений файл відсутній перед бекапом",
+ "move.skip.originalModifiedDuringBackup": "Оригінал змінився під час бекапу",
+ "move.skip.compressedModifiedDuringBackup": "Стиснений файл змінився під час бекапу",
+ "move.skip.originalContentChangedDuringBackup": "Вміст оригіналу змінився під час бекапу",
+ "move.skip.compressedContentChangedDuringBackup": "Вміст стисненого файлу змінився під час бекапу",
+ "move.skip.contentChangedDuringCopy": "Вміст змінився під час копіювання бекапу",
+ "move.skip.originalNotFoundAtMoveTime": "Оригінал не знайдено під час переміщення",
+ "move.skip.selfMove": "Шлях до стисненого файлу збігається зі шляхом до оригіналу",
+ "move.skip.externalModification": "Під час переміщення виявлено зовнішню зміну",
+ "move.skip.invalidBackupTask": "Некоректне завдання бекапу",
+ "move.skip.noOriginalCandidate": "Відповідний оригінал не знайдено",
+ "move.skip.unloading": "Додаток вивантажується",
+ "move.warning.externalModification": "Виявлено зовнішню зміну під час переміщення: {name}",
+ "backups.imagesFolder.name": "Тека з бекапами зображень",
+ "backups.imagesFolder.desc": "Відкрити теку з бекапами оригінальних і стиснених зображень",
+ "backups.imagesFolder.openButton": "Відкрити теку бекапів зображень",
+ "backups.imagesFolder.openError": "Не вдалося відкрити теку бекапів зображень",
+ "backups.imagesFolder.clearName": "Очистити бекапи зображень",
+ "backups.imagesFolder.clearDesc": "Видалити бекапи переміщених оригінальних/стиснених зображень",
+ "backups.imagesFolder.clearButton": "Очистити бекапи зображень",
+ "backups.imagesFolder.notFound": "Теку бекапів не знайдено",
+ "backups.imagesFolder.noneToDelete": "Немає бекапів для видалення",
+ "backups.imagesFolder.deletedCount": "Видалено наборів бекапів зображень: {count}",
+ "backups.imagesFolder.clearError": "Помилка під час очищення бекапів",
+ "backups.cache.title": "Бекапи кешу",
+ "backups.cache.folder.name": "Тека бекапів кешу",
+ "backups.cache.folder.desc": "Відкрити теку з файлами бекапів кешу",
+ "backups.cache.folder.openButton": "Відкрити теку бекапів кешу",
+ "backups.pathLabel": "Шлях",
+ "backups.foundLabel": "Знайдено бекапів",
+ "backups.cache.none": "Немає доступних бекапів кешу",
+ "backups.cache.restore": "Відновити кеш із бекапу",
+ "backups.cache.available": "Доступно бекапів:",
+ "backups.cache.selectPlaceholder": "-- Оберіть бекап --",
+ "common.add": "Додати",
+ "common.refresh": "Оновити",
+ "common.refreshing": "Оновлення...",
+ "common.processing": "Обробка...",
+ "common.clearCache": "Очистити кеш",
+ "common.refreshCache": "Оновити кеш",
+ "common.clear": "Очистити",
+ "common.clearing": "Очищення...",
+ "common.cancel": "Скасувати",
+ "common.close": "Закрити",
+ "units.kb": "КБ",
+ "instructions.usageTitle": "Використання:",
+ "instructions.notesTitle": "Примітки:",
+ "instructions.notes.saved": "Стиснені файли зберігаються у теці",
+ "instructions.notes.originalUnchanged": "Оригінальні файли не змінюються",
+ "instructions.notes.recompressionSkipped": "Повторне стиснення пропускається завдяки кешу",
+ "progress.start": "Починаємо обробку...",
+ "progress.processing": "Обробка",
+ "progress.skippedAlready": "Пропущено (вже стиснено)",
+ "progress.skipped": "Пропущено",
+ "progress.compressed": "Стиснено",
+ "progress.error": "Помилка",
+ "progress.cancelling": "Скасування...",
+ "progress.cancelled": "Скасовано",
+ "status.loading": "Завантаження...",
+ "status.indexing": "Індексація зображень...",
+ "progress.completed": "Завершено",
+ "folders.noneInVault": "У сховищі немає тек",
+ "folderSelect.title": "Виберіть теку для стиснення зображень",
+ "folderSelect.selectLabel": "Тека",
+ "folderSelect.root": "Коренева тека",
+ "folderSelect.select": "Вибрати",
+ "folderSelect.cancel": "Скасувати",
+ "instructions.action.rightClick": "Клацніть зображення правою кнопкою миші →",
+ "instructions.action.commandPalette": "Меню команд →",
+ "savings.original": "Оригінал",
+ "savings.current": "Поточний",
+ "savings.saved": "Заощаджено",
+ "tooltip.savings.header": "Деталі заощадження місця",
+ "tooltip.savings.original": "Початковий розмір:",
+ "tooltip.savings.current": "Поточний розмір:",
+ "tooltip.savings.saved": "Заощаджено місця:",
+ "tooltip.savings.filesProcessed": "Опрацьовано файлів:",
+ "tooltip.savings.estimated": "Оцінено файлів"
+}
diff --git a/src-ts/locales/vi.json b/src-ts/locales/vi.json
new file mode 100644
index 0000000..d7dd32a
--- /dev/null
+++ b/src-ts/locales/vi.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "Cài đặt",
+ "settings.loadFailed": "Không thể tải cài đặt; mặc định đã được sử dụng",
+ "init.failed": "Khởi tạo plugin không thành công. Tải lại plugin sau khi sửa lỗi được báo cáo.",
+ "migration.partialFailure": "Quá trình di chuyển dữ liệu plugin cũ đã hoàn tất và có lỗi. Kiểm tra bảng điều khiển dành cho nhà phát triển.",
+ "i18n.externalLoadFailed": "Không thể tải tệp ngôn ngữ bên ngoài",
+ "warning.wasmInitFailed": "Các mô-đun WebAssembly không khởi tạo được. Vui lòng tải lại plugin hoặc báo cáo lỗi.",
+ "command.compressInNote": "Nén tất cả hình ảnh trong ghi chú",
+ "command.compressInFolder": "Nén tất cả hình ảnh trong thư mục",
+ "command.compressAll": "Nén tất cả hình ảnh trong kho",
+ "command.moveCompressed": "Di chuyển tệp nén",
+ "context.compressImage": "Nén hình ảnh",
+ "context.compressImagesInFolder": "Nén hình ảnh trong thư mục",
+ "notice.cacheUpdated": "Đã cập nhật bộ nhớ đệm",
+ "notice.cacheCleared": "Đã xóa bộ nhớ đệm",
+ "notice.compressionDeferredDueToMove": "Quá trình nén bị trì hoãn trong khi thao tác di chuyển đang diễn ra",
+ "notice.operationFailed": "Thao tác không thành công. Kiểm tra bảng điều khiển dành cho nhà phát triển.",
+ "validation.pathNotAllowed": "Đường dẫn không được phép trong cài đặt",
+ "validation.outputFolder": "Tệp nằm trong thư mục đầu ra",
+ "validation.alreadyCompressed": "Tệp đã được nén",
+ "validation.tooSmall": "Tệp quá nhỏ",
+ "validation.bytes": "byte",
+ "compress.error.fileAccess": "Không thể truy cập tệp",
+ "compress.error.unknown": "lỗi không xác định",
+ "compress.error.tooLarge": "Hình ảnh quá lớn để nén an toàn",
+ "compress.error.notSmaller": "Tệp nén không nhỏ hơn bản gốc",
+ "compress.error.unsupportedFormat": "Định dạng tệp không được hỗ trợ",
+ "compress.error.copyCompressed": "Không thể sao chép tệp nén",
+ "compress.error.copyCompressedJpeg": "Không thể sao chép tệp JPEG đã nén",
+ "compress.error.pngQuality": "Bộ mã hóa PNG không thể đáp ứng phạm vi chất lượng đã định cấu hình",
+ "section.quality": "Chất lượng nén",
+ "section.paths": "Đường dẫn",
+ "section.automation": "Tự động hóa",
+ "section.stats": "Thống kê & bộ nhớ đệm",
+ "section.move": "Di chuyển tệp nén",
+ "section.cacheBackups": "Sao lưu bộ nhớ đệm",
+ "section.instructions": "Hướng dẫn",
+ "quality.png.name": "Chất lượng PNG (tối thiểu-tối đa)",
+ "quality.png.desc": "Phạm vi chất lượng để nén PNG (65-80 theo mặc định)",
+ "quality.jpeg.name": "Chất lượng JPEG",
+ "quality.jpeg.desc": "Chất lượng nén JPEG (1-95)",
+ "paths.allowedRoots.name": "Thư mục gốc được phép",
+ "paths.allowedRoots.desc": "Chọn các thư mục được phép nén (trống = tất cả các đường dẫn)",
+ "paths.allowedRoots.empty": "Chưa được chọn (nén mọi nơi)",
+ "paths.allowedRoots.pill.remove": "Bấm để xóa",
+ "paths.allowedRoots.modal.placeholder": "Chọn một thư mục...",
+ "paths.allowedRoots.cannotAddRoot": "Để trống danh sách để cho phép tất cả các thư mục",
+ "paths.allowedRoots.clear": "Xóa danh sách",
+ "paths.output.name": "Thư mục đầu ra",
+ "paths.output.desc": "Tên thư mục lưu trữ tệp nén",
+ "auto.newFiles.name": "Tự động nén tệp mới",
+ "auto.newFiles.desc": "Tự động nén hình ảnh mới khi thêm vào kho",
+ "auto.bg.name": "Tự động nén trong nền",
+ "auto.bg.desc": "Tự động nén trong nền khi không hoạt động",
+ "auto.bg.threshold.name": "Ngưỡng nén trong nền",
+ "auto.bg.threshold.desc": "Số hình ảnh chưa nén để tự động bắt đầu nén",
+ "auto.bg.inactivity.name": "Ngưỡng không hoạt động (phút)",
+ "auto.bg.inactivity.desc": "Số phút không có hoạt động của người dùng trước khi bắt đầu nén trong nền",
+ "guard.disabled": "{id} tạm thời bị tắt trong quá trình nén",
+ "guard.restored": "{id} đã được bật lại sau khi nén",
+ "auto.retention.toggle.name": "Tự động xóa bản sao lưu hình ảnh",
+ "auto.retention.toggle.desc": "Xóa bản sao lưu ảnh theo thời gian lưu giữ",
+ "auto.retention.days.name": "Thời gian lưu giữ bản sao lưu",
+ "auto.retention.days.desc": "Xóa các bản sao lưu cũ hơn số ngày được chỉ định",
+ "auto.move.toggle.name": "Cho phép tự động di chuyển các tệp nén",
+ "auto.move.toggle.desc": "Tự động di chuyển các tệp nén khi đạt đến ngưỡng",
+ "auto.move.threshold.name": "Ngưỡng tự động di chuyển (tệp)",
+ "auto.move.threshold.desc": "Số tệp trong 'Compressed' cần thiết để kích hoạt tự động di chuyển",
+ "auto.queueFull": "Hàng đợi tự động nén đã đầy ({max}); các tệp mới đã bị bỏ qua",
+ "background.starting": "Đã bắt đầu nén trong nền. Số hình ảnh: {count}",
+ "background.finished": "Đã hoàn tất nén trong nền. Hình ảnh đã nén: {count}",
+ "stats.uncompressed.name": "Hình ảnh không nén",
+ "stats.uncompressed.ready": "Hình ảnh sẵn sàng nén",
+ "stats.cache.name": "Mục bộ nhớ đệm",
+ "stats.cache.entries": "Mục",
+ "stats.cache.size": "kích thước",
+ "cache.corruptSaved": "Không thể đọc được bộ nhớ đệm. Một bản sao khôi phục đã được lưu:",
+ "move.title": "Di chuyển tệp nén",
+ "move.ready": "Tệp sẵn sàng di chuyển",
+ "move.button": "Di chuyển",
+ "move.noCompressedFolder": "Không tìm thấy thư mục Compressed",
+ "move.noneToMove": "Không có tệp nén để di chuyển",
+ "move.noneWithOriginals": "Không có tệp nén nào có tệp gốc trong các thư mục gốc được phép",
+ "move.backupCreated": "Đã tạo bản sao lưu của tệp gốc và tệp nén",
+ "move.backup.createdCount": "Các cặp tệp gốc/tệp nén đã sao lưu: {count}",
+ "move.skip.ambiguousOriginal": "Tên tệp gốc không rõ ràng",
+ "move.skip.originalMissingBeforeBackup": "Bản gốc bị thiếu trước khi sao lưu",
+ "move.skip.compressedMissingBeforeBackup": "Không tìm thấy tệp nén trước khi sao lưu",
+ "move.skip.originalModifiedDuringBackup": "Bản gốc đã thay đổi trong quá trình sao lưu",
+ "move.skip.compressedModifiedDuringBackup": "Tệp nén đã thay đổi trong khi sao lưu",
+ "move.skip.originalContentChangedDuringBackup": "Nội dung tệp gốc đã thay đổi trong khi sao lưu",
+ "move.skip.compressedContentChangedDuringBackup": "Nội dung tệp nén đã thay đổi trong khi sao lưu",
+ "move.skip.contentChangedDuringCopy": "Nội dung đã thay đổi trong khi bản sao lưu đang được sao chép",
+ "move.skip.originalNotFoundAtMoveTime": "Không tìm thấy bản gốc tại thời điểm di chuyển",
+ "move.skip.selfMove": "Đường dẫn tệp nén khớp với đường dẫn tệp gốc",
+ "move.skip.externalModification": "Đã phát hiện sửa đổi bên ngoài trong quá trình di chuyển",
+ "move.skip.invalidBackupTask": "Tác vụ sao lưu không hợp lệ",
+ "move.skip.noOriginalCandidate": "Không tìm thấy tệp gốc phù hợp",
+ "move.skip.unloading": "Plugin đang được vô hiệu hóa",
+ "move.warning.externalModification": "Đã phát hiện sửa đổi bên ngoài trong quá trình di chuyển: {name}",
+ "backups.imagesFolder.name": "Thư mục sao lưu hình ảnh",
+ "backups.imagesFolder.desc": "Mở thư mục chứa bản sao lưu của ảnh gốc/nén",
+ "backups.imagesFolder.openButton": "Mở thư mục sao lưu ảnh",
+ "backups.imagesFolder.openError": "Không mở được thư mục sao lưu ảnh",
+ "backups.imagesFolder.clearName": "Xóa bản sao lưu hình ảnh",
+ "backups.imagesFolder.clearDesc": "Xóa bản sao lưu của hình ảnh gốc/nén đã di chuyển",
+ "backups.imagesFolder.clearButton": "Xóa bản sao lưu hình ảnh",
+ "backups.imagesFolder.notFound": "Không tìm thấy thư mục sao lưu",
+ "backups.imagesFolder.noneToDelete": "Không có bản sao lưu để xóa",
+ "backups.imagesFolder.deletedCount": "Các bộ sao lưu hình ảnh đã xóa: {count}",
+ "backups.imagesFolder.clearError": "Lỗi khi xóa bản sao lưu",
+ "backups.cache.title": "Sao lưu bộ nhớ đệm",
+ "backups.cache.folder.name": "Thư mục sao lưu bộ nhớ đệm",
+ "backups.cache.folder.desc": "Mở thư mục chứa các tệp sao lưu bộ nhớ đệm",
+ "backups.cache.folder.openButton": "Mở thư mục sao lưu bộ nhớ đệm",
+ "backups.pathLabel": "Đường dẫn",
+ "backups.foundLabel": "Đã tìm thấy bản sao lưu",
+ "backups.cache.none": "Không có bản sao lưu bộ nhớ đệm",
+ "backups.cache.restore": "Khôi phục bộ nhớ đệm từ bản sao lưu",
+ "backups.cache.available": "Các bản sao lưu có sẵn:",
+ "backups.cache.selectPlaceholder": "-- Chọn bản sao lưu --",
+ "common.add": "Thêm",
+ "common.refresh": "Làm mới",
+ "common.refreshing": "Đang làm mới...",
+ "common.processing": "Đang xử lý...",
+ "common.clearCache": "Xóa bộ nhớ đệm",
+ "common.refreshCache": "Làm mới bộ nhớ đệm",
+ "common.clear": "Xóa",
+ "common.clearing": "Đang xóa...",
+ "common.cancel": "Hủy bỏ",
+ "common.close": "Đóng",
+ "units.kb": "KB",
+ "instructions.usageTitle": "Cách sử dụng:",
+ "instructions.notesTitle": "Ghi chú:",
+ "instructions.notes.saved": "Các tệp nén được lưu trong",
+ "instructions.notes.originalUnchanged": "Các tệp gốc không được sửa đổi",
+ "instructions.notes.recompressionSkipped": "Quá trình nén lại bị bỏ qua nhờ bộ nhớ đệm",
+ "progress.start": "Đang bắt đầu...",
+ "progress.processing": "Đang xử lý",
+ "progress.skippedAlready": "Đã bỏ qua (đã được nén)",
+ "progress.skipped": "Đã bỏ qua",
+ "progress.compressed": "Đã nén",
+ "progress.error": "Lỗi",
+ "progress.cancelling": "Đang hủy...",
+ "progress.cancelled": "Đã hủy",
+ "status.loading": "Đang tải...",
+ "status.indexing": "Lập chỉ mục hình ảnh...",
+ "progress.completed": "Hoàn tất",
+ "folders.noneInVault": "Không tìm thấy thư mục nào trong kho",
+ "folderSelect.title": "Chọn thư mục nén ảnh",
+ "folderSelect.selectLabel": "Thư mục",
+ "folderSelect.root": "Thư mục gốc",
+ "folderSelect.select": "Chọn",
+ "folderSelect.cancel": "Hủy bỏ",
+ "instructions.action.rightClick": "Nhấp chuột phải vào hình ảnh →",
+ "instructions.action.commandPalette": "Khay lệnh →",
+ "savings.original": "Bản gốc",
+ "savings.current": "Hiện tại",
+ "savings.saved": "Đã tiết kiệm",
+ "tooltip.savings.header": "Chi tiết dung lượng tiết kiệm được",
+ "tooltip.savings.original": "Kích thước ban đầu:",
+ "tooltip.savings.current": "Kích thước hiện tại:",
+ "tooltip.savings.saved": "Dung lượng tiết kiệm được:",
+ "tooltip.savings.filesProcessed": "Các tệp được xử lý:",
+ "tooltip.savings.estimated": "Tệp ước tính"
+}
diff --git a/src-ts/locales/zh-cn.json b/src-ts/locales/zh-cn.json
new file mode 100644
index 0000000..c1f4fa1
--- /dev/null
+++ b/src-ts/locales/zh-cn.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "设置",
+ "settings.loadFailed": "无法加载设置;使用了默认值",
+ "init.failed": "插件初始化失败。修复报告的错误后重新加载插件。",
+ "migration.partialFailure": "旧版插件数据迁移已完成,但有错误。检查开发者控制台。",
+ "i18n.externalLoadFailed": "无法加载外部语言文件",
+ "warning.wasmInitFailed": "WebAssembly 模块无法初始化。请重新加载插件或报告错误。",
+ "command.compressInNote": "压缩笔记中的所有图像",
+ "command.compressInFolder": "压缩文件夹中的所有图像",
+ "command.compressAll": "压缩仓库中的所有图像",
+ "command.moveCompressed": "移动压缩文件",
+ "context.compressImage": "压缩图像",
+ "context.compressImagesInFolder": "压缩文件夹中的图像",
+ "notice.cacheUpdated": "缓存已更新",
+ "notice.cacheCleared": "缓存已清除",
+ "notice.compressionDeferredDueToMove": "移动操作正在进行时压缩会延迟",
+ "notice.operationFailed": "操作失败。检查开发者控制台。",
+ "validation.pathNotAllowed": "设置中不允许此路径",
+ "validation.outputFolder": "文件位于输出文件夹内",
+ "validation.alreadyCompressed": "文件已经被压缩",
+ "validation.tooSmall": "文件太小",
+ "validation.bytes": "字节",
+ "compress.error.fileAccess": "无法访问文件",
+ "compress.error.unknown": "未知错误",
+ "compress.error.tooLarge": "图像太大,无法安全压缩",
+ "compress.error.notSmaller": "压缩后的文件不小于原文件",
+ "compress.error.unsupportedFormat": "不支持的文件格式",
+ "compress.error.copyCompressed": "无法复制压缩文件",
+ "compress.error.copyCompressedJpeg": "无法复制压缩的 JPEG 文件",
+ "compress.error.pngQuality": "PNG 编码器无法满足配置的质量范围",
+ "section.quality": "压缩质量",
+ "section.paths": "路径",
+ "section.automation": "自动化",
+ "section.stats": "统计和缓存",
+ "section.move": "移动压缩文件",
+ "section.cacheBackups": "缓存备份",
+ "section.instructions": "使用说明",
+ "quality.png.name": "PNG 质量(最小-最大)",
+ "quality.png.desc": "PNG 压缩的质量范围(默认为 65-80)",
+ "quality.jpeg.name": "JPEG 质量",
+ "quality.jpeg.desc": "JPEG 压缩质量 (1-95)",
+ "paths.allowedRoots.name": "允许的根文件夹",
+ "paths.allowedRoots.desc": "选择允许压缩的文件夹(空=所有路径)",
+ "paths.allowedRoots.empty": "未选择(到处压缩)",
+ "paths.allowedRoots.pill.remove": "点击删除",
+ "paths.allowedRoots.modal.placeholder": "选择一个文件夹...",
+ "paths.allowedRoots.cannotAddRoot": "将列表留空以允许所有文件夹",
+ "paths.allowedRoots.clear": "清除列表",
+ "paths.output.name": "输出文件夹",
+ "paths.output.desc": "存放压缩文件的文件夹名称",
+ "auto.newFiles.name": "自动压缩新文件",
+ "auto.newFiles.desc": "添加到仓库时自动压缩新图像",
+ "auto.bg.name": "自动后台压缩",
+ "auto.bg.desc": "当您不活动时在后台自动压缩",
+ "auto.bg.threshold.name": "后台压缩阈值",
+ "auto.bg.threshold.desc": "自动启动的未压缩图像数量",
+ "auto.bg.inactivity.name": "不活动阈值(分钟)",
+ "auto.bg.inactivity.desc": "后台压缩开始前无用户活动的分钟数",
+ "guard.disabled": "{id} 在压缩期间暂时禁用",
+ "guard.restored": "压缩后已重新启用 {id}",
+ "auto.retention.toggle.name": "自动删除图像备份",
+ "auto.retention.toggle.desc": "按保留期限删除图像备份",
+ "auto.retention.days.name": "备份保留(天)",
+ "auto.retention.days.desc": "删除早于指定天数的备份",
+ "auto.move.toggle.name": "启用压缩文件的自动移动",
+ "auto.move.toggle.desc": "达到阈值时自动移动压缩文件",
+ "auto.move.threshold.name": "自动移动阈值(文件数)",
+ "auto.move.threshold.desc": "触发自动移动所需的 'Compressed' 文件数",
+ "auto.queueFull": "自动压缩队列已满 ({max});新文件被跳过",
+ "background.starting": "后台压缩已开始。图像数:{count}",
+ "background.finished": "后台压缩已完成。已压缩图像数:{count}",
+ "stats.uncompressed.name": "未压缩图像",
+ "stats.uncompressed.ready": "待压缩图像",
+ "stats.cache.name": "缓存条目",
+ "stats.cache.entries": "条目",
+ "stats.cache.size": "大小",
+ "cache.corruptSaved": "无法读取缓存。已保存恢复副本:",
+ "move.title": "移动压缩文件",
+ "move.ready": "待移动文件",
+ "move.button": "移动",
+ "move.noCompressedFolder": "未找到 Compressed 文件夹",
+ "move.noneToMove": "没有可移动的压缩文件",
+ "move.noneWithOriginals": "允许的根文件夹中没有对应原始文件的压缩文件",
+ "move.backupCreated": "已创建原始文件和压缩文件的备份",
+ "move.backup.createdCount": "已备份的原始/压缩文件对:{count}",
+ "move.skip.ambiguousOriginal": "原始文件名不明确",
+ "move.skip.originalMissingBeforeBackup": "备份前缺少原始文件",
+ "move.skip.compressedMissingBeforeBackup": "备份前缺少压缩文件",
+ "move.skip.originalModifiedDuringBackup": "备份期间原始内容已更改",
+ "move.skip.compressedModifiedDuringBackup": "备份期间压缩文件已更改",
+ "move.skip.originalContentChangedDuringBackup": "备份期间原始文件内容已更改",
+ "move.skip.compressedContentChangedDuringBackup": "备份期间压缩文件内容已更改",
+ "move.skip.contentChangedDuringCopy": "复制备份时内容发生更改",
+ "move.skip.originalNotFoundAtMoveTime": "移动时未找到原始文件",
+ "move.skip.selfMove": "压缩文件路径与原始文件路径相同",
+ "move.skip.externalModification": "移动期间检测到外部修改",
+ "move.skip.invalidBackupTask": "备份任务无效",
+ "move.skip.noOriginalCandidate": "未找到候选原始文件",
+ "move.skip.unloading": "插件正在卸载",
+ "move.warning.externalModification": "移动期间检测到外部修改:{name}",
+ "backups.imagesFolder.name": "图像备份文件夹",
+ "backups.imagesFolder.desc": "打开包含原始/压缩图像备份的文件夹",
+ "backups.imagesFolder.openButton": "打开图像备份文件夹",
+ "backups.imagesFolder.openError": "无法打开图像备份文件夹",
+ "backups.imagesFolder.clearName": "清除图像备份",
+ "backups.imagesFolder.clearDesc": "删除移动的原始/压缩图像的备份",
+ "backups.imagesFolder.clearButton": "清除图像备份",
+ "backups.imagesFolder.notFound": "找不到备份文件夹",
+ "backups.imagesFolder.noneToDelete": "没有要删除的备份",
+ "backups.imagesFolder.deletedCount": "已删除的图像备份集:{count}",
+ "backups.imagesFolder.clearError": "清除备份时出错",
+ "backups.cache.title": "缓存备份",
+ "backups.cache.folder.name": "缓存备份文件夹",
+ "backups.cache.folder.desc": "打开包含缓存备份文件的文件夹",
+ "backups.cache.folder.openButton": "打开缓存备份文件夹",
+ "backups.pathLabel": "路径",
+ "backups.foundLabel": "找到备份",
+ "backups.cache.none": "没有可用的缓存备份",
+ "backups.cache.restore": "从备份恢复缓存",
+ "backups.cache.available": "可用备份:",
+ "backups.cache.selectPlaceholder": "-- 选择备份 --",
+ "common.add": "添加",
+ "common.refresh": "刷新",
+ "common.refreshing": "正在刷新...",
+ "common.processing": "处理中...",
+ "common.clearCache": "清除缓存",
+ "common.refreshCache": "刷新缓存",
+ "common.clear": "清除",
+ "common.clearing": "正在清除...",
+ "common.cancel": "取消",
+ "common.close": "关闭",
+ "units.kb": "KB",
+ "instructions.usageTitle": "用法:",
+ "instructions.notesTitle": "注意事项:",
+ "instructions.notes.saved": "压缩文件保存在",
+ "instructions.notes.originalUnchanged": "原始文件没有被修改",
+ "instructions.notes.recompressionSkipped": "由于缓存而跳过了重新压缩",
+ "progress.start": "开始...",
+ "progress.processing": "处理中",
+ "progress.skippedAlready": "已跳过(已压缩)",
+ "progress.skipped": "已跳过",
+ "progress.compressed": "已压缩",
+ "progress.error": "错误",
+ "progress.cancelling": "正在取消...",
+ "progress.cancelled": "已取消",
+ "status.loading": "正在加载...",
+ "status.indexing": "正在索引图像...",
+ "progress.completed": "完成",
+ "folders.noneInVault": "在仓库中找不到文件夹",
+ "folderSelect.title": "选择图像压缩的文件夹",
+ "folderSelect.selectLabel": "文件夹",
+ "folderSelect.root": "根文件夹",
+ "folderSelect.select": "选择",
+ "folderSelect.cancel": "取消",
+ "instructions.action.rightClick": "右键单击图像 →",
+ "instructions.action.commandPalette": "命令面板 →",
+ "savings.original": "原始大小",
+ "savings.current": "当前",
+ "savings.saved": "已节省",
+ "tooltip.savings.header": "节省空间的详细信息",
+ "tooltip.savings.original": "原始大小:",
+ "tooltip.savings.current": "当前大小:",
+ "tooltip.savings.saved": "节省空间:",
+ "tooltip.savings.filesProcessed": "处理的文件:",
+ "tooltip.savings.estimated": "估算文件"
+}
diff --git a/src-ts/locales/zh-tw.json b/src-ts/locales/zh-tw.json
new file mode 100644
index 0000000..81e8ab4
--- /dev/null
+++ b/src-ts/locales/zh-tw.json
@@ -0,0 +1,166 @@
+{
+ "settings.title": "設定",
+ "settings.loadFailed": "無法載入設定;使用了預設值",
+ "init.failed": "外掛程式初始化失敗。修復報告的錯誤後重新載入外掛程式。",
+ "migration.partialFailure": "舊版外掛程式資料遷移已完成,但有錯誤。檢查開發者控制台。",
+ "i18n.externalLoadFailed": "無法載入外部語言檔案",
+ "warning.wasmInitFailed": "WebAssembly 模組無法初始化。請重新載入外掛程式或報告錯誤。",
+ "command.compressInNote": "壓縮筆記中的所有影像",
+ "command.compressInFolder": "壓縮資料夾中的所有影像",
+ "command.compressAll": "壓縮儲存庫中的所有影像",
+ "command.moveCompressed": "移動壓縮檔案",
+ "context.compressImage": "壓縮影像",
+ "context.compressImagesInFolder": "壓縮資料夾中的影像",
+ "notice.cacheUpdated": "快取已更新",
+ "notice.cacheCleared": "快取已清除",
+ "notice.compressionDeferredDueToMove": "移動操作正在進行時壓縮會延遲",
+ "notice.operationFailed": "操作失敗。檢查開發者控制台。",
+ "validation.pathNotAllowed": "設定中不允許使用路徑",
+ "validation.outputFolder": "檔案位於輸出資料夾內",
+ "validation.alreadyCompressed": "檔案已經被壓縮",
+ "validation.tooSmall": "檔案太小",
+ "validation.bytes": "位元組",
+ "compress.error.fileAccess": "無法存取檔案",
+ "compress.error.unknown": "未知錯誤",
+ "compress.error.tooLarge": "影像太大,無法安全壓縮",
+ "compress.error.notSmaller": "壓縮後的檔案不小於原始檔案",
+ "compress.error.unsupportedFormat": "不支援的檔案格式",
+ "compress.error.copyCompressed": "無法複製壓縮檔案",
+ "compress.error.copyCompressedJpeg": "無法複製壓縮的 JPEG 檔案",
+ "compress.error.pngQuality": "PNG 編碼器無法滿足配置的品質範圍",
+ "section.quality": "壓縮品質",
+ "section.paths": "路徑",
+ "section.automation": "自動化",
+ "section.stats": "統計和快取",
+ "section.move": "移動壓縮檔案",
+ "section.cacheBackups": "快取備份",
+ "section.instructions": "使用說明",
+ "quality.png.name": "PNG 品質(最小-最大)",
+ "quality.png.desc": "PNG 壓縮的品質範圍(預設為 65-80)",
+ "quality.jpeg.name": "JPEG 品質",
+ "quality.jpeg.desc": "JPEG 壓縮品質 (1-95)",
+ "paths.allowedRoots.name": "允許的根資料夾",
+ "paths.allowedRoots.desc": "選擇允許壓縮的資料夾(空=所有路徑)",
+ "paths.allowedRoots.empty": "未選擇(到處壓縮)",
+ "paths.allowedRoots.pill.remove": "點選刪除",
+ "paths.allowedRoots.modal.placeholder": "選擇一個資料夾...",
+ "paths.allowedRoots.cannotAddRoot": "將清單留空以允許所有資料夾",
+ "paths.allowedRoots.clear": "清除列表",
+ "paths.output.name": "輸出資料夾",
+ "paths.output.desc": "存放壓縮檔案的資料夾名稱",
+ "auto.newFiles.name": "自動壓縮新檔案",
+ "auto.newFiles.desc": "新增至儲存庫時自動壓縮新圖片",
+ "auto.bg.name": "自動後台壓縮",
+ "auto.bg.desc": "當您不活動時在後台自動壓縮",
+ "auto.bg.threshold.name": "後台壓縮閾值",
+ "auto.bg.threshold.desc": "自動啟動後台壓縮所需的未壓縮影像數量",
+ "auto.bg.inactivity.name": "不活動閾值(分鐘)",
+ "auto.bg.inactivity.desc": "後台壓縮開始前無使用者活動的分鐘數",
+ "guard.disabled": "{id} 在壓縮期間暫時停用",
+ "guard.restored": "壓縮後已重新啟用 {id}",
+ "auto.retention.toggle.name": "自動刪除影像備份",
+ "auto.retention.toggle.desc": "按保留期限刪除影像備份",
+ "auto.retention.days.name": "備份保留(天)",
+ "auto.retention.days.desc": "刪除早於指定天數的備份",
+ "auto.move.toggle.name": "啟用壓縮檔案的自動移動",
+ "auto.move.toggle.desc": "達到閾值時自動移動壓縮檔案",
+ "auto.move.threshold.name": "自動移動閾值(檔案數)",
+ "auto.move.threshold.desc": "觸發自動移動所需的 'Compressed' 檔案數",
+ "auto.queueFull": "自動壓縮佇列已滿 ({max});新檔案被跳過",
+ "background.starting": "後台壓縮已開始。影像數:{count}",
+ "background.finished": "後台壓縮已完成。已壓縮影像數:{count}",
+ "stats.uncompressed.name": "未壓縮影像",
+ "stats.uncompressed.ready": "待壓縮影像",
+ "stats.cache.name": "快取條目",
+ "stats.cache.entries": "項目",
+ "stats.cache.size": "大小",
+ "cache.corruptSaved": "無法讀取快取。已儲存復原副本:",
+ "move.title": "移動壓縮檔案",
+ "move.ready": "待移動檔案",
+ "move.button": "移動",
+ "move.noCompressedFolder": "未找到 Compressed 資料夾",
+ "move.noneToMove": "沒有可移動的壓縮檔案",
+ "move.noneWithOriginals": "允許的根資料夾中沒有對應原始檔案的壓縮檔案",
+ "move.backupCreated": "已建立原始檔案和壓縮檔案的備份",
+ "move.backup.createdCount": "已備份的原始/壓縮檔案組:{count}",
+ "move.skip.ambiguousOriginal": "原始檔名不明確",
+ "move.skip.originalMissingBeforeBackup": "備份前缺少原始檔案",
+ "move.skip.compressedMissingBeforeBackup": "備份前缺少壓縮檔案",
+ "move.skip.originalModifiedDuringBackup": "備份期間原始內容已更改",
+ "move.skip.compressedModifiedDuringBackup": "備份期間壓縮檔案已變更",
+ "move.skip.originalContentChangedDuringBackup": "備份期間原始檔案內容已變更",
+ "move.skip.compressedContentChangedDuringBackup": "備份期間壓縮檔案內容已變更",
+ "move.skip.contentChangedDuringCopy": "複製備份時內容發生更改",
+ "move.skip.originalNotFoundAtMoveTime": "移動時未找到原始檔案",
+ "move.skip.selfMove": "壓縮檔案路徑與原始檔案路徑相同",
+ "move.skip.externalModification": "移動期間偵測到外部修改",
+ "move.skip.invalidBackupTask": "備份任務無效",
+ "move.skip.noOriginalCandidate": "未找到候選原始檔案",
+ "move.skip.unloading": "外掛程式正在卸載",
+ "move.warning.externalModification": "移動期間偵測到外部修改:{name}",
+ "backups.imagesFolder.name": "影像備份資料夾",
+ "backups.imagesFolder.desc": "開啟包含原始/壓縮影像備份的資料夾",
+ "backups.imagesFolder.openButton": "開啟影像備份資料夾",
+ "backups.imagesFolder.openError": "無法開啟影像備份資料夾",
+ "backups.imagesFolder.clearName": "清除影像備份",
+ "backups.imagesFolder.clearDesc": "刪除移動的原始/壓縮影像的備份",
+ "backups.imagesFolder.clearButton": "清除影像備份",
+ "backups.imagesFolder.notFound": "找不到備份資料夾",
+ "backups.imagesFolder.noneToDelete": "沒有要刪除的備份",
+ "backups.imagesFolder.deletedCount": "已刪除的影像備份集:{count}",
+ "backups.imagesFolder.clearError": "清除備份時出錯",
+ "backups.cache.title": "快取備份",
+ "backups.cache.folder.name": "快取備份資料夾",
+ "backups.cache.folder.desc": "開啟包含快取備份檔案的資料夾",
+ "backups.cache.folder.openButton": "開啟快取備份資料夾",
+ "backups.pathLabel": "路徑",
+ "backups.foundLabel": "找到備份",
+ "backups.cache.none": "沒有可用的快取備份",
+ "backups.cache.restore": "從備份恢復快取",
+ "backups.cache.available": "可用備份:",
+ "backups.cache.selectPlaceholder": "-- 選擇備份 --",
+ "common.add": "新增",
+ "common.refresh": "重新整理",
+ "common.refreshing": "正在重新整理...",
+ "common.processing": "處理中...",
+ "common.clearCache": "清除快取",
+ "common.refreshCache": "重新整理快取",
+ "common.clear": "清除",
+ "common.clearing": "正在清除...",
+ "common.cancel": "取消",
+ "common.close": "關閉",
+ "units.kb": "KB",
+ "instructions.usageTitle": "用法:",
+ "instructions.notesTitle": "注意事項:",
+ "instructions.notes.saved": "壓縮檔案保存在",
+ "instructions.notes.originalUnchanged": "原始檔案沒有被修改",
+ "instructions.notes.recompressionSkipped": "由於快取而跳過了重新壓縮",
+ "progress.start": "開始...",
+ "progress.processing": "處理中",
+ "progress.skippedAlready": "已跳過(已壓縮)",
+ "progress.skipped": "已跳過",
+ "progress.compressed": "已壓縮",
+ "progress.error": "錯誤",
+ "progress.cancelling": "正在取消...",
+ "progress.cancelled": "已取消",
+ "status.loading": "正在載入...",
+ "status.indexing": "正在索引影像...",
+ "progress.completed": "完成",
+ "folders.noneInVault": "在儲存庫中找不到資料夾",
+ "folderSelect.title": "選擇影像壓縮的資料夾",
+ "folderSelect.selectLabel": "資料夾",
+ "folderSelect.root": "根資料夾",
+ "folderSelect.select": "選擇",
+ "folderSelect.cancel": "取消",
+ "instructions.action.rightClick": "右鍵圖片 →",
+ "instructions.action.commandPalette": "命令面板 →",
+ "savings.original": "原始大小",
+ "savings.current": "目前",
+ "savings.saved": "已節省",
+ "tooltip.savings.header": "節省空間的詳細資訊",
+ "tooltip.savings.original": "原始大小:",
+ "tooltip.savings.current": "目前大小:",
+ "tooltip.savings.saved": "節省空間:",
+ "tooltip.savings.filesProcessed": "處理的檔案:",
+ "tooltip.savings.estimated": "估算檔案"
+}
diff --git a/src-ts/move-service.ts b/src-ts/move-service.ts
index 851b02a..324a3f6 100644
--- a/src-ts/move-service.ts
+++ b/src-ts/move-service.ts
@@ -14,6 +14,9 @@ type CompressedFileRecord = {
size: number;
originalPath?: string;
compressedSha256?: string;
+ originalSizeBeforeMove?: number;
+ originalMtimeMsBeforeMove?: number;
+ originalSha256BeforeMove?: string;
moveSkipReason?: string;
moveCandidates?: string[];
};
@@ -427,13 +430,6 @@ export class MoveService {
compressedFile.moveSkipReason = this.getMoveText("move.skip.compressedMissingBeforeBackup");
return { compressedFile, skipped: true };
}
- const [originalSha256, compressedSha256] = await Promise.all([
- streamHashSha256(originalPath),
- streamHashSha256(compressedFile.compressedPath)
- ]);
- if (this.isUnloading()) {
- return this.skipForUnload(compressedFile);
- }
const relativeToVault = path.relative(this.getVaultBasePath(), originalPath);
const compressedRelativePath = path.relative(this.getVaultBasePath(), compressedFile.compressedPath);
// Defensive: originals/compressed are expected inside the vault (allowed roots). Refuse to
@@ -445,6 +441,29 @@ export class MoveService {
compressedFile.moveSkipReason = this.getMoveText("move.skip.originalMissingBeforeBackup");
return { compressedFile, skipped: true };
}
+ const pendingMove = await this.plugin.cache.resolvePendingMoveEntry({
+ path: normalizeVaultPath(relativeToVault),
+ stat: { mtime: originalStats.mtimeMs, size: originalStats.size }
+ }, normalizeVaultPath(compressedRelativePath));
+ if (pendingMove.status === "conflict") {
+ compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
+ return { compressedFile, skipped: true };
+ }
+ if (pendingMove.status === "match" && pendingMove.entry) {
+ const outputMatchesStats = this.plugin.cache.normalizeMtime(pendingMove.entry.outputMtime) === this.plugin.cache.normalizeMtime(compressedStats.mtimeMs)
+ && Number(pendingMove.entry.outputSize) === compressedStats.size;
+ if (!outputMatchesStats) {
+ compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
+ return { compressedFile, skipped: true };
+ }
+ }
+ const [originalSha256, compressedSha256] = await Promise.all([
+ streamHashSha256(originalPath),
+ streamHashSha256(compressedFile.compressedPath)
+ ]);
+ if (this.isUnloading()) {
+ return this.skipForUnload(compressedFile);
+ }
return {
compressedFile,
originalPath,
@@ -534,6 +553,9 @@ export class MoveService {
// BR-H1: carry the backup-verified content hash so the destructive overwrite
// (moveSingleFile) can re-verify content, not just byte length.
compressedFile.compressedSha256 = task.compressedSha256;
+ compressedFile.originalSizeBeforeMove = task.originalSize;
+ compressedFile.originalMtimeMsBeforeMove = task.originalMtimeMs;
+ compressedFile.originalSha256BeforeMove = task.originalSha256;
result.files.push(compressedFile);
} catch (error) {
result.errorCount++;
@@ -633,6 +655,19 @@ export class MoveService {
compressedFile.moveSkipReason = this.getMoveText("move.skip.unloading");
return;
}
+ if (compressedFile.originalSizeBeforeMove !== undefined
+ && compressedFile.originalMtimeMsBeforeMove !== undefined
+ && (originalStats.size !== compressedFile.originalSizeBeforeMove || originalStats.mtimeMs !== compressedFile.originalMtimeMsBeforeMove)) {
+ compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
+ return;
+ }
+ if (compressedFile.originalSha256BeforeMove) {
+ const currentOriginalSha256 = await streamHashSha256(originalPath);
+ if (currentOriginalSha256 !== compressedFile.originalSha256BeforeMove) {
+ compressedFile.moveSkipReason = this.getMoveText("move.skip.externalModification");
+ return;
+ }
+ }
const vaultBasePath = this.getVaultBasePath();
const originalRelativePath = normalizeVaultPath(path.relative(vaultBasePath, originalPath));
const compressedRelativePath = normalizeVaultPath(path.relative(vaultBasePath, compressedFile.compressedPath));
diff --git a/src-ts/plugin.ts b/src-ts/plugin.ts
index 688c807..f2b8538 100644
--- a/src-ts/plugin.ts
+++ b/src-ts/plugin.ts
@@ -64,11 +64,7 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
imageIndexConfigKey: string;
indexRefreshTimers: Map;
readonly BACKGROUND_COMPRESSION_NOTICE_COOLDOWN_MS: number;
- readonly GHOST_CLEANUP_COMPRESSED_THRESHOLD: number;
- readonly STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD: number;
backgroundCompressionNoticeAt: number;
- ghostEntryDirtyCount: number;
- staleCacheDirtyCount: number;
compressionLimiter: ConcurrencyLimiter;
pluginsToDisableDuringCompression: string[];
pluginGuardService: PluginGuardService;
@@ -109,11 +105,7 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
this.imageIndexConfigKey = "";
this.indexRefreshTimers = new Map();
this.BACKGROUND_COMPRESSION_NOTICE_COOLDOWN_MS = 30 * 60 * 1000;
- this.GHOST_CLEANUP_COMPRESSED_THRESHOLD = 100;
- this.STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD = 100;
this.backgroundCompressionNoticeAt = 0;
- this.ghostEntryDirtyCount = 0;
- this.staleCacheDirtyCount = 0;
this.compressionLimiter = new ConcurrencyLimiter(1);
this.pluginsToDisableDuringCompression = ["obsidian-paste-image-rename"];
this.pluginGuardService = new PluginGuardService(this);
@@ -305,6 +297,7 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
try {
this.statusBarItem?.setText?.(t(this.app, "status.indexing"));
await this.rebuildImageIndex("startup");
+ await this.cache.compactCache();
if (!this.isUnloading) {
await this.statusBarController.update();
}
@@ -314,21 +307,6 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
}
async runStartupMaintenance() {
- let cacheChanged = false;
- if (this.settings.autoCleanupGhostsOnStart) {
- try {
- const removed = await this.cleanupGhostEntries();
- cacheChanged = cacheChanged || removed > 0;
- } catch (e) {
- console.error(getLogTag(this), 'Startup ghost cleanup error:', e);
- }
- }
- try {
- const pruned = await this.cache.pruneStaleCacheEntries(this.settings.cacheRetentionMonths);
- cacheChanged = cacheChanged || pruned > 0;
- } catch (e) {
- console.error(getLogTag(this), 'Startup cache retention error:', e);
- }
try {
const backupDir = this.getBackupStoragePaths().originalFilesBackups;
if (this.settings.autoBackupsRetentionEnabled) {
@@ -341,15 +319,10 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
if (this.settings.autoMoveCompressedEnabled) {
try {
await this.tryAutoMoveCompressed();
- cacheChanged = true;
} catch (e) {
console.error(getLogTag(this), 'Startup auto-move error:', e);
}
}
- if (cacheChanged) {
- await this.rebuildImageIndex("startup-maintenance");
- await this.statusBarController.update();
- }
}
async tryAutoMoveCompressed() {
@@ -604,6 +577,7 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
} else {
this.removeImageIndexFile(file?.path);
}
+ await this.cache.compactDeletedPath(file?.path);
this.scheduleStatusBarUpdate("vault-delete");
}
async handleVaultRename(file: obsidian.TAbstractFile, oldPath: string) {
@@ -783,10 +757,6 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
await this.cache.createBackup();
// Update savings indicator in settings
await this.updateSavingsIndicatorInSettings();
- await this.maybeCleanupGhostEntriesAfterCompression(compressed);
- if (await this.maybePruneStaleCacheEntriesAfterCompression(compressed) > 0) {
- await this.rebuildImageIndex("cache-retention");
- }
}
return {
compressed,
@@ -801,50 +771,6 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
this.compressionWorkflowsInFlight--;
}
}
- async maybeCleanupGhostEntriesAfterCompression(compressedCount: number) {
- if (this.isUnloading || !this.cache?.isAcceptingWrites?.()) {
- return 0;
- }
- const safeCompressedCount = typeof compressedCount === "number" && Number.isFinite(compressedCount) ? Math.max(0, Math.trunc(compressedCount)) : 0;
- if (safeCompressedCount <= 0) {
- return 0;
- }
- this.ghostEntryDirtyCount += safeCompressedCount;
- if (this.ghostEntryDirtyCount < this.GHOST_CLEANUP_COMPRESSED_THRESHOLD) {
- return 0;
- }
- try {
- const removedCount = await this.cleanupGhostEntries();
- this.ghostEntryDirtyCount = 0;
- return removedCount;
- } catch (error) {
- this.ghostEntryDirtyCount = this.GHOST_CLEANUP_COMPRESSED_THRESHOLD;
- console.error(getLogTag(this), "Automatic ghost cleanup error:", error);
- return 0;
- }
- }
- async maybePruneStaleCacheEntriesAfterCompression(compressedCount: number) {
- if (this.isUnloading || !this.cache?.isAcceptingWrites?.()) {
- return 0;
- }
- const safeCompressedCount = typeof compressedCount === "number" && Number.isFinite(compressedCount) ? Math.max(0, Math.trunc(compressedCount)) : 0;
- if (safeCompressedCount <= 0) {
- return 0;
- }
- this.staleCacheDirtyCount += safeCompressedCount;
- if (this.staleCacheDirtyCount < this.STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD) {
- return 0;
- }
- try {
- const prunedCount = await this.cache.pruneStaleCacheEntries(this.settings.cacheRetentionMonths);
- this.staleCacheDirtyCount = 0;
- return prunedCount;
- } catch (error) {
- this.staleCacheDirtyCount = this.STALE_CACHE_PRUNE_COMPRESSED_THRESHOLD;
- console.error(getLogTag(this), "Automatic cache retention error:", error);
- return 0;
- }
- }
// Batch compression in background (no modal)
async processBatchCompressionBackground(files: obsidian.TFile[]) {
const now = Date.now();
@@ -1202,9 +1128,6 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
await this.cache.addToCache(cacheKey, sizeSnapshot, file, this.savingsCalculator.getCompressedFilePath(pathSnapshot), pathSnapshot, mtimeSnapshot);
await this.cache.createBackup();
await this.updateImageIndexForFile(file);
- if (await this.maybePruneStaleCacheEntriesAfterCompression(1) > 0) {
- await this.rebuildImageIndex("cache-retention");
- }
await this.statusBarController.update();
// Update savings indicator in settings if settings tab is open
@@ -1443,14 +1366,10 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
}
async getStatsSnapshot() {
const imageStats = await this.savingsCalculator.collectImageStats(this.getAllImageFiles());
- const [ghostCount, compressedFilesCount] = await Promise.all([
- this.getGhostEntriesCount(),
- this.moveService.getCompressedFilesCount()
- ]);
+ const compressedFilesCount = await this.moveService.getCompressedFilesCount();
return {
...imageStats,
cacheStats: this.cache.getCacheStats(),
- ghostCount,
compressedFilesCount
};
}
@@ -1479,17 +1398,6 @@ export default class LocalImageCompressPlugin extends obsidian.Plugin {
throw error;
}
}
- // Get number of ghost entries in cache
- async getGhostEntriesCount() {
- return await this.cache.getGhostEntriesCount();
- }
- // Remove ghost entries from cache
- async cleanupGhostEntries() {
- const removedCount = await this.cache.cleanupGhostEntries();
- await this.rebuildImageIndex("cleanup-ghosts");
- await this.statusBarController.update();
- return removedCount;
- }
// ========================================================================
// STATUS BAR
// ========================================================================
diff --git a/src-ts/savings-calculator.ts b/src-ts/savings-calculator.ts
index cf3858c..7a95b12 100644
--- a/src-ts/savings-calculator.ts
+++ b/src-ts/savings-calculator.ts
@@ -285,7 +285,7 @@ export class SavingsCalculator {
const currentFormatted = this.formatFileSize(savings.currentSize || 0);
const savedFormatted = this.formatFileSize(savings.savedSize || 0);
const estimatedIndicator = (savings.estimatedFiles || 0) > 0 ? " ~" : "";
- const estimatedText = (savings.estimatedFiles || 0) > 0 ? ` (${savings.estimatedFiles} ${t(this.plugin.app, "tooltip.savings.estimated")})` : "";
+ const estimatedText = (savings.estimatedFiles || 0) > 0 ? ` (${t(this.plugin.app, "tooltip.savings.estimated")}: ${savings.estimatedFiles})` : "";
return {
originalFormatted,
diff --git a/src-ts/settings-tab.ts b/src-ts/settings-tab.ts
index 7f9e3d8..faa1390 100644
--- a/src-ts/settings-tab.ts
+++ b/src-ts/settings-tab.ts
@@ -47,7 +47,6 @@ export class SettingsTab extends obsidian.PluginSettingTab {
_savingsTooltipDocuments: Set;
currentStatsSnapshot: StatsSnapshot | null;
cacheStatsElement: HTMLElement | null;
- ghostStatsElement: HTMLElement | null;
uncompressedStatsElement: HTMLElement | null;
compressedFilesCountElement: HTMLElement | null;
savingsHostElement: HTMLElement | null;
@@ -66,7 +65,6 @@ export class SettingsTab extends obsidian.PluginSettingTab {
this._savingsTooltipDocuments = new Set();
this.currentStatsSnapshot = null;
this.cacheStatsElement = null;
- this.ghostStatsElement = null;
this.uncompressedStatsElement = null;
this.compressedFilesCountElement = null;
this.savingsHostElement = null;
@@ -207,9 +205,6 @@ export class SettingsTab extends obsidian.PluginSettingTab {
row.settingEl.addClass("tiny-local-subsetting");
}
}
- formatCountMessage(key: string, count: number) {
- return t(this.plugin.app, key, { count });
- }
getSavingsBarWidths(savings: SavingsSnapshot) {
const originalSize = Number.isFinite(savings.originalSize) && savings.originalSize > 0 ? savings.originalSize : 0;
const savedSize = Number.isFinite(savings.savedSize) && savings.savedSize > 0 ? savings.savedSize : 0;
@@ -336,16 +331,13 @@ export class SettingsTab extends obsidian.PluginSettingTab {
this.currentStatsSnapshot = stats;
if (this.cacheStatsElement) {
const cacheStats = stats.cacheStats;
- this.cacheStatsElement.setText(`${cacheStats.total} ${t(this.plugin.app, "stats.cache.entries")}, ${t(this.plugin.app, "stats.cache.size")}: ${Math.round(cacheStats.size / 1024)} ${t(this.plugin.app, "units.kb")}`);
- }
- if (this.ghostStatsElement) {
- this.ghostStatsElement.setText(`${stats.ghostCount} ${t(this.plugin.app, "stats.ghosts.pointToMissing")}`);
+ this.cacheStatsElement.setText(`${t(this.plugin.app, "stats.cache.entries")}: ${cacheStats.total}, ${t(this.plugin.app, "stats.cache.size")}: ${Math.round(cacheStats.size / 1024)} ${t(this.plugin.app, "units.kb")}`);
}
if (this.uncompressedStatsElement) {
- this.uncompressedStatsElement.setText(`${stats.uncompressedImages} ${t(this.plugin.app, "stats.uncompressed.ready")}`);
+ this.uncompressedStatsElement.setText(`${t(this.plugin.app, "stats.uncompressed.ready")}: ${stats.uncompressedImages}`);
}
if (this.compressedFilesCountElement) {
- this.compressedFilesCountElement.setText(`${stats.compressedFilesCount} ${t(this.plugin.app, "move.ready")}`);
+ this.compressedFilesCountElement.setText(`${t(this.plugin.app, "move.ready")}: ${stats.compressedFilesCount}`);
}
if (this.savingsHostElement) {
this.cleanupSavingsTooltips();
@@ -389,7 +381,6 @@ export class SettingsTab extends obsidian.PluginSettingTab {
// Keep references to elements for updates
this.cacheStatsElement = null;
- this.ghostStatsElement = null;
this.uncompressedStatsElement = null;
this.compressedFilesCountElement = null;
this.savingsHostElement = null;
@@ -605,16 +596,11 @@ export class SettingsTab extends obsidian.PluginSettingTab {
})
);
this.applySubsettingVisibility(this.plugin.settings.autoMoveCompressedEnabled, autoMoveRow);
- // Add at the end of Automation section
- new obsidian.Setting(containerEl).setName(t(this.plugin.app, "auto.cleanupGhosts.name")).setDesc(t(this.plugin.app, "auto.cleanupGhosts.desc")).addToggle((toggle) => toggle.setValue(this.plugin.settings.autoCleanupGhostsOnStart).onChange(async (value) => {
- this.plugin.settings.autoCleanupGhostsOnStart = value;
- await this.plugin.saveSettings();
- }));
// ========================================================================
// STATISTICS & CACHE
// ========================================================================
this.renderSection(containerEl, "section.stats");
- const uncompressedSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.uncompressed.name")).setDesc(`${statsSnapshot.uncompressedImages} ${t(this.plugin.app, "stats.uncompressed.ready")}`);
+ const uncompressedSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.uncompressed.name")).setDesc(`${t(this.plugin.app, "stats.uncompressed.ready")}: ${statsSnapshot.uncompressedImages}`);
this.uncompressedStatsElement = uncompressedSetting.descEl;
uncompressedSetting.addButton((button) => button.setButtonText(t(this.plugin.app, "common.refresh")).onClick(async () => {
await this.runButtonTask(button, "common.refresh", "common.refreshing", async () => {
@@ -624,7 +610,7 @@ export class SettingsTab extends obsidian.PluginSettingTab {
});
}));
const cacheStats = statsSnapshot.cacheStats;
- const cacheSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.cache.name")).setDesc(`${cacheStats.total} ${t(this.plugin.app, "stats.cache.entries")}, ${t(this.plugin.app, "stats.cache.size")}: ${Math.round(cacheStats.size / 1024)} ${t(this.plugin.app, "units.kb")}`);
+ const cacheSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.cache.name")).setDesc(`${t(this.plugin.app, "stats.cache.entries")}: ${cacheStats.total}, ${t(this.plugin.app, "stats.cache.size")}: ${Math.round(cacheStats.size / 1024)} ${t(this.plugin.app, "units.kb")}`);
this.cacheStatsElement = cacheSetting.descEl;
cacheSetting.addButton((button) => this.setDestructiveButton(button).setButtonText(t(this.plugin.app, "common.clearCache")).onClick(async () => {
await this.runButtonTask(button, "common.clearCache", "common.clearing", async () => {
@@ -641,32 +627,13 @@ export class SettingsTab extends obsidian.PluginSettingTab {
new obsidian.Notice(`${getPluginName(this)}: ${t(this.plugin.app, "notice.cacheUpdated")}`);
});
}));
- new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.cache.retention.name")).setDesc(t(this.plugin.app, "stats.cache.retention.desc")).addSlider((slider) => slider
- .setLimits(1, 60, 1)
- .setValue(this.plugin.settings.cacheRetentionMonths)
- .setDynamicTooltip()
- .onChange((value) => {
- this.plugin.settings.cacheRetentionMonths = value;
- this.debouncedSaveSettings();
- })
- );
- const ghostSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "stats.ghosts.name")).setDesc(`${statsSnapshot.ghostCount} ${t(this.plugin.app, "stats.ghosts.pointToMissing")}`);
- this.ghostStatsElement = ghostSetting.descEl;
- ghostSetting.addButton((button) => this.setDestructiveButton(button).setButtonText(t(this.plugin.app, "common.clearGhosts")).onClick(async () => {
- await this.runButtonTask(button, "common.clearGhosts", "common.clearing", async () => {
- const removedCount = await this.plugin.cleanupGhostEntries();
- await this.updateStats();
- new obsidian.Notice(`${getPluginName(this)}: ${this.formatCountMessage("stats.ghosts.clearedCount", removedCount)}`);
- });
- }));
-
// ========================================================================
// AUTO MOVE
// ========================================================================
this.renderSection(containerEl, "section.move");
// Button: move compressed files
- const moveSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "move.title")).setDesc(`${statsSnapshot.compressedFilesCount} ${t(this.plugin.app, "move.ready")}`);
+ const moveSetting = new obsidian.Setting(containerEl).setName(t(this.plugin.app, "move.title")).setDesc(`${t(this.plugin.app, "move.ready")}: ${statsSnapshot.compressedFilesCount}`);
this.compressedFilesCountElement = moveSetting.descEl;
moveSetting.addButton((button) => this.setDestructiveButton(button).setButtonText(t(this.plugin.app, "move.button")).onClick(async () => {
await this.runButtonTask(button, "move.button", "common.processing", async () => {
diff --git a/src-ts/settings.ts b/src-ts/settings.ts
index abef1e0..5689a96 100644
--- a/src-ts/settings.ts
+++ b/src-ts/settings.ts
@@ -14,8 +14,6 @@ export interface LocalImageCompressSettings {
autoBackgroundCompression: boolean;
autoBackgroundThreshold: number;
inactivityThresholdMinutes: number;
- cacheRetentionMonths: number;
- autoCleanupGhostsOnStart: boolean;
autoBackupsRetentionEnabled: boolean;
autoBackupsRetentionDays: number;
autoMoveCompressedEnabled: boolean;
@@ -34,8 +32,6 @@ export const DEFAULT_SETTINGS: LocalImageCompressSettings = {
autoBackgroundCompression: true,
autoBackgroundThreshold: 50,
inactivityThresholdMinutes: 2,
- cacheRetentionMonths: 12,
- autoCleanupGhostsOnStart: false,
autoBackupsRetentionEnabled: false,
autoBackupsRetentionDays: 30,
autoMoveCompressedEnabled: false,
@@ -43,7 +39,7 @@ export const DEFAULT_SETTINGS: LocalImageCompressSettings = {
};
const REMOVED_PASTE_RENAME_GUARD_SETTING = "disablePasteImageRename" + "DuringCompression";
-const REMOVED_TECHNICAL_SETTING_KEYS = [
+const REMOVED_SETTING_KEYS = [
"pngquantPath",
"mozjpegPath",
"pluginGuard" + "TimeoutMs",
@@ -52,6 +48,8 @@ const REMOVED_TECHNICAL_SETTING_KEYS = [
"wasmInit" + "TimeoutSeconds",
"maxInput" + "SizeMB",
"maxImagePixels" + "Millions",
+ "cacheRetentionMonths",
+ "autoCleanupGhostsOnStart",
REMOVED_PASTE_RENAME_GUARD_SETTING
];
@@ -101,7 +99,7 @@ function normalizePngQuality(value: unknown): PngQualitySettings {
export function normalizeSettings(loadedData: unknown): LocalImageCompressSettings {
const rawSource = loadedData && typeof loadedData === "object" ? loadedData as Partial & Record : {};
const source = { ...rawSource };
- for (const key of REMOVED_TECHNICAL_SETTING_KEYS) {
+ for (const key of REMOVED_SETTING_KEYS) {
delete source[key];
}
return {
@@ -118,8 +116,6 @@ export function normalizeSettings(loadedData: unknown): LocalImageCompressSettin
autoBackgroundCompression: normalizeBoolean(source.autoBackgroundCompression, DEFAULT_SETTINGS.autoBackgroundCompression),
autoBackgroundThreshold: clampInteger(source.autoBackgroundThreshold, DEFAULT_SETTINGS.autoBackgroundThreshold, 10, 1000),
inactivityThresholdMinutes: clampInteger(source.inactivityThresholdMinutes, DEFAULT_SETTINGS.inactivityThresholdMinutes, 1, 60),
- cacheRetentionMonths: clampInteger(source.cacheRetentionMonths, DEFAULT_SETTINGS.cacheRetentionMonths, 1, 60),
- autoCleanupGhostsOnStart: normalizeBoolean(source.autoCleanupGhostsOnStart, DEFAULT_SETTINGS.autoCleanupGhostsOnStart),
autoBackupsRetentionEnabled: normalizeBoolean(source.autoBackupsRetentionEnabled, DEFAULT_SETTINGS.autoBackupsRetentionEnabled),
autoBackupsRetentionDays: clampInteger(source.autoBackupsRetentionDays, DEFAULT_SETTINGS.autoBackupsRetentionDays, 1, 365),
autoMoveCompressedEnabled: normalizeBoolean(source.autoMoveCompressedEnabled, DEFAULT_SETTINGS.autoMoveCompressedEnabled),
diff --git a/src-ts/status-bar-controller.ts b/src-ts/status-bar-controller.ts
index 1af5529..8a69aef 100644
--- a/src-ts/status-bar-controller.ts
+++ b/src-ts/status-bar-controller.ts
@@ -76,7 +76,7 @@ export class StatusBarController {
const accessibleStatusText = `${getPluginName(this.plugin)}: ${statusText}`;
this.plugin.statusBarItem.setText(statusText);
this.plugin.statusBarItem.setAttribute?.("aria-label", accessibleStatusText);
- this.plugin.statusBarItem.setAttribute?.("title", accessibleStatusText);
+ this.plugin.statusBarItem.removeAttribute?.("title");
this.plugin.statusBarItem.show();
this.plugin.statusBarItem.removeClass("tiny-local-compressing");
this.plugin.statusBarItem.removeClass("tiny-local-status-attention");
diff --git a/src-ts/types.ts b/src-ts/types.ts
index d892b8d..b999439 100644
--- a/src-ts/types.ts
+++ b/src-ts/types.ts
@@ -92,10 +92,15 @@ export interface ImageStatsSnapshot {
export interface StatsSnapshot extends ImageStatsSnapshot {
cacheStats: CacheStats;
- ghostCount: number;
compressedFilesCount: number;
}
+export interface CacheCompactionResult {
+ removed: number;
+ missingFilesRemoved: number;
+ supersededRemoved: number;
+}
+
export interface FileStatsLike {
size?: unknown;
mtime?: unknown;